Multiple Date Pickers In View Causes Out Of Range Error

90 Views Asked by At

I have a view with multiple date pickers. The main date picker's value determines the correct range of the other date picker and am running a function onChange of the main date picker to ensure the values are always in range.

This was working perfectly but ever since iOS 17 (or maybe 17.2 not sure) the app is crashing with out of range errors, because the view is not updating in the correct order. The view is seeing the change of mainDate but crashing because secondDate is out of range, instead of running setSecondDate() and properly updating the view.

I do not want to hide any views in order to resolve the issue. The user must be able to see everything at the same time.

I need secondDate to properly update when mainDate is changed, so that it always remains in range.

This happens when setting the mainDate variable via the date picker, especially when changing the year. If it goes to far forward in time, I get an error of

"start date cannot be less than end date"

but if it goes back in time I get

"Invalid state. Unable to find a lower bounds in range"

This is my code (as stripped down as I can make it): Assume that ItemAdd has already been instantiated and secondDate is set to mainDate.

@MainActor
class ItemAdd: ObservableObject {
    @Published var mainDate: Date = Date()
    @Published var secondDate: Date?
    @Published var thirdDate: Date?

    func dateToString(date: Date) -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ssZZZZZ"
        dateFormatter.dateStyle = .long
        return dateFormatter.string(from: date)
    }

    var secondDateDate: Date {
       secondDate ?? Date()
    }

    var thirdDateDate: Date {
        thirdDate ?? Date()
    }

}

extension MyView {
    @MainActor
    class ViewModel: ObservableObject {

        weak var itemAdd: ItemAdd?

        @Published var loadState: LoadState = .loaded

        init(itemAdd: ItemAdd?) {
           self.itemAdd = itemAdd
        }

        enum LoadState {
          case loaded
          case loading
        }

        func dateRangeMainDate() -> ClosedRange<Date> {

            // General Variables
            var min: Date = Date()
            var max: Date = Date()

            guard let itemAdd = itemAdd else { return min...max }

            min = Calendar.current.date(
                            byAdding: .day,
                            value: -100,
                            to: Date()
                        ) ?? Date()

            max = Calendar.current.date(
                            byAdding: .day,
                            value: 100,
                            to: Date()
                        ) ?? Date()

            // Return the result
            return min...max
        }

        func dateRangeSecondDate() -> ClosedRange<Date> {

            // General Variables
            var min: Date = Date()
            var max: Date = Date()

            guard let itemAdd = itemAdd else { return min...max }

            min = mainDate

            var dateCalc = Calendar.current.date(
                            byAdding: .year,
                            value: 1,
                            to: mainDate
                        ) ?? Date()
            dateCalc = Calendar.current.date(
                        byAdding: .day,
                        value: -1,
                        to: dateCalc
                    ) ?? Date()
            max = dateCalc

            // Return the result
            return min...max
        }

        func setSecondDate() {
           guard let itemAdd = itemAdd else { return }

           if let newSecondDate = itemAdd.secondDate {
                if !self.dateRangeSecondDate().contains(newSecondDate) {
                    itemAdd.secondDate = self.dateRangeSecondDate().upperBound
                }
            } else {
                itemAdd.secondDate = self.dateRangeSecondDate().upperBound

            }
        }
    }
}

struct MyView: View {
    @ObservedObject var itemAdd: ItemAdd
    @StateObject var viewModel: ViewModel
    var body: some View {
        VStack {
            Text("Main Date: \(itemAdd.dateToFullString(date: itemAdd.mainDate))")

            DatePicker(
                "Main Date",
                selection: itemAdd.mainDate,
                in: viewModel.dateRangeMainDate(),
                displayedComponents: [.date]
            )
            .datePickerStyle(GraphicalDatePickerStyle())
            .labelsHidden()
            .frame(width: 320)
            .onChange(of: itemAdd.mainDate) {
                viewModel.loadState = .loading
                viewModel.setSecondDate()
                viewModel.loadState = .loaded
            }

            switch viewModel.loadState {
                case .loaded:
                    Text("Second Date: \(itemAdd.dateToFullString(date: itemAdd.secondDateDate))")

                    DatePicker(
                        "Second Date",
                        selection: itemAdd.secondDateDate,
                        in: viewModel.dateRangeSecondDate(),
                        displayedComponents: [.date]
                    )
                    .datePickerStyle(GraphicalDatePickerStyle())
                    .labelsHidden()
                    .frame(width: 320)

                case .loading:
                    ProgressView()
            }
        }
    }
}
2

There are 2 best solutions below

2
On

Your is issue may be because you are using the same general variables min and max to evaluate the pickers bounds at runtime. And also, you are updating the bounds inside the function that evaluate the bounds.

Each time that the .onChange closure is called and the mainDate updated, the UI is redrawed and dateRangeSecondDate is likely pushing the bounds one year above ! It's quite confusing and you can't actually predict which range updating function is called first.

My approach would be to update min and max only in the .onChange closure of the main picker if needed (i can see that this is not actually needed because it's a fixed range of 100 days before and after today). Then, the functions dateRangeMainDate and dateRangeSecondDate will just evaluate the correct bounds at runtime and return it.

See a simplified example here:

var min: Date = Calendar.current.date(byAdding: .day, value: -100, to: Date()) ?? Date()
var max: Date = Calendar.current.date(byAdding: .day, value: 100, to: Date()) ?? Date()

// potential mainDate binding variable
var mainDate: Date = Date()

func dateRangeMainDate() -> ClosedRange<Date> {
    // do any evaluation here if needed, with local variables and return, but don't update directly the min and max here
    return min...max
}

func dateRangeSecondDate() -> ClosedRange<Date> {
    
    var finalDate = Calendar.current.date(
        byAdding: .year,
        value: 1,
        to: mainDate
    ) ?? Date()

    finalDate = Calendar.current.date(
        byAdding: .day,
        value: -1,
        to: finalDate
    ) ?? Date()
    
    return mainDate...finalDate
}

Keep in mind that the purpose of functions to give ranges is not to add more complexity. Just evaluate the bounds and return it. I hope this helps.

0
On

Thank you to those who tried to answer, but I finally figured it out after two very frustrating days. Because the view was updating out of order, the range in the secondDate's date picker was causing the crash (which I knew).

To resolve this I wrote a ternary like this:

range: viewModel.dateRangeSecondDate().contains(secondDate) ?
viewModel.dateRangeSecondDate()
:
(Calendar.current.date(byAdding: .year, value: -100, to: Date()))...(Calendar.current.date(byAdding: .year, value: 100, to: Date()))

That way the view evaluates secondDate properly and when mainDate is updated, and the setSecondDate function runs, secondDate is once again contained in the dataRangeSecondDate so the second date date picker correctly displays the range to the user.

And.... no more crash.