From 4ae91f13e954c14e17deeffc8f464694c73c9616 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Tue, 5 Mar 2024 21:21:56 +0100 Subject: [PATCH] reorder control --- .../Sources/ListReorderGesture.swift | 96 +++++++++++++++---- CHANGELOG.md | 1 + .../CollectionViewAppearance.swift | 68 ++++++++++++- .../ReorderingViewController.swift | 18 ++++ ListableUI/Sources/Item/ItemReordering.swift | 15 +++ .../Sources/ListableLocalizedStrings.swift | 2 +- 6 files changed, 177 insertions(+), 23 deletions(-) diff --git a/BlueprintUILists/Sources/ListReorderGesture.swift b/BlueprintUILists/Sources/ListReorderGesture.swift index 84384943e..6a13c1b63 100644 --- a/BlueprintUILists/Sources/ListReorderGesture.swift +++ b/BlueprintUILists/Sources/ListReorderGesture.swift @@ -5,6 +5,7 @@ // Created by Kyle Van Essen on 11/14/19. // +import Accessibility import BlueprintUI import ListableUI import UIKit @@ -38,6 +39,7 @@ import UIKit /// ``` public struct ListReorderGesture : Element { + public enum Begins { case onTap case onLongPress @@ -54,9 +56,8 @@ public struct ListReorderGesture : Element let actions : ReorderingActions - /// The acccessibility Label of the item that will be reordered. - /// This will be set as the gesture's accessibilityValue to provide a richer VoiceOver utterance. - public var reorderItemAccessibilityLabel : String? = nil + /// The acccessibility label for the reorder element. Defaults to "Reorder". + public var accessibilityLabel : String? /// Creates a new re-order gesture which wraps the provided element. /// @@ -66,6 +67,7 @@ public struct ListReorderGesture : Element isEnabled : Bool = true, actions : ReorderingActions, begins: Begins = .onTap, + accessibilityLabel: String? = nil, wrapping element : Element ) { self.isEnabled = isEnabled @@ -74,6 +76,8 @@ public struct ListReorderGesture : Element self.begins = begins + self.accessibilityLabel = accessibilityLabel + self.element = element } @@ -88,24 +92,16 @@ public struct ListReorderGesture : Element public func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? { return ViewDescription(View.self) { config in + config.builder = { View(frame: context.bounds, wrapping: self) } + config.contentView = { $0.containerView } config.apply { view in - view.isAccessibilityElement = true - view.accessibilityLabel = ListableLocalizedStrings.ReorderGesture.accessibilityLabel - view.accessibilityValue = reorderItemAccessibilityLabel - view.accessibilityHint = ListableLocalizedStrings.ReorderGesture.accessibilityHint - view.accessibilityTraits.formUnion(.button) - view.accessibilityCustomActions = accessibilityActions() - - view.recognizer.isEnabled = self.isEnabled - - view.recognizer.apply(actions: self.actions) - - view.recognizer.minimumPressDuration = begins == .onLongPress ? 0.5 : 0.0 + view.apply(self) } + } } @@ -118,9 +114,14 @@ public extension Element func listReorderGesture( with actions : ReorderingActions, isEnabled : Bool = true, - begins: ListReorderGesture.Begins = .onTap + begins: ListReorderGesture.Begins = .onTap, + accessibilityLabel: String? = nil ) -> Element { - ListReorderGesture(isEnabled: isEnabled, actions: actions, begins: begins, wrapping: self) + ListReorderGesture(isEnabled: isEnabled, + actions: actions, + begins: begins, + accessibilityLabel: accessibilityLabel, + wrapping: self) } } @@ -129,25 +130,84 @@ fileprivate extension ListReorderGesture { private final class View : UIView { + + let containerView = UIView() let recognizer : ItemReordering.GestureRecognizer + private lazy var proxyElement = UIAccessibilityElement(accessibilityContainer: self) + private var minimumPressDuration: TimeInterval = 0.0 { + didSet { + updateGesturePressDuration() + } + } + + @objc private func updateGesturePressDuration() { + self.recognizer.minimumPressDuration = UIAccessibility.isVoiceOverRunning ? 0.0 : self.minimumPressDuration + } init(frame: CGRect, wrapping : ListReorderGesture) { self.recognizer = .init() super.init(frame: frame) - + recognizer.accessibilityProxy = proxyElement + NotificationCenter.default.addObserver(self, selector: #selector(updateGesturePressDuration) , name: UIAccessibility.voiceOverStatusDidChangeNotification, object: nil) + self.isOpaque = false self.clipsToBounds = false self.backgroundColor = .clear self.addGestureRecognizer(self.recognizer) + + self.isAccessibilityElement = false + + containerView.isOpaque = false + containerView.backgroundColor = .clear + addSubview(containerView) } @available(*, unavailable) required init?(coder aDecoder: NSCoder) { listableInternalFatal() } + + func apply(_ model: ListReorderGesture) { + proxyElement.accessibilityLabel = model.accessibilityLabel ?? ListableLocalizedStrings.ReorderGesture.accessibilityLabel + proxyElement.accessibilityHint = ListableLocalizedStrings.ReorderGesture.accessibilityHint + proxyElement.accessibilityTraits.formUnion(.button) + proxyElement.accessibilityCustomActions = model.accessibilityActions() + + recognizer.isEnabled = model.isEnabled + + recognizer.apply(actions: model.actions) + minimumPressDuration = model.begins == .onLongPress ? 0.5 : 0.0 + } + + override func layoutSubviews() { + super.layoutSubviews() + containerView.frame = bounds + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if UIAccessibility.isVoiceOverRunning, + UIAccessibility.focusedElement(using: .notificationVoiceOver) as? NSObject == proxyElement { + // Intercept touch events to avoid activating contained elements. + return self + } + + return super.hitTest(point, with: event) + } + + override var accessibilityElements: [Any]? { + get { + guard recognizer.isEnabled else { return super.accessibilityElements } + proxyElement.accessibilityFrame = self.accessibilityFrame + proxyElement.accessibilityActivationPoint = self.accessibilityActivationPoint + return [containerView, proxyElement] + } + set { + fatalError("Cannot set accessibility elements directly") + } + } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index e6e54e34b..072846ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # [Main] ### Fixed +- `ListReorderGesture` no longer blocks child accessibility, now exposing a proxy element for accessible control. ### Added diff --git a/Demo/Sources/Demos/Demo Screens/CollectionViewAppearance.swift b/Demo/Sources/Demos/Demo Screens/CollectionViewAppearance.swift index 6cc4c9d94..bc01ea35e 100644 --- a/Demo/Sources/Demos/Demo Screens/CollectionViewAppearance.swift +++ b/Demo/Sources/Demos/Demo Screens/CollectionViewAppearance.swift @@ -119,6 +119,65 @@ struct DemoHeader2 : BlueprintHeaderFooterContent, Equatable } +struct DemoTile : BlueprintItemContent, Equatable, LocalizedCollatableItemContent +{ + var text : String + var secondaryText: String + + var identifierValue: String { + return "\(text) \(secondaryText)" + } + + func element(with info : ApplyItemContentInfo) -> Element + { + Button(onTap:{ + print("\(text) tapped!") + }, wrapping: Row { row in + row.verticalAlignment = .center + + row.add(child: + Column { col in + col.add(child: Label(text: text) { + $0.font = .systemFont(ofSize: 17.0, weight: .medium) + $0.color = info.state.isActive ? .white : .darkGray + }) + col.add(child: Label(text: secondaryText) { + $0.font = .systemFont(ofSize: 12.0, weight: .light) + $0.color = info.state.isActive ? .white : .gray + }) + } + .inset(horizontal: 15.0, vertical: 24.0) + ) + }) + .accessibilityElement(label: text, value: secondaryText, traits: [.button]) + .listReorderGesture(with: info.reorderingActions, begins: .onLongPress, accessibilityLabel: "Reorder \(text)") + + + } + + func backgroundElement(with info: ApplyItemContentInfo) -> Element? + { + Box( + backgroundColor: info.state.isReordering ? .white(0.8) : .white, + cornerStyle: .rounded(radius: 8.0) + ) + } + + func selectedBackgroundElement(with info: ApplyItemContentInfo) -> Element? + { + Box( + backgroundColor: .white(0.2), + cornerStyle: .rounded(radius: 8.0), + shadowStyle: .simple(radius: 2.0, opacity: 0.15, offset: .init(width: 0.0, height: 1.0), color: .black) + ) + } + + var collationString: String { + return "\(text) \(secondaryText)" + } +} + + struct DemoItem : BlueprintItemContent, Equatable, LocalizedCollatableItemContent { var text : String @@ -142,10 +201,11 @@ struct DemoItem : BlueprintItemContent, Equatable, LocalizedCollatableItemConten if info.isReorderable { row.addFixed( - child: Image( - image: UIImage(named: "ReorderControl"), - contentMode: .center - ) + child: + Image( + image: UIImage(named: "ReorderControl"), + contentMode: .center + ) .listReorderGesture(with: info.reorderingActions, begins: requiresLongPress ? .onLongPress : .onTap) ) } diff --git a/Demo/Sources/Demos/Demo Screens/ReorderingViewController.swift b/Demo/Sources/Demos/Demo Screens/ReorderingViewController.swift index cf4452cc0..a580afd07 100644 --- a/Demo/Sources/Demos/Demo Screens/ReorderingViewController.swift +++ b/Demo/Sources/Demos/Demo Screens/ReorderingViewController.swift @@ -108,5 +108,23 @@ final class ReorderingViewController : ListViewController item.reordering = ItemReordering(sections: .current) } } + + list += Section("5") { section in + section.header = DemoHeader(title: "Tile Section") + section.layouts.table.columns = .init(count: 2, spacing: 15.0) + + section += Item(DemoTile(text: "Item 0", secondaryText: "Section 4")) { item in + item.reordering = ItemReordering(sections: .current) + } + section += Item(DemoTile(text: "Item 1", secondaryText: "Section 4")) { item in + item.reordering = ItemReordering(sections: .current) + } + section += Item(DemoTile(text: "Item 2", secondaryText: "Section 4")) { item in + item.reordering = ItemReordering(sections: .current) + } + section += Item(DemoTile(text: "Item 3", secondaryText: "Section 4")) { item in + item.reordering = ItemReordering(sections: .current) + } + } } } diff --git a/ListableUI/Sources/Item/ItemReordering.swift b/ListableUI/Sources/Item/ItemReordering.swift index a1e895aad..93a0181d1 100644 --- a/ListableUI/Sources/Item/ItemReordering.swift +++ b/ListableUI/Sources/Item/ItemReordering.swift @@ -161,12 +161,16 @@ extension ItemReordering { private var onMove : OnMove? = nil private var onEnd : OnEnd? = nil + // If this is set the gesture recognizer will only fire when the accessibilityProxy is selected by voiceover. + public var accessibilityProxy: NSObject? + /// Creates a gesture recognizer with the provided target and selector. public override init(target: Any?, action: Selector?) { super.init(target: target, action: action) self.addTarget(self, action: #selector(updated)) + self.minimumPressDuration = 0 } @@ -206,6 +210,10 @@ extension ItemReordering { @objc private func updated() { + guard accessibilityShouldContinue() else { + self.state = .cancelled + return + } switch self.state { case .possible: break case .began: @@ -228,6 +236,13 @@ extension ItemReordering { @unknown default: listableInternalFatal() } } + + private func accessibilityShouldContinue() -> Bool { + guard UIAccessibility.isVoiceOverRunning, let proxy = accessibilityProxy else { + return true + } + return UIAccessibility.focusedElement(using: .notificationVoiceOver) as? NSObject == proxy + } } } diff --git a/ListableUI/Sources/ListableLocalizedStrings.swift b/ListableUI/Sources/ListableLocalizedStrings.swift index dc1e67f19..1e816e8bc 100644 --- a/ListableUI/Sources/ListableLocalizedStrings.swift +++ b/ListableUI/Sources/ListableLocalizedStrings.swift @@ -18,7 +18,7 @@ public struct ListableLocalizedStrings { bundle: .listableUIResources, value: "Reorder", comment: "Accessibility label for the reorder control on an item") - + public static let accessibilityHint = NSLocalizedString("reorder.AccessibilityHint", tableName: nil, bundle: .listableUIResources,