Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
25238ee
Improve VoiceOver navigation and message accessibility
Kostenkov-2021 Jul 22, 2026
6a9b10a
Expand VoiceOver message actions and sharing accessibility
Kostenkov-2021 Jul 24, 2026
452baa3
Improve VoiceOver accessibility for modal interfaces
Kostenkov-2021 Jul 24, 2026
8591801
Improve Dynamic Type support for accessible modals
Kostenkov-2021 Jul 24, 2026
512637e
Improve VoiceOver support for alerts and Voice Control
Kostenkov-2021 Jul 24, 2026
41b9bd3
Improve VoiceOver reply navigation and peer info actions
Kostenkov-2021 Jul 24, 2026
2cff1ba
Improve VoiceOver accessibility for peer info members and header
Kostenkov-2021 Jul 24, 2026
2c9ee56
Improve VoiceOver accessibility for peer info media panes
Kostenkov-2021 Jul 24, 2026
1e328cb
Improve VoiceOver behavior in embedded peer info panes
Kostenkov-2021 Jul 24, 2026
d9f29dd
Improve VoiceOver accessibility in gifts and sharing
Kostenkov-2021 Jul 24, 2026
1864188
Improve VoiceOver accessibility in selection flows
Kostenkov-2021 Jul 24, 2026
047760a
Keep VoiceOver scroll feedback consistently localized
Kostenkov-2021 Jul 25, 2026
ca99e37
Improve VoiceOver selection flows and Share Extension
Kostenkov-2021 Jul 25, 2026
2108986
Improve VoiceOver accessibility for gifts and reply media
Kostenkov-2021 Jul 29, 2026
81e0eb2
Improve VoiceOver focus persistence and modal accessibility
Kostenkov-2021 Jul 29, 2026
e37e5ba
Complete VoiceOver focus persistence and regression gates
Kostenkov-2021 Aug 22, 2026
0f958f6
Expand VoiceOver regression coverage
Kostenkov-2021 Aug 22, 2026
693b4f9
Improve rich text scaling and Voice Control performance coverage
Kostenkov-2021 Aug 22, 2026
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
Prev Previous commit
Next Next commit
Improve VoiceOver selection flows and Share Extension
Add selected and disabled accessibility states to share and gift selection flows.
Preserve VoiceOver focus across peer, topic, search, and gift transactions.
Improve Share Extension focus transitions, modal containment, Escape handling,
context menu dismissal, and send error presentation.
Exclude hidden animated content from the accessibility tree.
  • Loading branch information
Kostenkov-2021 committed Jul 25, 2026
commit ca99e370a6395a0c9799ee92807a18e55b998faa
7 changes: 7 additions & 0 deletions submodules/ShareController/Sources/ShareController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2376,8 +2376,15 @@ public final class ShareController: ViewController {
if !self.immediateExternalShare {
self.controllerNode.animateIn()
}
Queue.mainQueue().after(0.1) { [weak self] in
self?.controllerNode.activateInitialAccessibilityFocus()
}
}
}

override public func accessibilityPerformEscape() -> Bool {
return self.controllerNode.performAccessibilityEscape()
}

override public func dismiss(completion: (() -> Void)? = nil) {
self.controllerNode.view.endEditing(true)
Expand Down
75 changes: 72 additions & 3 deletions submodules/ShareController/Sources/ShareControllerNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,12 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate
}
}))
])
return ContextController.Items(content: .list(items), animationCache: nil)
return ContextController.Items(content: .list(items), dismissed: { [weak self] in
guard let self, UIAccessibility.isVoiceOverRunning, !self.actionButtonNode.accessibilityElementsHidden, self.actionButtonNode.alpha > 0.0, self.actionButtonNode.view.window != nil else {
return
}
UIAccessibility.post(notification: .layoutChanged, argument: self.actionButtonNode.view)
}, animationCache: nil)
}
let contextController = makeContextController(presentationData: presentationData, source: .reference(ShareContextReferenceContentSource(sourceNode: node, customPosition: CGPoint(x: 0.0, y: fromForeignApp ? -116.0 : 0.0))), items: items, gesture: gesture)
contextController.immediateItemsTransitionAnimation = true
Expand Down Expand Up @@ -784,6 +789,8 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate

override func didLoad() {
super.didLoad()

self.view.accessibilityViewIsModal = true

if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.wrappingScrollNode.view.contentInsetAdjustmentBehavior = .never
Expand Down Expand Up @@ -861,6 +868,10 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate
self?.contentNodeOffsetUpdated(contentOffset, transition: transition)
})
strongSelf.contentNodeOffsetUpdated(topicsContentNode.contentGridNode.scrollView.contentOffset.y, transition: .animated(duration: 0.4, curve: .spring))

if UIAccessibility.isVoiceOverRunning {
UIAccessibility.post(notification: .screenChanged, argument: topicsContentNode.accessibilityInitialFocusTarget)
}

strongSelf.view.endEditing(true)
}
Expand Down Expand Up @@ -901,6 +912,9 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate
}
})
}
if UIAccessibility.isVoiceOverRunning {
UIAccessibility.post(notification: .screenChanged, argument: searchContentNode.accessibilityFocusTarget(peerId: peerId) ?? searchContentNode.accessibilityInitialFocusTarget)
}
} else if let peersContentNode = self.peersContentNode {
peersContentNode.setDidBeginDragging({ [weak self] in
self?.contentNodeDidBeginDragging()
Expand All @@ -919,7 +933,40 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate
}
})
}
if UIAccessibility.isVoiceOverRunning {
UIAccessibility.post(notification: .screenChanged, argument: peersContentNode.accessibilityFocusTarget(peerId: peerId))
}
}
}

func activateInitialAccessibilityFocus() {
guard UIAccessibility.isVoiceOverRunning else {
return
}
let target: Any?
if let topicsContentNode = self.topicsContentNode {
>
} else if let searchContentNode = self.contentNode as? ShareSearchContainerNode {
>
} else if let peersContentNode = self.peersContentNode {
>
} else {
>
}
UIAccessibility.post(notification: .screenChanged, argument: target)
}

func performAccessibilityEscape() -> Bool {
if let topicsContentNode = self.topicsContentNode {
topicsContentNode.backPressed()
return true
}
if self.contentNode is ShareSearchContainerNode, let peersContentNode = self.peersContentNode {
self.transitionToContentNode(peersContentNode)
return true
}
self.cancel?()
return true
}

func updatePresentationData(_ presentationData: PresentationData) {
Expand Down Expand Up @@ -1068,6 +1115,12 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate
} else if !(contentNode is ShareLoadingContainer) {
self.setActionNodesHidden(false, inputField: !self.controllerInteraction!.selectedPeers.isEmpty || self.presetText != nil || self.mediaParameters?.publicLinkPrefix != nil, actions: true)
}

if let searchContentNode = contentNode as? ShareSearchContainerNode, UIAccessibility.isVoiceOverRunning {
UIAccessibility.post(notification: .screenChanged, argument: searchContentNode.accessibilityInitialFocusTarget)
} else if contentNode === self.peersContentNode, previous is ShareSearchContainerNode, let peersContentNode = self.peersContentNode, UIAccessibility.isVoiceOverRunning {
UIAccessibility.post(notification: .screenChanged, argument: peersContentNode.accessibilitySearchFocusTarget)
}
} else {
if let contentNode = self.contentNode {
contentNode.accessibilityElementsHidden = false
Expand Down Expand Up @@ -1525,8 +1578,24 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate
strongSelf.dismiss?(true)
}
}
}, error: { _ in

}, error: { [weak self] error in
guard let self else {
return
}
if let peersContentNode = self.peersContentNode {
self.transitionToContentNode(peersContentNode, fastOut: true)
}
switch error {
case .generic:
self.presentError(nil, self.presentationData.strings.Login_UnknownError)
case let .fileTooBig(size):
self.presentError(
self.presentationData.strings.Notifications_UploadError_TooLarge_Title,
self.presentationData.strings.Notifications_UploadError_TooLarge_Text(
dataSizeString(size, formatting: DataSizeStringFormatting(presentationData: self.presentationData))
).string
)
}
}, completed: {
if !wasDone && fromForeignApp {
doneImpl(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,9 @@ final class ShareControllerGridSectionNode: ASDisplayNode {
super.init()

self.isAccessibilityElement = true
self.accessibilityTraits = .button
self.peerNode.accessibilityElementsHidden = true
self.accessibilityLabel = title
self.accessibilityTraits = .header
self.titleNode.accessibilityElementsHidden = true

self.addSubnode(self.backgroundNode)
self.addSubnode(self.titleNode)
Expand Down Expand Up @@ -183,6 +184,10 @@ final class ShareControllerPeerGridItemNode: GridItemNode {
self.peerNode = SelectablePeerNode()

super.init()

self.isAccessibilityElement = true
self.accessibilityTraits = .button
self.peerNode.accessibilityElementsHidden = true

self.peerNode.toggleSelection = { [weak self] isDisabled in
if let strongSelf = self {
Expand Down Expand Up @@ -325,10 +330,17 @@ final class ShareControllerPeerGridItemNode: GridItemNode {

func updateSelection(animated: Bool) {
var selected = false
var isDisabled = false
if let controllerInteraction = self.controllerInteraction, let (_, _, _, _, maybeItem, _) = self.currentState, let item = maybeItem {
if case let .peer(peer, _, _, _, _, _) = item {
if case let .peer(peer, _, _, _, requiresPremiumForMessaging, _) = item {
selected = controllerInteraction.selectedPeerIds.contains(peer.peerId)
isDisabled = requiresPremiumForMessaging
self.accessibilityHint = requiresPremiumForMessaging ? self.currentState?.strings.Chat_ToastMessagingRestrictedToPremium_Text(peer.peer?.compactDisplayTitle ?? "").string : nil
} else {
self.accessibilityHint = nil
}
} else {
self.accessibilityHint = nil
}

self.peerNode.updateSelection(selected: selected, animated: animated)
Expand All @@ -338,15 +350,20 @@ final class ShareControllerPeerGridItemNode: GridItemNode {
} else {
self.accessibilityTraits.remove(.selected)
}
if isDisabled {
self.accessibilityTraits.insert(.notEnabled)
} else {
self.accessibilityTraits.remove(.notEnabled)
}
}

override func accessibilityActivate() -> Bool {
guard let controllerInteraction = self.controllerInteraction, let item = self.currentState?.item else {
return false
}
switch item {
case let .peer(peer, _, _, _, requiresPremiumForMessaging, requiresStars):
if requiresPremiumForMessaging || requiresStars != nil {
case let .peer(peer, _, _, _, requiresPremiumForMessaging, _):
if requiresPremiumForMessaging {
controllerInteraction.disabledPeerSelected(peer)
} else {
controllerInteraction.togglePeer(peer, self.currentState?.search ?? false)
Expand Down
43 changes: 42 additions & 1 deletion submodules/ShareController/Sources/SharePeersContainerNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -363,18 +363,56 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode {
private func dequeueTransition() {
if let (transition, _) = self.enqueuedTransitions.first {
self.enqueuedTransitions.remove(at: 0)

var focusedPeerId: EnginePeer.Id?
self.contentGridNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.view.accessibilityElementIsFocused() {
focusedPeerId = itemNode.peerId
}
}

var itemTransition: ContainedViewLayoutTransition = .immediate
if transition.animated {
itemTransition = .animated(duration: 0.3, curve: .spring)
}
self.contentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in })
self.contentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { [weak self] _ in
guard let self, let focusedPeerId else {
return
}
self.contentGridNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == focusedPeerId, !itemNode.view.accessibilityElementIsFocused() {
UIAccessibility.post(notification: .layoutChanged, argument: itemNode.view)
}
}
})
}
}

func setEnsurePeerVisibleOnLayout(_ peerId: EnginePeer.Id?) {
self.ensurePeerVisibleOnLayout = peerId
}

func accessibilityFocusTarget(peerId: EnginePeer.Id? = nil) -> Any? {
if let peerId {
var result: Any?
self.contentGridNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == peerId {
result = itemNode.view
}
}
if let result {
return result
}
}
if self.segmentedValues != nil {
return self.segmentedNode.view
}
return self.contentGridNode.view
}

var accessibilitySearchFocusTarget: Any {
return self.searchButtonNode.view
}

func setDidBeginDragging(_ f: (() -> Void)?) {
self.contentDidBeginDragging = f
Expand Down Expand Up @@ -430,6 +468,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode {
}

func prepareForAnimateIn() {
self.accessibilityElementsHidden = true
self.searchButtonNode.alpha = 0.0
self.shareButtonNode.alpha = 0.0
self.contentTitleNode.alpha = 0.0
Expand All @@ -438,6 +477,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode {
}

func animateIn(peerId: EnginePeer.Id, scrollDelta: CGFloat) -> CGRect? {
self.accessibilityElementsHidden = false
self.headerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)

self.searchButtonNode.alpha = 1.0
Expand Down Expand Up @@ -510,6 +550,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode {
}

func animateOut(peerId: EnginePeer.Id, scrollDelta: CGFloat) -> CGRect? {
self.accessibilityElementsHidden = true
self.headerNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)

self.searchButtonNode.alpha = 0.0
Expand Down
4 changes: 4 additions & 0 deletions submodules/ShareController/Sources/ShareSearchBarNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ final class ShareSearchBarNode: ASDisplayNode, UITextFieldDelegate {
private let inputInsets = UIEdgeInsets(top: 10.0, left: 26.0, bottom: 10.0, right: 10.0 + 16.0)

var textUpdated: ((String) -> Void)?

var accessibilityFocusTarget: UIView {
return self.textInputNode.textField
}

init(theme: PresentationTheme, strings: PresentationStrings, placeholder: String) {
self.backgroundNode = ASImageNode()
Expand Down
Loading