Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

reorder control now proxies accessibility into a seperate element #533

Merged
merged 1 commit into from
Mar 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 78 additions & 18 deletions BlueprintUILists/Sources/ListReorderGesture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Created by Kyle Van Essen on 11/14/19.
//

import Accessibility
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huh, TIL

import BlueprintUI
import ListableUI
import UIKit
Expand Down Expand Up @@ -38,6 +39,7 @@ import UIKit
/// ```
public struct ListReorderGesture : Element
{

public enum Begins {
case onTap
case onLongPress
Expand All @@ -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.
///
Expand All @@ -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
Expand All @@ -74,6 +76,8 @@ public struct ListReorderGesture : Element

self.begins = begins

self.accessibilityLabel = accessibilityLabel

self.element = element
}

Expand All @@ -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)
}

}
}

Expand All @@ -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)
}
}

Expand All @@ -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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, TIL, I had no idea you could do this!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, although seems obvious in retrospect.

private var minimumPressDuration: TimeInterval = 0.0 {
didSet {
updateGesturePressDuration()
}
}

@objc private func updateGesturePressDuration() {
self.recognizer.minimumPressDuration = UIAccessibility.isVoiceOverRunning ? 0.0 : self.minimumPressDuration
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe not for this PR, but wondering how the behaviour would be with full keyboard access, Switch Control, and Voice Control.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this out using full keyboard access. It kind of works, but I observed that Listable reordering is overall broken for full keyboard access. Then again, even the iOS home screen felt broken for full keyboard access, so there's no prior art to even copy from.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldnt expect the gesture recognizer itself to work well with keyboard access. do the accessibility actions work with full keyboard access?

}

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

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# [Main]

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

### Added

Expand Down
68 changes: 64 additions & 4 deletions Demo/Sources/Demos/Demo Screens/CollectionViewAppearance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
)
}
Expand Down
18 changes: 18 additions & 0 deletions Demo/Sources/Demos/Demo Screens/ReorderingViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
15 changes: 15 additions & 0 deletions ListableUI/Sources/Item/ItemReordering.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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:
Expand All @@ -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
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion ListableUI/Sources/ListableLocalizedStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading