dr_barto
dr_barto

Reputation: 6065

SwiftUI DatePicker jumps between short and medium date formats when changing the date

(Using Xcode 12.3 and iOS 14.3)

I use a DatePicker component with the default style (DefaultDatePickerStyle) to display and edit a date: the component shows the current date value in a label and pops up a calendar when tapping that label. So far, so good.

When I change the date (either programmatically or manually it in the UI), the component erratically changes the date format of its label. It seems to switch between the .short and .medium values of DateFormatter.Style. Note that the date format cannot be set programmatically, it's internal to DatePicker.

Here is an example:

Date format changes when altering the date

The DatePicker displays "Feb 7, 2021"; I alter the date by subtracting one day using a button, causing the component to display "2/6/21"; subtracting a day again, the display changes to "Feb 5, 2021" etc. Sometimes it keeps the same format for a few dates, but mostly it toggles on every change.

Example code:

struct DateView: View {
  @State var date = Date()

  var body: some View {
    VStack(spacing: 20) {
      Button("-1 day") {
        date.addTimeInterval(-24*60*60)
      }
      Button("+1 day") {
        date.addTimeInterval(24*60*60)
      }
      DatePicker(
        "Date",
        selection: $date,
        displayedComponents: .date
      ).labelsHidden()
    }
  }
}

Omitting displayedComponents or labelsHidden has no effect on the issue.

The same issue can be observed when repeatedly opening the calendar popup and selecting dates: after closing the popup, the displayed date sometimes is in short format, and sometimes in medium format.

Any idea what's going on there?

Upvotes: 52

Views: 7784

Answers (5)

I experienced this problem because I had two DatePickers next to each other in an HStack.

Because the selected dates of the two pickers differed, it crossed the date boundary of where the bug happens, so the two pickers showed different formats.

I noticed that the picker responds dynamically adjusting the date format as width decreases.

So, I explicitly set the frame.width and was able to get them to be consistent.

I still used the above MyDatePicker approach in combination with what I described above, but I did not test using the stock Apple DatePicker implementation with my own solution.

Upvotes: 0

Luke Redpath
Luke Redpath

Reputation: 10565

The accepted answer of using .id to force the date picker to re-draw does not work consistently in my experience, and is a bit of a hack.

I've spent some time looking into this and the source of this bug appears to be with how the DatePicker picks up the current locale, which is how it determines the date format.

I've had more success explicitly setting the locale using the environment modifier:

DatePicker().environment(\.locale, .current)

Its not perfect - I still sometimes see it briefly show a shorter date format before showing the correct format.

If you don't want to hardcode the date picker to a specific locale (even the .current one), I recommend you add a property to your view that picks up the current locale from the environment:

@Environment(\.locale) private var locale

And then pass that in instead - this will still enable you to override the locale higher up the view hierarchy if needed.

Another thing to note about the .id hack is it changes the behaviour of the date picker - the default behaviour in compact style is for the popover calendar to remain visible unless the user explicitly dismisses it by tapping outside once they are happy with their selection. The .id modifier causes the picker to re-draw and the popover to close immediately.

Upvotes: 0

Graham Lea
Graham Lea

Reputation: 6333

I am trying to bind DatePicker to a date on a Core Data NSManagedObject, and I'm showing a calendar pop-up like this:

DatePicker lozenge with a calendar selector popup.

The other SwiftUI solution (recommending .id(date)) didn't work for me, instead the popup behaved strangely and disappeared on every second tap.

What I've done instead is to put the date into a state variable:

    @State private var snoozeUntilDate = Date()

And initialise this variable to the managed object's date whenever the date picker is shown:

DatePicker("Snooze until date", selection: $snoozeUntilDate, displayedComponents: .date)
    .labelsHidden()
    .onAppear {
        snoozeUntilDate = task.snoozeUntilDate!
    }

And update the managed object whenever the @State variable changes:

    .onChange(of: snoozeUntilDate) { newDate in
        task.snoozeUntilDate = newDate
    }

Doing this has fixed the problem for me! 🥳

While I'm not 100% sure, I think the trouble here might have been caused by the managed object. I tried making the @State variable optional (Date?), and that didn't re-introduce the weirdness, so it seems like the binding to the managed object (which I was creating using Binding(task.snoozeUntilDate)!) might have been the source of the weirdness.

Upvotes: 0

LN-12
LN-12

Reputation: 1049

For me, the suggested workaround did work, but the date was still showing the short format for a very short moment before formatting the correct way. Another approch, which works without any issues for me, is to wrap a UIDatePicker to use it in SwiftUI instead of using the build in view (taken from https://stackoverflow.com/a/59853272/13440564).

struct MyDatePicker: UIViewRepresentable {
    @Binding var date: Date
    var range: ClosedRange<Date>

    func makeUIView(context: Context) -> UIDatePicker {
        let datePicker = UIDatePicker()
        datePicker.datePickerMode = .date
        datePicker.addTarget(context.coordinator, action: #selector(Coordinator.changed(_:)), for: .valueChanged)
        datePicker.minimumDate = range.lowerBound
        datePicker.maximumDate = range.upperBound
        return datePicker
    }

    func updateUIView(_ datePicker: UIDatePicker, context: Context) {
        datePicker.date = date
    }

    func makeCoordinator() -> MyDatePicker.Coordinator {
        Coordinator(date: $date)
    }

    class Coordinator: NSObject {
        private let date: Binding<Date>

        init(date: Binding<Date>) {
            self.date = date
        }

        @objc func changed(_ sender: UIDatePicker) {
            self.date.wrappedValue = sender.date
        }
    }
}

You can the simply call it like this and customize it to your needs:

HStack {
    Text("Select date")

    Spacer()

    MyDatePicker(
        date: $date,
        range: range
    ).frame(maxHeight: 35)
}

Upvotes: 5

Asperi
Asperi

Reputation: 258107

Looks like a bug. Here is a found workaround (tested with Xcode 13beta / iOS 15)

demo

  DatePicker(
    "Date",
    selection: $date,
    displayedComponents: .date
  )
  .labelsHidden()
  .id(date)             // << here !!

Upvotes: 36

Related Questions