swiftuiswiftui-stateswiftui-datepicker

Update SwiftUI Date Picker to match a model's computed property: Cannot assign to property


I have a form which lets the user create a project. In this form, I want to automatically change the project's deadline DatePicker based on the user's input such as start date, daily count, and target count. If the DatePicker's date is changed, then the other views should update also.

For example if the user has a start date of Sep 6, 2022, a target of 1000, and a daily count of 500, then the deadline is 1000 / 500 = 2 days and should show Sep 8, 2022.

Looking at solutions like this one and this one, I thought maybe I should use a computed property to calculate the deadline. I can display the deadline in a Text view, but how do I do this in a DatePicker? If I directly put the deadline targetDate in the picker:

DatePicker("Deadline", selection: $createdProject.targetDate)

Then I get a build error:

Cannot assign to property: 'targetDate' is a get-only property

Maybe something I don't understand, or there's a simple solution but I can't think of it.

ContentView.swift:

import SwiftUI

struct ContentView: View {
    
    @StateObject var createdProject:ProjectItem = ProjectItem()
    
    var body: some View {
        Form {
            Section {
                DatePicker("Start", selection: $createdProject.startDate)
            }
            
            Section {
                VStack {
                    HStack {
                        Text("Starting word count:").fixedSize(horizontal: false, vertical: true)
                        TextField("0", value: $createdProject.startWordCount, formatter: NumberFormatter()).textFieldStyle(.roundedBorder)
                    }.padding(.bottom, 10)
                    HStack {
                        Text("Target word count").fixedSize(horizontal: false, vertical: true)
                        
                        TextField("85000", value: $createdProject.targetWordCount, formatter: NumberFormatter())
                            .textFieldStyle(.roundedBorder)
                    }
                    
                }
            }
            
            Section {
                VStack {
                    HStack {
                        Text("Daily word count").fixedSize(horizontal: false, vertical: true)
                        TextField("0", value: $createdProject.dailyWordCount, formatter: NumberFormatter()).textFieldStyle(.roundedBorder)
                    }
                }
                // This changes to show the updated deadline
                // Text("Deadline is \(createdProject.targetDate)")
                // But DatePicker has build error
                DatePicker("Deadline", selection: $createdProject.targetDate)
            }
            
        } // end Form
    }
}

ProjectItem.swift

import SwiftUI

class ProjectItem: Identifiable, ObservableObject {
    
    @Published var id: UUID = UUID()
    @Published var startDate:Date = Date()
    var targetDate:Date {
        if (dailyWordCount == 0) {
            return Date()
        } else {
            // Given start date, starting word count,
            // daily word count, and target word count,
            // calculate the new target date
            let daysNeeded = (targetWordCount - startWordCount) / dailyWordCount
            print("Need \(daysNeeded) days to reach the target")
            var dateComponent = DateComponents()
            dateComponent.day = daysNeeded

            let nextDate = Calendar.current.date(byAdding: dateComponent, to: startDate) ?? startDate
            print("The target date will be \(nextDate)")
            return nextDate
        }
    }
    @Published var startWordCount:Int = 0
    @Published var targetWordCount:Int = 85000
    @Published var dailyWordCount:Int = 0
}

Example image:

The views described on an iPhone 11 preview


Solution

  • DatePicker needs to be able to set the property, so you are required to have a setter,

    You can make targetDate a normal property, and then add an initializer to set it to the correct value.

    Then add didSets (property observers) to all the properties, and then re-calculate the target date when they change. To recalculate the other properties when the target date changes, just add a didSet to targetDate.

    class ProjectItem: Identifiable, ObservableObject {
        
        @Published var id: UUID = UUID() {
            didSet {
                calculateTargetDate()
            }
        }
    
        @Published var startDate:Date = Date() {
            didSet {
                calculateTargetDate()
            }
        }
    
        @Published var targetDate:Date! = nil {
            didSet {
                updateDailyWordCount()
            }
        }
    
        @Published var startWordCount:Int = 0 {
            didSet {
                calculateTargetDate()
            }
        }
    
        @Published var targetWordCount:Int = 85000 {
            didSet {
                calculateTargetDate()
            }
        }
    
        @Published var dailyWordCount:Int = 0 {
            didSet {
                calculateTargetDate()
            }
        }
        
        init() {
            targetDate = calculateTargetDate()
        }
        
        func calculateTargetDate() -> Date {
            if (dailyWordCount == 0) {
                targetDate = Date()
            } else {
                // Given start date, starting word count,
                // daily word count, and target word count,
                // calculate the new target date
                let daysNeeded = (targetWordCount - startWordCount) / dailyWordCount
                print("Need \(daysNeeded) days to reach the target")
                var dateComponent = DateComponents()
                dateComponent.day = daysNeeded
                
                let nextDate = Calendar.current.date(byAdding: dateComponent, to: startDate) ?? startDate
                print("The target date will be \(nextDate)")
    
                if targetDate == nextDate {
                    // Exit to stop recursion
                    return
                }
    
                targetDate = nextDate
            }
        }
    
        func updateDailyWordCount() {
            // Do some calculations with the new target date
            // Then update the daily word count 
            // (or some other property)
        } 
    }
    

    I hope this helps!