How to trigger SwiftUI DatePicker Programmatically?
Asked Answered
A

2

9

As Image below shows, if you type the date "Jan 11 2023", it presents the date picker. What I wanna to achieve is have a button elsewhere, when that button is clicked, present this date picker automatically.

Does anyone know if there is a way to achieve it?

DatePicker("Jump to", selection: $date, in: dateRange, displayedComponents: [.date]) 

enter image description here

Below is a test on @rob mayoff's answer. I still couldn't figure out why it didn't work yet.

I tested on Xcode 14.2 with iPhone 14 with iOS 16.2 simulator, as well as on device. What I noticed is that although the triggerDatePickerPopover() is called, it never be able to reach button.accessibilityActivate().

import SwiftUI

struct ContentView: View {
  @State var date: Date = .now
  let dateRange: ClosedRange<Date> = Date(timeIntervalSinceNow: -864000) ... Date(timeIntervalSinceNow: 864000)

  var pickerId: String { "picker" }

  var body: some View {
    VStack {
      DatePicker(
        "Jump to",
        selection: $date,
        in: dateRange,
        displayedComponents: [.date]
      )
      .accessibilityIdentifier(pickerId)

      Button("Clicky") {
        triggerDatePickerPopover()
          print("Clicky Triggered")
      }
    }
    .padding()
  }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

extension NSObject {
  func accessibilityDescendant(passing test: (Any) -> Bool) -> Any? {

    if test(self) { return self }

    for child in accessibilityElements ?? [] {
      if test(child) { return child }
      if let child = child as? NSObject, let answer = child.accessibilityDescendant(passing: test) {
        return answer
      }
    }

    for subview in (self as? UIView)?.subviews ?? [] {
      if test(subview) { return subview }
      if let answer = subview.accessibilityDescendant(passing: test) {
        return answer
      }
    }

    return nil
  }
}


extension NSObject {
  func accessibilityDescendant(identifiedAs id: String) -> Any? {
    return accessibilityDescendant {
      // For reasons unknown, I cannot cast a UIView to a UIAccessibilityIdentification at runtime.
      return ($0 as? UIView)?.accessibilityIdentifier == id
      || ($0 as? UIAccessibilityIdentification)?.accessibilityIdentifier == id
    }
  }
    
    func buttonAccessibilityDescendant() -> Any? {
       return accessibilityDescendant { ($0 as? NSObject)?.accessibilityTraits == .button }
     }
}

extension ContentView {
  func triggerDatePickerPopover() {
    if
      let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
      let window = scene.windows.first,
      let picker = window.accessibilityDescendant(identifiedAs: pickerId) as? NSObject,
      let button = picker.buttonAccessibilityDescendant() as? NSObject
    {
        print("triggerDatePickerPopover")
      button.accessibilityActivate()
    }
  }
}

enter image description here

Update 2: I followed up the debug instruction. It seems that with exact same code. My inspector are missing the accessibility identifier. Not knowing why.... feels mind buggingly now. enter image description here

Here is a link to download the project https://www.icloud.com/iclouddrive/040jHC0jwwJg3xgAEvGZShqFg#DatePickerTest

Update 3: @rob mayoff's solution is brilliant! For anyone reading. If it didn't work in your case, just wait. It's probably just due to device or simulator getting ready for accessibility.

Alverta answered 10/1, 2023 at 16:28 Comment(6)
This looks like a nice solution to your problem: https://mcmap.net/q/649858/-how-to-make-a-button-or-any-other-element-show-swiftui-39-s-datepicker-popup-on-tapBookbinding
@Bookbinding Sadly it does not work in my case. I tested multiple answers in that thread, and many uses a pop over solution to pop up the Date Picker. In my case, I stills want to keep Apple's Date Button, e.g."Jan 11, 2023". But adding an addition programmatically way to trigger the same behavior somewhere else.Alverta
I pasted your copy of my code into a test project and it works for me. Xcode 14.2 on macOS 12.6.1, on both a simulator (13 mini, iOS 16.2) and a real device (12 mini, iOS 16.3 beta).Teeming
@robmayoff I'm trying to figure out if there anything I missed. The only difference I could tell, is that I'm on macOS 13.1. Tested on iPhone 12 Pro with iOS 16.3 beta, as well as simulators of iPad Pro 11, iPhone 13 and iPhone Pro 14 . Nothing worked for me so far. I created a new iOS SwiftUI app from Xcode 14.2's template and paste in the code. May I ask if there is any other thing I need to tweak on the project, like enabling some accessibility capability or any other setting, or import any other framework other than SwiftUI?Alverta
I don't think you should need to do anything to enable accessibility. I can't think of anything else. I wouldn't expect macOS 13.1 to affect how accessibility works inside the simulator.Teeming
Thank you so much for the reply, I just found a iPad Pro 11 gen 4 with iOS 16.2. And somehow still not able to get it working. I just saw your debug instructions and reading it now. Thank you so much for following upAlverta
T
9

UPDATE (2)

In retrospect, my original solution using the accessibility API is a bit risky since Apple could change the accessibility structure of DatePicker in the future. I should have taken a better look at the DatePicker documentation first and noticed the .datePickerStyle(.graphical) modifier, which lets you directly use the calendar view shown in the popover.

Since SwiftUI doesn't support custom popovers on iPhone (the .popover modifier shows a sheet instead), a fairly simple solution is to put a graphical DatePicker directly into your VStack, and show/hide it with any number of buttons. You can style the buttons to look like a default DatePicker button.

Here's a fully-worked example. It looks like this:

demo of inline date picker

And here's the code:

struct ContentView: View {
  @State var date = Date.now
  @State var isShowingDatePicker = false

  let dateRange: ClosedRange<Date> = Date(timeIntervalSinceNow: -864000) ... Date(timeIntervalSinceNow: 864000)

  private var datePickerId: String { "picker" }

  var body: some View {
    ScrollViewReader { reader in
      ScrollView(.vertical) {
        VStack {
          VStack {
            HStack {
              Text("Jump to")
              Spacer()
              Button(
                action: { toggleDatePicker(reader: reader) },
                label: { Text(date, format: Date.FormatStyle.init(date: .abbreviated)) }
              )
              .buttonStyle(.bordered)
              .foregroundColor(isShowingDatePicker ? .accentColor : .primary)
            }

            VStack {
              DatePicker("Jump to", selection: $date, in: dateRange, displayedComponents: .date)
                .datePickerStyle(.graphical)
                .frame(height: isShowingDatePicker ? nil : 0, alignment: .top)
                .clipped()
                .background {
                  RoundedRectangle(cornerRadius: 8, style: .continuous)
                    .foregroundColor(Color(UIColor.systemBackground))
                    .shadow(radius: 1)
                }
            }
            .padding(.horizontal, 8)
          }.id(datePickerId)

          filler

          Button("Clicky") { toggleDatePicker(true, reader: reader) }
        }
      }
    }
    .padding()
  }

  private func toggleDatePicker(_ show: Bool? = nil, reader: ScrollViewProxy) {
    withAnimation(.easeOut) {
      isShowingDatePicker = show ?? !isShowingDatePicker
      if isShowingDatePicker {
        reader.scrollTo(datePickerId)
      }
    }
  }

  private var filler: some View {
    HStack {
      Text(verbatim: "Some large amount of content to make the ScrollView useful.\n\n" + Array(repeating: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", count: 4).joined(separator: "\n\n"))
        .lineLimit(nil)
      Spacer()
    }
  }
}

ORIGINAL

SwiftUI doesn't provide a direct way to programmatically trigger the calendar popover.

However, we can do it using the accessibility API. Here's what my test looks like:

screen capture of iPhone simulator showing that the date picker popover opens from clicks on either a button or the date picker

You can see that the calendar popover opens from clicks on either the ‘Clicky’ button or the date picker itself.

First, we need a way to find the picker using the accessibility API. Let's assign an accessibility identifier to the picker:

struct ContentView: View {
  @State var date: Date = .now
  let dateRange: ClosedRange<Date> = Date(timeIntervalSinceNow: -864000) ... Date(timeIntervalSinceNow: 864000)

  var pickerId: String { "picker" }

  var body: some View {
    VStack {
      DatePicker(
        "Jump to",
        selection: $date,
        in: dateRange,
        displayedComponents: [.date]
      )
      .accessibilityIdentifier(pickerId)

      Button("Clicky") {
        triggerDatePickerPopover()
      }
    }
    .padding()
  }
}

Before we can write triggerDatePickerPopover, we need a function that searches the accessibility element tree:

extension NSObject {
  func accessibilityDescendant(passing test: (Any) -> Bool) -> Any? {

    if test(self) { return self }

    for child in accessibilityElements ?? [] {
      if test(child) { return child }
      if let child = child as? NSObject, let answer = child.accessibilityDescendant(passing: test) {
        return answer
      }
    }

    for subview in (self as? UIView)?.subviews ?? [] {
      if test(subview) { return subview }
      if let answer = subview.accessibilityDescendant(passing: test) {
        return answer
      }
    }

    return nil
  }
}

Let's use that to write a method that searches for an element with a specific id:

extension NSObject {
  func accessibilityDescendant(identifiedAs id: String) -> Any? {
    return accessibilityDescendant {
      // For reasons unknown, I cannot cast a UIView to a UIAccessibilityIdentification at runtime.
      return ($0 as? UIView)?.accessibilityIdentifier == id
      || ($0 as? UIAccessibilityIdentification)?.accessibilityIdentifier == id
    }
  }
}

I found, in testing, that even though UIView is documented to conform to the UIAccessibilityIdentification protocol (which defines the accessibilityIdentifier property), casting a UIView to a UIAccessibilityIdentification does not work at runtime. So the method above is a little more complex than you might expect.

It turns out that the picker has a child element which acts as a button, and that button is what we'll need to activate. So let's write a method that searches for a button element too:

  func buttonAccessibilityDescendant() -> Any? {
    return accessibilityDescendant { ($0 as? NSObject)?.accessibilityTraits == .button }
  }

And at last we can write the triggerDatePickerPopover method:

extension ContentView {
  func triggerDatePickerPopover() {
    if
      let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
      let window = scene.windows.first,
      let picker = window.accessibilityDescendant(identifiedAs: pickerId) as? NSObject,
      let button = picker.buttonAccessibilityDescendant() as? NSObject
    {
      button.accessibilityActivate()
    }
  }
}

UPDATE

You say you're having problems with my code. Since it's working for me, it's hard to diagnose the problem. Try launching the Accessibility Inspector (from Xcode's menu bar, choose Xcode > Open Developer Tool > Accessibility Inspector). Tell it to target the Simulator, then use the right-angle-bracket button to step through the accessibility elements until you get to the DatePicker. Then hover the mouse over the row in the inspector's Hierarchy pane and click the right-arrow-in-circle. It ought to look like this:

accessibility inspector and simulator side-by-side with the date picker selected in the inspector

Notice that the inspector sees the date picker's identifier, “picker”, as set in the code, and that the picker has a button child in the hierarchy. If yours looks different, you'll need to figure out why and change the code to match.

Stepping through accessibilityDescendant method and manually dumping the children (e.g. po accessibilityElements and po (self as? UIView)?.subviews) may also help.

Teeming answered 10/1, 2023 at 19:7 Comment(11)
That is such an impressive answer. Thank you so much and I truly admire your effort. I was stuck at solving this puzzle for a while. Your approach is brilliant. Unfortunately, when I try to recreate your solution, my test didn't work. I posted a the test in the answer so that I could include the code and gif.Alverta
Hi rob, just FYI I moved the test code to the question itself. I still couldn't figure out why it didn't work o.o The code is supposed to be identical. And it seems irrelevant to the iOS versions. Please let me know if that code works in your environment. Thank you so much!Alverta
I can't reproduce the problem but I've added some debugging advice to my answer.Teeming
I added update 2 in the question. It appears my project is missing the identifier "picker". Really had no idea whyAlverta
I'm wondering if this has to do with the class. In your debug screenshot, your date picker is a "UIDatePicker" class. While in my debug screenshot in update 2, my date picker is a "_UIDatePickerIOSCompactView" class. For some unknown reasonAlverta
OH MY GOD!!! IT FINALLY WORKED. I did absolute nothing, and after waiting for several minutes, both my iPhone and iPad, as well as all simulators, worked as expected. My best guess, is that because I never used accessibilityIdentifier before. It will take probably minutes to about half an hour to prepare something for the first run(And during this time, the accessibilityIdentifier will not be attached.) And during its preparing, nothing will work.Alverta
Just want to say thank you again, for your patience and clear instructions. It works exactly as you said and what I had hoped. It keeps the DatePicker’s original behavior untouched(which is exactly what I hoped for).Alverta
Well, that is very weird. I'm glad it started working for you! I hope it works more timely for your users.Teeming
I do have a larger dynamic text size set on my iPhone, and maybe that enables accessibility? But I don't think I've changed that setting in the simulator, and you didn't mention changing it. Something to look into if you want.Teeming
Probably that's the case! I guess accessibility gets loaded up the first time it's being changed or used. And will remain ready ever since. Although it may seems like an insist on a UX design decision, it really means a lot for me to achieve the desired effect. I implemented your solution in my project and feel so happy about the outcome. It'll be available on the next app update. If you wanna to check it in action, you're very welcomed to have look at the app: apps.apple.com/ca/app/moodlight-intelligent-note/id1631169735Alverta
Thanks for the wonderful tutorial, I see there are a few issues with getting the date picker to display properly on first tap when using .hourAndMinute for displayedComponents.Reductase
H
1

Overlay a transparent DatePicker on top of your UI

You can .overlay a transparent DatePicker on top of your custom UI. The user thinks they're tapping your button, but really they tap an "invisible" DatePicker.

@State var date = Date()

Text("Add date \(date)")
  .overlay {
    DatePicker(
      selection: $date,
      displayedComponents: .date
    ) {}
      .opacity(0.011) // Minimum that still allows this to be tappable
  }

Looks like this

Once you make it transparent the user won't be able to see it

Stacking DatePicker

One issue is the DatePicker tap area can be too small. You can stack them so it all looks like one button to the end user.

Text("Add date \(date)")
  .overlay(alignment: .leading) {
    HStack(spacing: 0) {
      ForEach(0..<3) { _ in
        DatePicker(
          selection: $date,
          displayedComponents: .date
        ) {}
          .opacity(0.011) // Minimum that still allows this to be tappable
      }
    }
  }

Looks like this

Once you add transparency the user won't see anything

Here answered 1/3 at 22:55 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.