Search code examples
iosswiftdateswiftuidatepicker

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

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.


Solution

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