简体   繁体   中英

How to trigger SwiftUI DatePicker Programmatically?

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]) 

在此处输入图像描述

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()
    }
  }
}

在此处输入图像描述

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. 在此处输入图像描述

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.

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:

iPhone 模拟器的屏幕截图显示日期选择器弹出窗口通过单击按钮或日期选择器打开

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:

可访问性检查器和模拟器与检查器中选择的日期选择器并排

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 (eg po accessibilityElements and po (self as? UIView)?.subviews ) may also help.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM