如何以编程方式触发SwiftUI DatePicker?

4
如下图所示,如果您输入日期“2023年1月11日”,它将呈现出日期选择器。我想要实现的是在别处有一个按钮,当单击该按钮时,自动显示此日期选择器。
有没有人知道有没有办法实现这个呢?
DatePicker("Jump to", selection: $date, in: dateRange, displayedComponents: [.date]) 

enter image description here

以下是对@rob mayoff答案的测试。我仍然无法弄清楚为什么它还没有起作用。
我在Xcode 14.2上进行了测试,使用iOS 16.2模拟器的iPhone 14,以及在设备上进行了测试。我注意到的是,尽管调用了triggerDatePickerPopover(),但它永远无法到达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

更新2: 我按照调试指示进行了跟进。看起来使用完全相同的代码,我的检查器缺少可访问性标识符。不知道为什么...现在感觉很困扰。 enter image description here

这里有一个下载项目的链接 https://www.icloud.com/iclouddrive/040jHC0jwwJg3xgAEvGZShqFg#DatePickerTest

更新3: @rob mayoff的解决方案非常棒!对于任何阅读此文的人。如果在您的情况下没有起作用,请耐心等待。这可能只是由于设备或模拟器正在准备可访问性。


这看起来是您问题的一个不错的解决方案:https://dev59.com/NVEG5IYBdhLWcg3wcuH2#72902483 - Chris
@Chris,遗憾的是,在我的情况下它并不起作用。我测试了那个线程中多个答案,其中许多使用弹出框解决方案来弹出日期选择器。在我的情况下,我仍然想保留苹果的日期按钮,例如“2023年1月11日”。但是,我需要通过编程的方式在其他地方触发相同的行为。 - Legolas Wang
我把你复制的代码贴到测试项目中,对我来说可以工作。在macOS 12.6.1上使用Xcode 14.2,在模拟器(13 mini,iOS 16.2)和真实设备(12 mini,iOS 16.3 beta)上都可以工作。 - rob mayoff
@robmayoff 我正在尝试弄清楚是否有我错过的东西。我唯一能发现的区别是,我使用的是macOS 13.1。在iPhone 12 Pro上测试了iOS 16.3 beta,以及iPad Pro 11、iPhone 13和iPhone Pro 14的模拟器。到目前为止,没有任何作用。我从Xcode 14.2的模板中创建了一个新的iOS SwiftUI应用程序,并粘贴了代码。请问我是否需要调整项目中的其他设置,例如启用某些辅助功能或任何其他设置,或导入除SwiftUI之外的其他框架? - Legolas Wang
我认为你不需要做任何事情来启用辅助功能。我想不到其他的事情了。我不会期望macOS 13.1会影响模拟器内部的辅助功能工作方式。 - rob mayoff
非常感谢您的回复,我刚刚找到了一台搭载iOS 16.2的iPad Pro 11 gen 4,但还是无法使其运行。我刚刚看到了您的调试说明,正在阅读中。非常感谢您的跟进。 - Legolas Wang
1个回答

5

更新(2)

回顾一下,我最初使用辅助功能 API 的解决方案有点冒险,因为苹果未来可能会更改 DatePicker 的辅助结构。我应该先仔细查看 DatePicker 文档,并注意到 .datePickerStyle(.graphical) 修饰符,它允许您直接使用弹出窗口中显示的日历视图。

由于 SwiftUI 不支持 iPhone 上的自定义弹出窗口(.popover 修饰符会显示一个 sheet),一个相当简单的解决方案是将图形化的 DatePicker 直接放入您的 VStack 中,并使用任意数量的按钮显示/隐藏它。您可以将按钮样式设置为默认的 DatePicker 按钮。

这是一个完整的示例。它看起来像这样:

demo of inline date picker

这里是代码:

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

翻译

SwiftUI没有直接的方法来编程触发日历弹出窗口。

但是,我们可以使用辅助功能API来实现。这是我的测试样例:

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

你可以看到日历弹出窗口从单击"Clicky"按钮或日期选择器本身打开。
首先,我们需要通过辅助功能API找到选择器的方法。 让我们为选择器分配一个辅助性标识符:
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()
  }
}

在我们编写triggerDatePickerPopover之前,我们需要一个函数来搜索可访问元素树:

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

让我们使用它来编写一个搜索具有特定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
    }
  }
}

在测试中,我发现即使UIView文档中记录符合UIAccessibilityIdentification协议(定义了accessibilityIdentifier属性),将UIView强制转换为UIAccessibilityIdentification在运行时不起作用。因此,上面的方法比您预期的要复杂一些。

事实证明,选择器有一个充当按钮的子元素,我们需要激活该按钮。因此,让我们编写一个搜索按钮元素的方法:

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

最后,我们可以编写triggerDatePickerPopover方法:
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()
    }
  }
}

更新

你说我的代码有问题。由于它在我的电脑上运行正常,很难诊断问题。尝试启动辅助功能检查器(从Xcode的菜单栏中选择Xcode > 打开开发人员工具 > 辅助功能检查器)。告诉它以模拟器为目标,然后使用右角括号按钮逐步浏览可访问元素,直到找到DatePicker。然后将鼠标悬停在检查器层次结构窗格中的行上,并单击圆圈中的右箭头。它应该看起来像这样:

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

请注意,检查器将日期选择器的标识符“picker”视为在代码中设置,并且选择器在层次结构中具有一个按钮子元素。如果您的看起来不同,您需要找出原因并更改代码以匹配。
通过逐步执行accessibilityDescendant方法并手动转储子项(例如po accessibilityElements和po(self as?UIView)?.subviews)也可能有所帮助。

嗨,Rob,顺便提一下,我把测试代码移到了问题本身。我仍然没搞明白为什么它不起作用 o.o 这段代码应该是完全相同的。而且似乎与 iOS 版本无关。请让我知道那段代码在你的环境中是否有效。非常感谢! - Legolas Wang
我在问题中添加了更新2。看起来我的项目缺少标识符“picker”。真的不知道为什么。 - Legolas Wang
我想知道这与类有关吗。在您的调试截图中,您的日期选择器是“UIDatePicker”类。而在我的调试截图中,在第二次更新中,我的日期选择器是“_UIDatePickerIOSCompactView”类。由于某些未知原因。 - Legolas Wang
天啊!!!终于成功了。我什么都没做,在等待几分钟后,我的iPhone和iPad以及所有模拟器都按预期工作了。我最好的猜测是因为我以前从未使用过accessibilityIdentifier。第一次运行可能需要几分钟到半个小时的时间来准备一些东西(在此期间,accessibilityIdentifier将不会被附加)。在准备期间,什么也不会工作。 - Legolas Wang
非常感谢这个精彩的教程,我发现在使用.displayedComponents时,当第一次点击时,日期选择器无法正确显示的问题有一些小瑕疵。 - undefined
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接