I have a type that vends a Timer.TimerPublisher, which you can see below:
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()
}
}
}
I have a View that is used to save new events. It is injected with a view model that uses TimerClient so that I can disable the Save button if users attempt to save an event with a past date:
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: &cancellables)
}
private func observeCurrentDateChanges() {
nowSubject.sink(receiveValue: { [weak self] _ in
self?.shouldDisableSaveButton = self?.disableSaveButton() ?? false
})
.store(in: &cancellables)
}
}
The view model works well, and it updates the Save button if an attempt is made to select a past date:
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: &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
}
}
The view model is used in a View that displays all saved items in a list:
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
}
}
This works as expected, and the Text elements are updated with the expected values. Additionally, I am able to scroll the List and see the Text values update thanks to adding the Timer to the main runloop and common mode. However, when I navigate to the event-entry view, the view model seems to get reset when the timer fires.
Below, you can see that the entered text is reset with every fire of the timer:
It seems that my view model is somehow being recreated, but both view models are @ObservableObjects, so I am not sure why I'm seeing the values reset after the timer fires. The DatePicker, Toggle, TextField, and Button values are all reset to their defaults when the timer fires.
What am I missing that is causing the text to clear when the timer fires?
If it helps, the project is located here. Be sure to use the save-countdown-events branch. Additionally, I am modeling my client types after Point-Free's teachings about dependency injection (code), if that helps provide more context.