Почему модель представления @ObservableObject сбрасывается при срабатывании таймера.Срабатывает TimerPublisher?

#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. Я хотел использовать его везде, где только можно, в этом небольшом проекте, даже если это излишне, чтобы мне было с ним удобнее.