Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
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
94 changes: 74 additions & 20 deletions submodules/AlertUI/Sources/TextAlertWithEntitiesController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ import TextNodeWithEntities

private let alertWidth: CGFloat = 270.0

private final class TextAlertWithEntitiesAccessibilityCustomAction: UIAccessibilityCustomAction {
let perform: () -> Void

init(name: String, target: Any?, selector: Selector, perform: @escaping () -> Void) {
self.perform = perform

super.init(name: name, target: target, selector: selector)
}
}

final class TextAlertWithEntitiesContentNode: AlertContentNode {
private var theme: AlertControllerTheme
private let actionLayout: TextAlertContentActionLayout
Expand All @@ -25,6 +35,10 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode {
return self._dismissOnOutsideTap
}

override public var accessibilityInitialFocusNode: ASDisplayNode? {
return self.titleNode ?? self.textNode
}

private var highlightedItemIndex: Int? = nil

var textAttributeAction: (NSAttributedString.Key, (Any) -> Void)? {
Expand All @@ -47,6 +61,7 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode {
self.textNode.highlightAttributeAction = nil
self.textNode.tapAttributeAction = nil
}
self.updateTextAccessibilityActions()
}
}

Expand All @@ -59,10 +74,11 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode {
titleNode.attributedText = title
titleNode.displaysAsynchronously = false
titleNode.isUserInteractionEnabled = false
titleNode.maximumNumberOfLines = 4
titleNode.maximumNumberOfLines = 0
titleNode.truncationType = .end
titleNode.isAccessibilityElement = true
titleNode.accessibilityLabel = title.string
titleNode.accessibilityTraits = [.header]
self.titleNode = titleNode
} else {
self.titleNode = nil
Expand Down Expand Up @@ -127,6 +143,38 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode {
for separatorNode in self.actionVerticalSeparators {
self.addSubnode(separatorNode)
}

self.updateTextAccessibilityActions()
}

private func updateTextAccessibilityActions() {
guard let attributedText = self.textNode.attributedText, let (attribute, textAttributeAction) = self.textAttributeAction, attributedText.length != 0 else {
self.textNode.accessibilityCustomActions = nil
return
}

var accessibilityActions: [UIAccessibilityCustomAction] = []
attributedText.enumerateAttribute(attribute, in: NSRange(location: 0, length: attributedText.length), options: []) { [weak self] value, range, _ in
guard let self, let value else {
return
}
let actionName = attributedText.attributedSubstring(from: range).string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !actionName.isEmpty else {
return
}
accessibilityActions.append(TextAlertWithEntitiesAccessibilityCustomAction(name: actionName, target: self, selector: #selector(self.performTextAccessibilityAction(_:)), perform: {
textAttributeAction(value)
}))
}
self.textNode.accessibilityCustomActions = accessibilityActions.isEmpty ? nil : accessibilityActions
}

@objc private func performTextAccessibilityAction(_ action: UIAccessibilityCustomAction) -> Bool {
guard let action = action as? TextAlertWithEntitiesAccessibilityCustomAction else {
return false
}
action.perform()
return true
}

func setHighlightedItemIndex(_ index: Int?, update: Bool = false) {
Expand Down Expand Up @@ -199,6 +247,13 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode {
_ = self.updateLayout(size: size, transition: .immediate)
}
}

override func contentSizeCategoryUpdated() {
for actionNode in self.actionNodes {
actionNode.updateTheme(self.theme)
}
self.requestLayout?(.immediate)
}

override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
self.validLayout = size
Expand All @@ -214,34 +269,31 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode {
}
let textSize = self.textNode.updateLayout(CGSize(width: size.width - insets.left - insets.right, height: CGFloat.greatestFiniteMagnitude))

let actionButtonHeight: CGFloat = 44.0

var minActionsWidth: CGFloat = 0.0
let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count))
let actionTitleInsets: CGFloat = 8.0
let minimumActionButtonHeight: CGFloat = 44.0
let maxActionWidth: CGFloat = self.actionNodes.isEmpty ? size.width : floor(size.width / CGFloat(self.actionNodes.count))

var effectiveActionLayout = self.actionLayout
if self.traitCollection.preferredContentSizeCategory.isAccessibilityCategory {
effectiveActionLayout = .vertical
}
var actionHeights: [CGFloat] = []
for actionNode in self.actionNodes {
let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight))
if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 {
let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: max(1.0, maxActionWidth - 16.0), height: CGFloat.greatestFiniteMagnitude))
let actionHeight = max(minimumActionButtonHeight, actionTitleSize.height + 20.0)
actionHeights.append(actionHeight)
if case .horizontal = effectiveActionLayout, actionHeight > minimumActionButtonHeight {
effectiveActionLayout = .vertical
}
switch effectiveActionLayout {
case .horizontal:
minActionsWidth += actionTitleSize.width + actionTitleInsets
case .vertical:
minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets)
}
}

let resultSize: CGSize

var actionsHeight: CGFloat = 0.0
switch effectiveActionLayout {
case .horizontal:
actionsHeight = actionButtonHeight
actionsHeight = actionHeights.max() ?? minimumActionButtonHeight
case .vertical:
actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count)
actionsHeight = actionHeights.reduce(0.0, +)
}

let contentWidth = alertWidth - insets.left - insets.right
Expand All @@ -261,10 +313,11 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode {
resultSize = CGSize(width: contentWidth + insets.left + insets.right, height: textSize.height + actionsHeight + insets.top + insets.bottom)
}

self.actionNodesSeparator.isHidden = self.actionNodes.isEmpty
self.actionNodesSeparator.frame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel))

var actionOffset: CGFloat = 0.0
let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count))
let actionWidth: CGFloat = self.actionNodes.isEmpty ? resultSize.width : floor(resultSize.width / CGFloat(self.actionNodes.count))
var separatorIndex = -1
var nodeIndex = 0
for actionNode in self.actionNodes {
Expand Down Expand Up @@ -294,11 +347,12 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode {
let actionNodeFrame: CGRect
switch effectiveActionLayout {
case .horizontal:
actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionsHeight))
actionOffset += currentActionWidth
case .vertical:
actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight))
actionOffset += actionButtonHeight
let actionHeight = actionHeights[nodeIndex]
actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionHeight))
actionOffset += actionHeight
}

transition.updateFrame(node: actionNode, frame: actionNodeFrame)
Expand Down
36 changes: 34 additions & 2 deletions submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1319,8 +1319,9 @@ public struct ChatListSearchContainerTransition {
public let approvedGlobalPostQueryState: ApprovedGlobalPostQueryState?
public let globalSearchStateValue: TelegramGlobalPostSearchState?
public var animated: Bool
public let stableIds: [AnyHashable]

public init(deletions: [ListViewDeleteItem], insertions: [ListViewInsertItem], updates: [ListViewUpdateItem], displayingResults: Bool, isEmpty: Bool, isLoading: Bool, query: String?, approvedGlobalPostQueryState: ApprovedGlobalPostQueryState?, globalSearchStateValue: TelegramGlobalPostSearchState?, animated: Bool) {
public init(deletions: [ListViewDeleteItem], insertions: [ListViewInsertItem], updates: [ListViewUpdateItem], displayingResults: Bool, isEmpty: Bool, isLoading: Bool, query: String?, approvedGlobalPostQueryState: ApprovedGlobalPostQueryState?, globalSearchStateValue: TelegramGlobalPostSearchState?, animated: Bool, stableIds: [AnyHashable] = []) {
self.deletions = deletions
self.insertions = insertions
self.updates = updates
Expand All @@ -1331,9 +1332,17 @@ public struct ChatListSearchContainerTransition {
self.globalSearchStateValue = globalSearchStateValue
self.query = query
self.animated = animated
self.stableIds = stableIds
}
}

private func accessibilityElementIsFocused(in view: UIView) -> Bool {
if view.isAccessibilityElement && view.accessibilityElementIsFocused() {
return true
}
return view.subviews.contains(where: { accessibilityElementIsFocused(in: $0) })
}

enum OpenPeerAction {
case generic
case info
Expand Down Expand Up @@ -1410,7 +1419,7 @@ public func chatListSearchContainerPreparedTransition(
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enableHeaders: enableHeaders, filter: filter, requestPeerType: requestPeerType, location: location, communityId: communityId, key: key, tagMask: tagMask, interaction: interaction, listInteraction: listInteraction, peerContextAction: peerContextAction, toggleExpandLocalResults: toggleExpandLocalResults, toggleExpandGlobalResults: toggleExpandGlobalResults, searchPeer: searchPeer, searchQuery: searchQuery, searchOptions: searchOptions, messageContextAction: messageContextAction, openClearRecentlyDownloaded: openClearRecentlyDownloaded, toggleAllPaused: toggleAllPaused, openStories: openStories, openPublicPosts: openPublicPosts, openMessagesFilter: openMessagesFilter, switchMessagesFilter: switchMessagesFilter), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enableHeaders: enableHeaders, filter: filter, requestPeerType: requestPeerType, location: location, communityId: communityId, key: key, tagMask: tagMask, interaction: interaction, listInteraction: listInteraction, peerContextAction: peerContextAction, toggleExpandLocalResults: toggleExpandLocalResults, toggleExpandGlobalResults: toggleExpandGlobalResults, searchPeer: searchPeer, searchQuery: searchQuery, searchOptions: searchOptions, messageContextAction: messageContextAction, openClearRecentlyDownloaded: openClearRecentlyDownloaded, toggleAllPaused: toggleAllPaused, openStories: openStories, openPublicPosts: openPublicPosts, openMessagesFilter: openMessagesFilter, switchMessagesFilter: switchMessagesFilter), directionHint: nil) }

return ChatListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, displayingResults: displayingResults, isEmpty: isEmpty, isLoading: isLoading, query: searchQuery, approvedGlobalPostQueryState: approvedGlobalPostQueryState, globalSearchStateValue: globalSearchStateValue, animated: animated)
return ChatListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, displayingResults: displayingResults, isEmpty: isEmpty, isLoading: isLoading, query: searchQuery, approvedGlobalPostQueryState: approvedGlobalPostQueryState, globalSearchStateValue: globalSearchStateValue, animated: animated, stableIds: toEntries.map { AnyHashable($0.stableId) })
}

private struct ChatListSearchListPaneNodeState: Equatable {
Expand Down Expand Up @@ -1686,6 +1695,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode {
private let searchContextsValue = Atomic<[Int: ChatListSearchMessagesContext]>(value: [:])
var searchCurrentMessages: [EngineMessage]?
var currentEntries: [ChatListSearchEntry]?
private var displayedEntryIds: [AnyHashable] = []

private var deletedMessagesDisposable: Disposable?

Expand Down Expand Up @@ -5539,8 +5549,30 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode {
options.insert(.PreferSynchronousResourceLoading)
}

var focusedEntryId: AnyHashable?
if UIAccessibility.isVoiceOverRunning, let listNode = self.listNode {
for itemNode in listNode.visibleItemNodes() {
guard let index = itemNode.index, self.displayedEntryIds.indices.contains(index) else {
continue
}
if accessibilityElementIsFocused(in: itemNode.view) {
focusedEntryId = self.displayedEntryIds[index]
break
}
}
}

self.listNode?.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.displayedEntryIds = transition.stableIds
if let focusedEntryId, let index = transition.stableIds.firstIndex(of: focusedEntryId), let listNode = strongSelf.listNode {
for itemNode in listNode.visibleItemNodes() {
if itemNode.index == index, !accessibilityElementIsFocused(in: itemNode.view) {
UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view)
break
}
}
}
let searchOptions = strongSelf.searchOptionsValue
strongSelf.listNode?.isHidden = strongSelf.tagMask == .photoOrVideo && (strongSelf.searchQueryValue ?? "").isEmpty
strongSelf.mediaNode?.isHidden = !(strongSelf.listNode?.isHidden ?? true)
Expand Down
33 changes: 32 additions & 1 deletion submodules/ContactListUI/Sources/ContactListNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,7 @@ private func preparedContactListNodeTransition(context: AccountContext, presenta
scrollToItem = ListViewScrollToItem(index: 0, position: .top(-50.0), animated: false, curve: .Default(duration: 0.0), directionHint: .Up)
}

return ContactsListNodeTransition(deletions: deletions, insertions: insertions, updates: updates, indexSections: indexSections, firstTime: firstTime, isEmpty: isEmpty, hasOptions: hasOptions, scrollToItem: scrollToItem, animation: animation)
return ContactsListNodeTransition(deletions: deletions, insertions: insertions, updates: updates, indexSections: indexSections, firstTime: firstTime, isEmpty: isEmpty, hasOptions: hasOptions, scrollToItem: scrollToItem, animation: animation, entries: toEntries)
}

private struct ContactsListNodeTransition {
Expand All @@ -926,6 +926,14 @@ private struct ContactsListNodeTransition {
let hasOptions: Bool
let scrollToItem: ListViewScrollToItem?
let animation: ContactListAnimation
let entries: [ContactListNodeEntry]
}

private func accessibilityElementIsFocused(in view: UIView) -> Bool {
if view.isAccessibilityElement && view.accessibilityElementIsFocused() {
return true
}
return view.subviews.contains(where: { accessibilityElementIsFocused(in: $0) })
}

public enum ContactListPresentation {
Expand Down Expand Up @@ -1023,6 +1031,7 @@ public final class ContactListNode: ASDisplayNode {
private var indexSections: [String]?

private var queuedTransitions: [ContactsListNodeTransition] = []
private var displayedEntries: [ContactListNodeEntry] = []
private var validLayout: (ContainerViewLayout, UIEdgeInsets, CGFloat)?

private var _ready = ValuePromise<Bool>()
Expand Down Expand Up @@ -2302,8 +2311,30 @@ public final class ContactListNode: ASDisplayNode {
self.indexNode.isUserInteractionEnabled = !transition.indexSections.isEmpty
}

var focusedEntryId: ContactListNodeEntryId?
if UIAccessibility.isVoiceOverRunning {
for itemNode in self.listNode.visibleItemNodes() {
guard let index = itemNode.index, self.displayedEntries.indices.contains(index) else {
continue
}
if accessibilityElementIsFocused(in: itemNode.view) {
focusedEntryId = self.displayedEntries[index].stableId
break
}
}
}

self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, scrollToItem: transition.scrollToItem, updateOpaqueState: nil, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.displayedEntries = transition.entries
if let focusedEntryId, let index = transition.entries.firstIndex(where: { $0.stableId == focusedEntryId }) {
for itemNode in strongSelf.listNode.visibleItemNodes() {
if itemNode.index == index, !accessibilityElementIsFocused(in: itemNode.view) {
UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view)
break
}
}
}
if !strongSelf.didSetReady {
strongSelf.didSetReady = true
strongSelf._ready.set(true)
Expand Down
Loading