#swiftui #combine
#swiftui #объединить
Вопрос:
У меня есть тип, который отображает a Timer.TimerPublisher
, который вы можете увидеть ниже:
import Combine
import Foundation
struct TimerClient {
// MARK: Properties
var timerValueChange: () -> AnyPublisher<Date, Never>
// MARK: Initialization
init(
timerValueChange: @escaping () -> AnyPublisher<Date, Never>
) {
self.timerValueChange = timerValueChange
}
}
extension TimerClient {
// MARK: Properties
static var live: Self {
Self {
return Timer
.TimerPublisher(
interval: 1,
runLoop: .main,
mode: .common
)
.autoconnect()
.share()
.eraseToAnyPublisher()
}
}
}
У меня есть View
, который используется для сохранения новых событий. В нее вводится модель представления, которая использует TimerClient
, чтобы я мог отключить Save
кнопку, если пользователи пытаются сохранить событие с прошлой датой:
import Combine
final class CountdownEventEntryViewModel: ObservableObject {
// MARK: Properties
private let nowSubject = CurrentValueSubject<Date, Never>(Date())
private let calendar: Calendar
private let timerClient: TimerClient
private var cancellables = Set<AnyCancellable>()
@Published var eventTitle = ""
@Published var isAllDay = false
@Published var eventDate = Date()
@Published var eventTime = Date()
@Published var shouldDisableSaveButton = true
// MARK: Initialization
init(
calendar: Calendar = .autoupdatingCurrent,
timerClient: TimerClient
) {
self.calendar = calendar
self.timerClient = timerClient
observeTimer()
observeCurrentDateChanges()
}
// MARK: UI Configuration
private func disableSaveButton() -> Bool {
if eventTitle.trimmingCharacters(in: .whitespaces).isEmpty {
return true
}
if isAllDay {
let startOfToday = calendar.startOfDay(for: nowSubject.value)
let startOfSelectedDate = calendar.startOfDay(for: eventDate)
return startOfSelectedDate <= startOfToday
} else {
return normalizedSelectedDate() <= nowSubject.value
}
}
private func observeTimer() {
timerClient.timerValueChange().sink(receiveValue: { [weak self] newDate in
self?.nowSubject.send(newDate)
})
.store(in: amp;cancellables)
}
private func observeCurrentDateChanges() {
nowSubject.sink(receiveValue: { [weak self] _ in
self?.shouldDisableSaveButton = self?.disableSaveButton() ?? false
})
.store(in: amp;cancellables)
}
}
Модель представления работает хорошо, и она обновляет Save
кнопку, если предпринимается попытка выбрать прошлую дату:
import SwiftUI
struct CountdownEventEntryView: View {
// MARK: Properties
@ObservedObject var viewModel: CountdownEventEntryViewModel
var body: some View {
Form {
Section {
TextField(
viewModel.eventTitlePlaceholderKey,
text: $viewModel.eventTitle
)
}
Section {
Toggle(
isOn: $viewModel.isAllDay.animation(),
label: {
Text(viewModel.isAllDayLabelTextKey)
}
)
DatePicker(
viewModel.eventDatePickerLabelTextKey,
selection: $viewModel.eventDate,
displayedComponents: [.date]
)
if !viewModel.isAllDay {
DatePicker(
viewModel.eventTimePickerLabelTextKey,
selection: $viewModel.eventTime,
displayedComponents: [.hourAndMinute]
)
}
}
}
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Text(viewModel.saveButtonTitleKey)
}
.disabled(viewModel.shouldDisableSaveButton)
}
}
}
}
After the entry view is dismissed, the List
that displays saved events is updated according to the following view model:
import Combine
import CoreData
final class CountdownEventsViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate {
// MARK: Properties
private let calendar: Calendar
private let timerClient: TimerClient
private var cancellables = Set<AnyCancellable>()
@Published var now = Date()
@Published var countdownEvents = [CountdownEvent]()
// MARK: Initialization
init(
calendar: Calendar = .autoupdatingCurrent,
timerClient: TimerClient
) {
self.calendar = calendar
self.timerClient = timerClient
super.init()
observeTimer()
fetchCountdownEvents()
}
// MARK: Timer Observation
private func observeTimer() {
timerClient.timerValueChange().sink(receiveValue: { [weak self] newDate in
self?.now = newDate
})
.store(in: amp;cancellables)
}
// MARK: Event Display
func formattedTitle(for event: CountdownEvent) -> String {
event.title ?? untitledLabelKey
}
func formattedDate(for event: CountdownEvent) -> String {
guard let date = event.date else {
return dateUnknownLabelKey
}
if event.isAllDay {
return DateFormatter.dateOnlyFormatter.string(from: date)
}
return DateFormatter.dateAndTimeFormatter.string(from: date)
}
func formattedTimeRemaining(from date: Date, to event: CountdownEvent) -> String {
guard let eventDate = event.date else {
return dateUnknownLabelKey
}
let allowedComponents: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second]
let dateComponents = calendar.dateComponents(allowedComponents, from: date, to: eventDate)
guard let formatted = DateComponentsFormatter.eventTimeRemainingFormatter.string(from: dateComponents) else {
return dateUnknownLabelKey
}
return formatted
}
}
Модель представления используется в a View
, которая отображает все сохраненные элементы в списке:
import SwiftUI
struct CountdownEventsView: View {
// MARK: Properties
@ObservedObject private var viewModel: CountdownEventsViewModel
@State private var showEventEntry = false
@State private var now = Date()
var body: some View {
List {
Section {
ForEach(viewModel.countdownEvents) { event in
VStack(alignment: .leading) {
Text(viewModel.formattedTitle(for: event))
Text(viewModel.formattedDate(for: event))
Text(viewModel.formattedTimeRemaining(from: now, to: event))
}
}
}
}
.toolbar {
ToolbarItem(placement: .navigation) {
HStack(spacing: 30) {
Button(action: showSettings) {
Label(viewModel.settingsButtonTitleKey, systemImage: viewModel.settingsButtonImageName)
}
Button(action: {
showEventEntry = true
}, label: {
Label(
title: { Text(viewModel.addEventButtonTitleKey) },
icon: { Image(systemName: viewModel.addEventButtonImageName) }
)
})
}
}
.sheet(
isPresented: $showEventEntry,
content: {
NavigationView {
CountdownEventEntryView(
viewModel:
CountdownEventEntryViewModel(
timerClient: .live
)
)
}
}
)
.onReceive(viewModel.$now, perform: { now in
self.now = now
})
}
// MARK: Initialization
init(viewModel: CountdownEventsViewModel) {
self.viewModel = viewModel
}
}
Это работает так, как ожидалось, и Text
элементы обновляются с ожидаемыми значениями. Кроме того, я могу прокручивать List
и видеть Text
обновление значений благодаря добавлению Timer
в цикл main
выполнения и common
режим. Однако, когда я перехожу к представлению записи событий, модель представления, похоже, сбрасывается при срабатывании таймера.
Ниже вы можете видеть, что введенный текст сбрасывается при каждом срабатывании таймера:
Кажется, что моя модель представления каким-то образом воссоздается, но обе модели представления являются @ObservableObject
s, поэтому я не уверен, почему я вижу сброс значений после срабатывания таймера. Все DatePicker
значения , Toggle
, TextField
, и Button
сбрасываются до значений по умолчанию при срабатывании таймера.
Чего мне не хватает, из-за чего текст очищается при срабатывании таймера?
Если это поможет, проект находится здесь. Обязательно используйте save-countdown-events
ветку. Кроме того, я моделирую типы своих клиентов после обучения Point-Free о внедрении зависимостей (code), если это помогает предоставить больше контекста.
Комментарии:
1. Честно говоря, здесь много кода. Хороший подход к отладке проблемы — попытаться изолировать ее, удалив вещи, которые не связаны, что не случайно также приводит к более целенаправленному вопросу с помощью минимально воспроизводимого примера. Можете ли вы попытаться свести его к минимуму?
2. Кроме того, почему вы используете
Timer only to check if the time is not in the past. You don't need a timer for that; you just need to call
Date()`для получения текущего времени и сравнения`3. @NewDev Конечно, я постараюсь сократить код и предоставить более минимизированный образец. Я использовал таймер просто потому, что пытаюсь лучше понять Combine. Я хотел использовать его везде, где только можно, в этом небольшом проекте, даже если это излишне, чтобы мне было с ним удобнее.