Home > database >  How to trigger SwiftUI DatePicker Programmatically?
How to trigger SwiftUI DatePicker Programmatically?

Time:01-11

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 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.

  • Related