Skip to content

Commit c3f8240

Browse files
reorder control now proxies accessibility into a seperate element (#533)
This allows the gesture recognizer to be applied to a view without overriding its accessibility. Useful in situations where you'd like the gesture to apply to the entire cell for example.
1 parent 12b5e65 commit c3f8240

File tree

6 files changed

+177
-23
lines changed

6 files changed

+177
-23
lines changed

BlueprintUILists/Sources/ListReorderGesture.swift

+78-18
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// Created by Kyle Van Essen on 11/14/19.
66
//
77

8+
import Accessibility
89
import BlueprintUI
910
import ListableUI
1011
import UIKit
@@ -38,6 +39,7 @@ import UIKit
3839
/// ```
3940
public struct ListReorderGesture : Element
4041
{
42+
4143
public enum Begins {
4244
case onTap
4345
case onLongPress
@@ -54,9 +56,8 @@ public struct ListReorderGesture : Element
5456

5557
let actions : ReorderingActions
5658

57-
/// The acccessibility Label of the item that will be reordered.
58-
/// This will be set as the gesture's accessibilityValue to provide a richer VoiceOver utterance.
59-
public var reorderItemAccessibilityLabel : String? = nil
59+
/// The acccessibility label for the reorder element. Defaults to "Reorder".
60+
public var accessibilityLabel : String?
6061

6162
/// Creates a new re-order gesture which wraps the provided element.
6263
///
@@ -66,6 +67,7 @@ public struct ListReorderGesture : Element
6667
isEnabled : Bool = true,
6768
actions : ReorderingActions,
6869
begins: Begins = .onTap,
70+
accessibilityLabel: String? = nil,
6971
wrapping element : Element
7072
) {
7173
self.isEnabled = isEnabled
@@ -74,6 +76,8 @@ public struct ListReorderGesture : Element
7476

7577
self.begins = begins
7678

79+
self.accessibilityLabel = accessibilityLabel
80+
7781
self.element = element
7882
}
7983

@@ -88,24 +92,16 @@ public struct ListReorderGesture : Element
8892
public func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription?
8993
{
9094
return ViewDescription(View.self) { config in
95+
9196
config.builder = {
9297
View(frame: context.bounds, wrapping: self)
9398
}
99+
config.contentView = { $0.containerView }
94100

95101
config.apply { view in
96-
view.isAccessibilityElement = true
97-
view.accessibilityLabel = ListableLocalizedStrings.ReorderGesture.accessibilityLabel
98-
view.accessibilityValue = reorderItemAccessibilityLabel
99-
view.accessibilityHint = ListableLocalizedStrings.ReorderGesture.accessibilityHint
100-
view.accessibilityTraits.formUnion(.button)
101-
view.accessibilityCustomActions = accessibilityActions()
102-
103-
view.recognizer.isEnabled = self.isEnabled
104-
105-
view.recognizer.apply(actions: self.actions)
106-
107-
view.recognizer.minimumPressDuration = begins == .onLongPress ? 0.5 : 0.0
102+
view.apply(self)
108103
}
104+
109105
}
110106
}
111107

@@ -118,9 +114,14 @@ public extension Element
118114
func listReorderGesture(
119115
with actions : ReorderingActions,
120116
isEnabled : Bool = true,
121-
begins: ListReorderGesture.Begins = .onTap
117+
begins: ListReorderGesture.Begins = .onTap,
118+
accessibilityLabel: String? = nil
122119
) -> Element {
123-
ListReorderGesture(isEnabled: isEnabled, actions: actions, begins: begins, wrapping: self)
120+
ListReorderGesture(isEnabled: isEnabled,
121+
actions: actions,
122+
begins: begins,
123+
accessibilityLabel: accessibilityLabel,
124+
wrapping: self)
124125
}
125126
}
126127

@@ -129,25 +130,84 @@ fileprivate extension ListReorderGesture
129130
{
130131
private final class View : UIView
131132
{
133+
134+
let containerView = UIView()
132135
let recognizer : ItemReordering.GestureRecognizer
136+
private lazy var proxyElement = UIAccessibilityElement(accessibilityContainer: self)
137+
private var minimumPressDuration: TimeInterval = 0.0 {
138+
didSet {
139+
updateGesturePressDuration()
140+
}
141+
}
142+
143+
@objc private func updateGesturePressDuration() {
144+
self.recognizer.minimumPressDuration = UIAccessibility.isVoiceOverRunning ? 0.0 : self.minimumPressDuration
145+
}
133146

134147
init(frame: CGRect, wrapping : ListReorderGesture)
135148
{
136149
self.recognizer = .init()
137150

138151
super.init(frame: frame)
139-
152+
recognizer.accessibilityProxy = proxyElement
153+
NotificationCenter.default.addObserver(self, selector: #selector(updateGesturePressDuration) , name: UIAccessibility.voiceOverStatusDidChangeNotification, object: nil)
154+
140155
self.isOpaque = false
141156
self.clipsToBounds = false
142157
self.backgroundColor = .clear
143158

144159
self.addGestureRecognizer(self.recognizer)
160+
161+
self.isAccessibilityElement = false
162+
163+
containerView.isOpaque = false
164+
containerView.backgroundColor = .clear
165+
addSubview(containerView)
145166
}
146167

147168
@available(*, unavailable)
148169
required init?(coder aDecoder: NSCoder) {
149170
listableInternalFatal()
150171
}
172+
173+
func apply(_ model: ListReorderGesture) {
174+
proxyElement.accessibilityLabel = model.accessibilityLabel ?? ListableLocalizedStrings.ReorderGesture.accessibilityLabel
175+
proxyElement.accessibilityHint = ListableLocalizedStrings.ReorderGesture.accessibilityHint
176+
proxyElement.accessibilityTraits.formUnion(.button)
177+
proxyElement.accessibilityCustomActions = model.accessibilityActions()
178+
179+
recognizer.isEnabled = model.isEnabled
180+
181+
recognizer.apply(actions: model.actions)
182+
minimumPressDuration = model.begins == .onLongPress ? 0.5 : 0.0
183+
}
184+
185+
override func layoutSubviews() {
186+
super.layoutSubviews()
187+
containerView.frame = bounds
188+
}
189+
190+
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
191+
if UIAccessibility.isVoiceOverRunning,
192+
UIAccessibility.focusedElement(using: .notificationVoiceOver) as? NSObject == proxyElement {
193+
// Intercept touch events to avoid activating contained elements.
194+
return self
195+
}
196+
197+
return super.hitTest(point, with: event)
198+
}
199+
200+
override var accessibilityElements: [Any]? {
201+
get {
202+
guard recognizer.isEnabled else { return super.accessibilityElements }
203+
proxyElement.accessibilityFrame = self.accessibilityFrame
204+
proxyElement.accessibilityActivationPoint = self.accessibilityActivationPoint
205+
return [containerView, proxyElement]
206+
}
207+
set {
208+
fatalError("Cannot set accessibility elements directly")
209+
}
210+
}
151211
}
152212
}
153213

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# [Main]
22

33
### Fixed
4+
- `ListReorderGesture` no longer blocks child accessibility, now exposing a proxy element for accessible control.
45

56
### Added
67

Demo/Sources/Demos/Demo Screens/CollectionViewAppearance.swift

+64-4
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,65 @@ struct DemoHeader2 : BlueprintHeaderFooterContent, Equatable
119119
}
120120

121121

122+
struct DemoTile : BlueprintItemContent, Equatable, LocalizedCollatableItemContent
123+
{
124+
var text : String
125+
var secondaryText: String
126+
127+
var identifierValue: String {
128+
return "\(text) \(secondaryText)"
129+
}
130+
131+
func element(with info : ApplyItemContentInfo) -> Element
132+
{
133+
Button(onTap:{
134+
print("\(text) tapped!")
135+
}, wrapping: Row { row in
136+
row.verticalAlignment = .center
137+
138+
row.add(child:
139+
Column { col in
140+
col.add(child: Label(text: text) {
141+
$0.font = .systemFont(ofSize: 17.0, weight: .medium)
142+
$0.color = info.state.isActive ? .white : .darkGray
143+
})
144+
col.add(child: Label(text: secondaryText) {
145+
$0.font = .systemFont(ofSize: 12.0, weight: .light)
146+
$0.color = info.state.isActive ? .white : .gray
147+
})
148+
}
149+
.inset(horizontal: 15.0, vertical: 24.0)
150+
)
151+
})
152+
.accessibilityElement(label: text, value: secondaryText, traits: [.button])
153+
.listReorderGesture(with: info.reorderingActions, begins: .onLongPress, accessibilityLabel: "Reorder \(text)")
154+
155+
156+
}
157+
158+
func backgroundElement(with info: ApplyItemContentInfo) -> Element?
159+
{
160+
Box(
161+
backgroundColor: info.state.isReordering ? .white(0.8) : .white,
162+
cornerStyle: .rounded(radius: 8.0)
163+
)
164+
}
165+
166+
func selectedBackgroundElement(with info: ApplyItemContentInfo) -> Element?
167+
{
168+
Box(
169+
backgroundColor: .white(0.2),
170+
cornerStyle: .rounded(radius: 8.0),
171+
shadowStyle: .simple(radius: 2.0, opacity: 0.15, offset: .init(width: 0.0, height: 1.0), color: .black)
172+
)
173+
}
174+
175+
var collationString: String {
176+
return "\(text) \(secondaryText)"
177+
}
178+
}
179+
180+
122181
struct DemoItem : BlueprintItemContent, Equatable, LocalizedCollatableItemContent
123182
{
124183
var text : String
@@ -142,10 +201,11 @@ struct DemoItem : BlueprintItemContent, Equatable, LocalizedCollatableItemConten
142201

143202
if info.isReorderable {
144203
row.addFixed(
145-
child: Image(
146-
image: UIImage(named: "ReorderControl"),
147-
contentMode: .center
148-
)
204+
child:
205+
Image(
206+
image: UIImage(named: "ReorderControl"),
207+
contentMode: .center
208+
)
149209
.listReorderGesture(with: info.reorderingActions, begins: requiresLongPress ? .onLongPress : .onTap)
150210
)
151211
}

Demo/Sources/Demos/Demo Screens/ReorderingViewController.swift

+18
Original file line numberDiff line numberDiff line change
@@ -108,5 +108,23 @@ final class ReorderingViewController : ListViewController
108108
item.reordering = ItemReordering(sections: .current)
109109
}
110110
}
111+
112+
list += Section("5") { section in
113+
section.header = DemoHeader(title: "Tile Section")
114+
section.layouts.table.columns = .init(count: 2, spacing: 15.0)
115+
116+
section += Item(DemoTile(text: "Item 0", secondaryText: "Section 4")) { item in
117+
item.reordering = ItemReordering(sections: .current)
118+
}
119+
section += Item(DemoTile(text: "Item 1", secondaryText: "Section 4")) { item in
120+
item.reordering = ItemReordering(sections: .current)
121+
}
122+
section += Item(DemoTile(text: "Item 2", secondaryText: "Section 4")) { item in
123+
item.reordering = ItemReordering(sections: .current)
124+
}
125+
section += Item(DemoTile(text: "Item 3", secondaryText: "Section 4")) { item in
126+
item.reordering = ItemReordering(sections: .current)
127+
}
128+
}
111129
}
112130
}

ListableUI/Sources/Item/ItemReordering.swift

+15
Original file line numberDiff line numberDiff line change
@@ -161,12 +161,16 @@ extension ItemReordering {
161161
private var onMove : OnMove? = nil
162162
private var onEnd : OnEnd? = nil
163163

164+
// If this is set the gesture recognizer will only fire when the accessibilityProxy is selected by voiceover.
165+
public var accessibilityProxy: NSObject?
166+
164167
/// Creates a gesture recognizer with the provided target and selector.
165168
public override init(target: Any?, action: Selector?)
166169
{
167170
super.init(target: target, action: action)
168171

169172
self.addTarget(self, action: #selector(updated))
173+
170174
self.minimumPressDuration = 0
171175
}
172176

@@ -206,6 +210,10 @@ extension ItemReordering {
206210

207211
@objc private func updated()
208212
{
213+
guard accessibilityShouldContinue() else {
214+
self.state = .cancelled
215+
return
216+
}
209217
switch self.state {
210218
case .possible: break
211219
case .began:
@@ -228,6 +236,13 @@ extension ItemReordering {
228236
@unknown default: listableInternalFatal()
229237
}
230238
}
239+
240+
private func accessibilityShouldContinue() -> Bool {
241+
guard UIAccessibility.isVoiceOverRunning, let proxy = accessibilityProxy else {
242+
return true
243+
}
244+
return UIAccessibility.focusedElement(using: .notificationVoiceOver) as? NSObject == proxy
245+
}
231246
}
232247
}
233248

ListableUI/Sources/ListableLocalizedStrings.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public struct ListableLocalizedStrings {
1818
bundle: .listableUIResources,
1919
value: "Reorder",
2020
comment: "Accessibility label for the reorder control on an item")
21-
21+
2222
public static let accessibilityHint = NSLocalizedString("reorder.AccessibilityHint",
2323
tableName: nil,
2424
bundle: .listableUIResources,

0 commit comments

Comments
 (0)