From 25238eeaed3b04afe902104329cadc4ea5ee5cd2 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:47:40 +0500 Subject: [PATCH 01/11] Improve VoiceOver navigation and message accessibility - preserve VoiceOver focus by message ID across history transactions - improve accessibility scrolling and focus restoration - expose accessible history navigation buttons - align input field hit testing, frame, and focus behavior - unify accessibility data handling across message renderers - move reply context, including voice message replies, into the accessibility hint --- submodules/Display/Source/ListView.swift | 29 +++++++++++++-- .../ChatMessageAnimatedStickerItemNode.swift | 14 +------- .../Sources/ChatMessageBubbleItemNode.swift | 14 +------- .../ChatMessageInstantVideoItemNode.swift | 14 +------- .../Sources/ChatMessageItemView.swift | 36 +++++++++++++++++-- .../Sources/ChatMessageStickerItemNode.swift | 14 +------- .../Sources/ChatTextInputPanelNode.swift | 5 +++ .../Sources/ChatControllerNode.swift | 3 +- .../Sources/ChatHistoryListNode.swift | 17 +++++++++ .../ChatHistoryNavigationButtonNode.swift | 26 +++++++++++++- .../ChatHistoryNavigationButtons.swift | 12 +++---- 11 files changed, 118 insertions(+), 66 deletions(-) diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index 6e945691f3a..dd000586849 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -5442,7 +5442,7 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur if let (_, frame) = accessibilityFocusedNode { for itemNode in self.itemNodes { if frame.intersects(itemNode.frame) { - UIAccessibility.post(notification: UIAccessibility.Notification.layoutChanged, argument: itemNode.view) + UIAccessibility.post(notification: UIAccessibility.Notification.layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view) if let index = itemNode.index { let scrollStatus: String if let accessibilityPageScrolledString = self.accessibilityPageScrolledString { @@ -5465,8 +5465,10 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur switch direction { case .down: scrollDirection = self.rotated ? .up : .down - default: + case .up: scrollDirection = self.rotated ? .down : .up + default: + return false } return self.scrollWithDirection(scrollDirection, distance: distance) } @@ -5477,8 +5479,29 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur } private func findAccessibilityFocus(_ node: ASDisplayNode) -> Bool { - if node.view.accessibilityElementIsFocused() { + return containsAccessibilityFocus(node.view) +} + +private func containsAccessibilityFocus(_ view: UIView) -> Bool { + if view.accessibilityElementIsFocused() { return true } + for subview in view.subviews { + if containsAccessibilityFocus(subview) { + return true + } + } return false } + +private func firstAccessibilityElement(in view: UIView) -> UIView? { + if view.isAccessibilityElement && !view.isHidden && view.alpha > 0.01 { + return view + } + for subview in view.subviews { + if let result = firstAccessibilityElement(in: subview) { + return result + } + } + return nil +} diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift index 06d1369916a..da2567dc2f3 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift @@ -801,19 +801,7 @@ public class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { } override public func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData) { - super.updateAccessibilityData(accessibilityData) - - self.messageAccessibilityArea.accessibilityLabel = accessibilityData.label - self.messageAccessibilityArea.accessibilityValue = accessibilityData.value - self.messageAccessibilityArea.accessibilityHint = accessibilityData.hint - self.messageAccessibilityArea.accessibilityTraits = accessibilityData.traits - if let customActions = accessibilityData.customActions { - self.messageAccessibilityArea.accessibilityCustomActions = customActions.map({ action -> UIAccessibilityCustomAction in - return ChatMessageAccessibilityCustomAction(name: action.name, target: self, selector: #selector(self.performLocalAccessibilityCustomAction(_:)), action: action.action) - }) - } else { - self.messageAccessibilityArea.accessibilityCustomActions = nil - } + super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:))) } @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 2dcfc95ed57..2413f1bc580 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -5681,19 +5681,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } override public func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData) { - super.updateAccessibilityData(accessibilityData) - - self.messageAccessibilityArea.accessibilityLabel = accessibilityData.label - self.messageAccessibilityArea.accessibilityValue = accessibilityData.value - self.messageAccessibilityArea.accessibilityHint = accessibilityData.hint - self.messageAccessibilityArea.accessibilityTraits = accessibilityData.traits - if let customActions = accessibilityData.customActions { - self.messageAccessibilityArea.accessibilityCustomActions = customActions.map({ action -> UIAccessibilityCustomAction in - return ChatMessageAccessibilityCustomAction(name: action.name, target: self, selector: #selector(self.performLocalAccessibilityCustomAction(_:)), action: action.action) - }) - } else { - self.messageAccessibilityArea.accessibilityCustomActions = nil - } + super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:))) } @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift index f8c3faf6960..6daacd49eb1 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift @@ -246,19 +246,7 @@ public class ChatMessageInstantVideoItemNode: ChatMessageItemView, ASGestureReco } override public func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData) { - super.updateAccessibilityData(accessibilityData) - - self.messageAccessibilityArea.accessibilityLabel = accessibilityData.label - self.messageAccessibilityArea.accessibilityValue = accessibilityData.value - self.messageAccessibilityArea.accessibilityHint = accessibilityData.hint - self.messageAccessibilityArea.accessibilityTraits = accessibilityData.traits - if let customActions = accessibilityData.customActions { - self.messageAccessibilityArea.accessibilityCustomActions = customActions.map({ action -> UIAccessibilityCustomAction in - return ChatMessageAccessibilityCustomAction(name: action.name, target: self, selector: #selector(self.performLocalAccessibilityCustomAction(_:)), action: action.action) - }) - } else { - self.messageAccessibilityArea.accessibilityCustomActions = nil - } + super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:))) } @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift index e6d467fbff3..42b5743520d 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift @@ -604,12 +604,18 @@ public final class ChatMessageAccessibilityData { } if let replyValue { - value = "\(value). \(item.presentationData.strings.VoiceOver_Chat_ReplyingToMessage(replyValue).string)" + let replyHint = item.presentationData.strings.VoiceOver_Chat_ReplyingToMessage(replyValue).string + if let hint, !hint.isEmpty { + self.hint = "\(hint). \(replyHint)" + } else { + self.hint = replyHint + } + } else { + self.hint = hint } self.label = label self.value = value - self.hint = hint self.traits = traits self.customActions = customActions.isEmpty ? nil : customActions self.singleUrl = singleUrl @@ -656,6 +662,7 @@ open class ChatMessageItemView: ListViewItemNode, ChatMessageItemNodeProtocol { open var item: ChatMessageItem? open var accessibilityData: ChatMessageAccessibilityData? + private weak var messageAccessibilityNode: AccessibilityAreaNode? open var safeInsets = UIEdgeInsets() open var awaitingAppliedReaction: (MessageReaction.Reaction?, () -> Void)? @@ -694,6 +701,31 @@ open class ChatMessageItemView: ListViewItemNode, ChatMessageItemNodeProtocol { open func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData) { self.accessibilityData = accessibilityData } + + public func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData, accessibilityNode: AccessibilityAreaNode, customActionTarget: Any, customActionSelector: Selector) { + self.accessibilityData = accessibilityData + self.messageAccessibilityNode = accessibilityNode + + accessibilityNode.accessibilityLabel = accessibilityData.label + accessibilityNode.accessibilityValue = accessibilityData.value + accessibilityNode.accessibilityHint = accessibilityData.hint + accessibilityNode.accessibilityTraits = accessibilityData.traits + if let customActions = accessibilityData.customActions { + accessibilityNode.accessibilityCustomActions = customActions.map { action in + return ChatMessageAccessibilityCustomAction(name: action.name, target: customActionTarget, selector: customActionSelector, action: action.action) + } + } else { + accessibilityNode.accessibilityCustomActions = nil + } + } + + public func accessibilityContainsFocus() -> Bool { + return self.messageAccessibilityNode?.view.accessibilityElementIsFocused() == true || self.view.accessibilityElementIsFocused() + } + + public func restoreAccessibilityFocus() { + UIAccessibility.post(notification: .layoutChanged, argument: self.messageAccessibilityNode?.view ?? self.view) + } override open func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { if let item = item as? ChatMessageItem { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift index 4d5cd55f59b..30a4ac9a9db 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift @@ -389,19 +389,7 @@ public class ChatMessageStickerItemNode: ChatMessageItemView { } override public func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData) { - super.updateAccessibilityData(accessibilityData) - - self.messageAccessibilityArea.accessibilityLabel = accessibilityData.label - self.messageAccessibilityArea.accessibilityValue = accessibilityData.value - self.messageAccessibilityArea.accessibilityHint = accessibilityData.hint - self.messageAccessibilityArea.accessibilityTraits = accessibilityData.traits - if let customActions = accessibilityData.customActions { - self.messageAccessibilityArea.accessibilityCustomActions = customActions.map({ action -> UIAccessibilityCustomAction in - return ChatMessageAccessibilityCustomAction(name: action.name, target: self, selector: #selector(self.performLocalAccessibilityCustomAction(_:)), action: action.action) - }) - } else { - self.messageAccessibilityArea.accessibilityCustomActions = nil - } + super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:))) } @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index 826bdb4098d..0d0d35b31c4 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -1185,6 +1185,9 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg } old.resignInputFirstResponder() old.asNode.removeFromSupernode() + if wasFirstResponder && UIAccessibility.isVoiceOverRunning { + UIAccessibility.post(notification: .layoutChanged, argument: new.inputView) + } } private func loadTextInputNode(useNative: Bool = false) { @@ -3198,6 +3201,8 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg richTextInputNode.textContainerInset = textInputViewRealInsets richTextInputNode.textFieldFrame = actualTextFieldFrame richTextInputNode.updateLayout(size: textFieldFrame.size) + let accessibilityBounds = richTextInputNode.inputView.bounds.inset(by: richTextInputNode.inputHitTestSlop) + richTextInputNode.inputView.accessibilityFrame = UIAccessibility.convertToScreenCoordinates(accessibilityBounds, in: richTextInputNode.inputView) self.updateInputField(textInputFrame: textFieldFrame, transition: ComponentTransition(transition)) if shouldUpdateLayout { richTextInputNode.layoutInputField() diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 83c19884c8d..a8f8b31aa37 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -811,8 +811,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { } self.inputPanelBackgroundNode.isUserInteractionEnabled = false - self.navigateButtons = ChatHistoryNavigationButtons(theme: self.chatPresentationInterfaceState.theme, preferClearGlass: self.chatPresentationInterfaceState.preferredGlassType == .clear, dateTimeFormat: self.chatPresentationInterfaceState.dateTimeFormat, backgroundNode: self.backgroundNode, isChatRotated: historyNodeRotated) - self.navigateButtons.accessibilityElementsHidden = true + self.navigateButtons = ChatHistoryNavigationButtons(theme: self.chatPresentationInterfaceState.theme, strings: self.chatPresentationInterfaceState.strings, preferClearGlass: self.chatPresentationInterfaceState.preferredGlassType == .clear, dateTimeFormat: self.chatPresentationInterfaceState.dateTimeFormat, backgroundNode: self.backgroundNode, isChatRotated: historyNodeRotated) super.init() diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index b547dd3b6f7..c1b8580fda5 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -3873,6 +3873,15 @@ public final class ChatHistoryListNodeImpl: ASDisplayNode, ChatHistoryNode, Chat } self.hasActiveTransition = true let transition = self.enqueuedHistoryViewTransitions.removeFirst() + + var accessibilityFocusedMessageId: MessageId? + if UIAccessibility.isVoiceOverRunning { + self.forEachVisibleMessageItemNode { itemNode in + if accessibilityFocusedMessageId == nil, itemNode.accessibilityContainsFocus(), let item = itemNode.item { + accessibilityFocusedMessageId = item.content.first?.0.id + } + } + } var expiredMessageStableIds = Set() if let previousHistoryView = self.historyView, transition.options.contains(.AnimateInsertion) { @@ -4429,6 +4438,14 @@ public final class ChatHistoryListNodeImpl: ASDisplayNode, ChatHistoryNode, Chat } strongSelf.hasActiveTransition = false + + if let accessibilityFocusedMessageId { + strongSelf.forEachVisibleMessageItemNode { itemNode in + if let item = itemNode.item, item.content.contains(where: { $0.0.id == accessibilityFocusedMessageId }) { + itemNode.restoreAccessibilityFocus() + } + } + } if let previousCloneView { previousCloneView.transform = strongSelf.view.transform diff --git a/submodules/TelegramUI/Sources/ChatHistoryNavigationButtonNode.swift b/submodules/TelegramUI/Sources/ChatHistoryNavigationButtonNode.swift index 53e1f936985..158651f5d5e 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryNavigationButtonNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryNavigationButtonNode.swift @@ -42,6 +42,7 @@ class ChatHistoryNavigationButtonNode: ContextControllerSourceNode { var badge: String = "" { didSet { if self.badge != oldValue { + self.accessibilityValue = self.badge.isEmpty ? nil : self.badge self.layoutBadge() } } @@ -51,7 +52,7 @@ class ChatHistoryNavigationButtonNode: ContextControllerSourceNode { private var preferClearGlass: Bool private let type: ChatHistoryNavigationButtonType - init(theme: PresentationTheme, preferClearGlass: Bool, backgroundNode: WallpaperBackgroundNode, type: ChatHistoryNavigationButtonType) { + init(theme: PresentationTheme, strings: PresentationStrings, preferClearGlass: Bool, backgroundNode: WallpaperBackgroundNode, type: ChatHistoryNavigationButtonType) { self.theme = theme self.preferClearGlass = preferClearGlass self.type = type @@ -84,6 +85,21 @@ class ChatHistoryNavigationButtonNode: ContextControllerSourceNode { self.badgeTextNode.reverseAnimationDirection = true super.init() + + self.isAccessibilityElement = true + self.accessibilityTraits = .button + switch type { + case .down: + self.accessibilityLabel = strings.KeyCommand_ScrollDown + case .up: + self.accessibilityLabel = strings.KeyCommand_ScrollUp + case .mentions: + self.accessibilityLabel = strings.Conversation_ContextMenuMention + case .reactions: + self.accessibilityLabel = strings.Conversation_ReadAllReactions + case .pollVotes: + self.accessibilityLabel = strings.Conversation_ReadAllPollVotes + } let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:))) self.tapRecognizer = tapRecognizer @@ -159,6 +175,14 @@ class ChatHistoryNavigationButtonNode: ContextControllerSourceNode { } } } + + override func accessibilityActivate() -> Bool { + guard self.isEnabled, let tapped = self.tapped else { + return false + } + tapped() + return true + } private var currentValue: Int = 0 private func layoutBadge() { diff --git a/submodules/TelegramUI/Sources/ChatHistoryNavigationButtons.swift b/submodules/TelegramUI/Sources/ChatHistoryNavigationButtons.swift index de472c88f3e..62b9dd5fd4b 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryNavigationButtons.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryNavigationButtons.swift @@ -112,29 +112,29 @@ final class ChatHistoryNavigationButtons: ASDisplayNode { } } - init(theme: PresentationTheme, preferClearGlass: Bool, dateTimeFormat: PresentationDateTimeFormat, backgroundNode: WallpaperBackgroundNode, isChatRotated: Bool) { + init(theme: PresentationTheme, strings: PresentationStrings, preferClearGlass: Bool, dateTimeFormat: PresentationDateTimeFormat, backgroundNode: WallpaperBackgroundNode, isChatRotated: Bool) { self.isChatRotated = isChatRotated self.theme = theme self.preferClearGlass = preferClearGlass self.dateTimeFormat = dateTimeFormat - self.mentionsButton = ChatHistoryNavigationButtonNode(theme: theme, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: .mentions) + self.mentionsButton = ChatHistoryNavigationButtonNode(theme: theme, strings: strings, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: .mentions) self.mentionsButton.alpha = 0.0 self.mentionsButton.isHidden = true - self.reactionsButton = ChatHistoryNavigationButtonNode(theme: theme, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: .reactions) + self.reactionsButton = ChatHistoryNavigationButtonNode(theme: theme, strings: strings, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: .reactions) self.reactionsButton.alpha = 0.0 self.reactionsButton.isHidden = true - self.pollVotesButton = ChatHistoryNavigationButtonNode(theme: theme, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: .pollVotes) + self.pollVotesButton = ChatHistoryNavigationButtonNode(theme: theme, strings: strings, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: .pollVotes) self.pollVotesButton.alpha = 0.0 self.pollVotesButton.isHidden = true - self.downButton = ChatHistoryNavigationButtonNode(theme: theme, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: isChatRotated ? .down : .up) + self.downButton = ChatHistoryNavigationButtonNode(theme: theme, strings: strings, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: isChatRotated ? .down : .up) self.downButton.alpha = 0.0 self.downButton.isHidden = true - self.upButton = ChatHistoryNavigationButtonNode(theme: theme, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: isChatRotated ? .up : .down) + self.upButton = ChatHistoryNavigationButtonNode(theme: theme, strings: strings, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: isChatRotated ? .up : .down) self.upButton.alpha = 0.0 self.upButton.isHidden = true From 6a9b10a887ab91d544254d6def54c8db7879a73f Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:36:16 +0500 Subject: [PATCH 02/11] Expand VoiceOver message actions and sharing accessibility - add reply, reaction, copy, forward, delete, and options accessibility actions - announce delivery, read, and playback states separately - apply selected traits consistently across message renderers - simplify reply hints by excluding timestamps and delivery states - improve accessibility of peer info media and editing controls - expose recipients, inputs, search, and action controls in the share extension --- .../Sources/ShareActionButtonNode.swift | 13 +++++ .../Sources/ShareControllerNode.swift | 7 +++ .../Sources/ShareControllerPeerGridItem.swift | 33 +++++++++++ .../Sources/ShareInputFieldNode.swift | 7 +++ .../Sources/ShareSearchBarNode.swift | 9 ++- .../Sources/ShareSearchContainerNode.swift | 2 +- .../ChatMessageAnimatedStickerItemNode.swift | 17 ++---- .../Sources/ChatMessageBubbleItemNode.swift | 29 +++------- .../ChatMessageInstantVideoItemNode.swift | 17 ++---- .../Sources/ChatMessageItemView.swift | 57 ++++++++++++++++--- .../Sources/ChatMessageStickerItemNode.swift | 17 ++---- .../Sources/ChatControllerInteraction.swift | 9 +++ .../Sources/Panes/PeerInfoGifPaneNode.swift | 40 +++++++++++++ ...PeerInfoHeaderMultiLineTextFieldNode.swift | 5 +- ...eerInfoHeaderSingleLineTextFieldNode.swift | 4 +- .../TelegramUI/Sources/ChatController.swift | 6 +- 16 files changed, 198 insertions(+), 74 deletions(-) diff --git a/submodules/ShareController/Sources/ShareActionButtonNode.swift b/submodules/ShareController/Sources/ShareActionButtonNode.swift index 13cf0857c2b..2b1dca4bc76 100644 --- a/submodules/ShareController/Sources/ShareActionButtonNode.swift +++ b/submodules/ShareController/Sources/ShareActionButtonNode.swift @@ -28,6 +28,7 @@ public final class ShareActionButtonNode: HighlightTrackingButtonNode { public var badge: String? { didSet { if self.badge != oldValue { + self.accessibilityValue = self.badge if let badge = self.badge { self.badgeText = NSAttributedString(string: badge, font: Font.regular(14.0), textColor: self.badgeTextColor, paragraphAlignment: .center) self.badgeLabel.isHidden = false @@ -68,6 +69,9 @@ public final class ShareActionButtonNode: HighlightTrackingButtonNode { self.badgeBackground.image = generateStretchableFilledCircleImage(diameter: 22.0, color: badgeBackgroundColor) super.init() + + self.isAccessibilityElement = true + self.accessibilityTraits = .button self.containerNode.addSubnode(self.referenceNode) self.addSubnode(self.containerNode) @@ -147,6 +151,10 @@ public final class ShareStartAtTimestampNode: HighlightTrackingButtonNode { self.titleTextNode.displaysAsynchronously = false super.init() + + self.isAccessibilityElement = true + self.accessibilityLabel = titleText + self.accessibilityTraits = .button self.addSubnode(self.checkNode) self.addSubnode(self.titleTextNode) @@ -156,6 +164,11 @@ public final class ShareStartAtTimestampNode: HighlightTrackingButtonNode { @objc private func pressed() { self.checkNode.setSelected(!self.checkNode.selected, animated: true) + if self.checkNode.selected { + self.accessibilityTraits.insert(.selected) + } else { + self.accessibilityTraits.remove(.selected) + } self.updated?() } diff --git a/submodules/ShareController/Sources/ShareControllerNode.swift b/submodules/ShareController/Sources/ShareControllerNode.swift index 7566da53641..6cac8695294 100644 --- a/submodules/ShareController/Sources/ShareControllerNode.swift +++ b/submodules/ShareController/Sources/ShareControllerNode.swift @@ -706,6 +706,8 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate self.addSubnode(self.wrappingScrollNode) self.cancelButtonNode.setTitle(self.presentationData.strings.Common_Cancel, with: Font.medium(20.0), with: self.presentationData.theme.actionSheet.standardActionTextColor, for: .normal) + self.cancelButtonNode.accessibilityLabel = self.presentationData.strings.Common_Cancel + self.cancelButtonNode.accessibilityTraits = .button self.wrappingScrollNode.addSubnode(self.cancelButtonNode) self.cancelButtonNode.addTarget(self, action: #selector(self.cancelButtonPressed), forControlEvents: .touchUpInside) @@ -1840,19 +1842,23 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate if count == 0 { if self.presetText != nil { self.actionButtonNode.setTitle(self.presentationData.strings.ShareMenu_Send, with: Font.medium(20.0), with: self.presentationData.theme.actionSheet.disabledActionTextColor, for: .normal) + self.actionButtonNode.accessibilityLabel = self.presentationData.strings.ShareMenu_Send self.actionButtonNode.isEnabled = false self.actionButtonNode.badge = nil } else if let segmentedValues = self.segmentedValues { let value = segmentedValues[self.selectedSegmentedIndex] self.actionButtonNode.setTitle(value.actionTitle, with: Font.regular(20.0), with: self.presentationData.theme.actionSheet.standardActionTextColor, for: .normal) + self.actionButtonNode.accessibilityLabel = value.actionTitle self.actionButtonNode.isEnabled = true self.actionButtonNode.badge = nil } else if let defaultAction = self.defaultAction { self.actionButtonNode.setTitle(defaultAction.title, with: Font.regular(20.0), with: self.presentationData.theme.actionSheet.standardActionTextColor, for: .normal) + self.actionButtonNode.accessibilityLabel = defaultAction.title self.actionButtonNode.isEnabled = true self.actionButtonNode.badge = nil } else { self.actionButtonNode.setTitle(self.presentationData.strings.ShareMenu_Send, with: Font.medium(20.0), with: self.presentationData.theme.actionSheet.disabledActionTextColor, for: .normal) + self.actionButtonNode.accessibilityLabel = self.presentationData.strings.ShareMenu_Send self.actionButtonNode.isEnabled = false self.actionButtonNode.badge = nil } @@ -1866,6 +1872,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate } self.actionButtonNode.isEnabled = true self.actionButtonNode.setTitle(text, with: Font.medium(20.0), with: self.presentationData.theme.actionSheet.standardActionTextColor, for: .normal) + self.actionButtonNode.accessibilityLabel = text self.actionButtonNode.badge = "\(count)" } } diff --git a/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift b/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift index 1389f89037b..c4a1463ef47 100644 --- a/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift +++ b/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift @@ -77,6 +77,10 @@ final class ShareControllerGridSectionNode: ASDisplayNode { self.titleNode.truncationMode = .byTruncatingTail super.init() + + self.isAccessibilityElement = true + self.accessibilityTraits = .button + self.peerNode.accessibilityElementsHidden = true self.addSubnode(self.backgroundNode) self.addSubnode(self.titleNode) @@ -255,6 +259,7 @@ final class ShareControllerPeerGridItemNode: GridItemNode { online: isOnline, synchronousLoad: synchronousLoad ) + self.accessibilityLabel = threadData?.info.title ?? peer.compactDisplayTitle if let shimmerNode = self.placeholderNode { self.placeholderNode = nil shimmerNode.removeFromSupernode() @@ -277,7 +282,9 @@ final class ShareControllerPeerGridItemNode: GridItemNode { synchronousLoad: synchronousLoad, storyMode: storyMode ) + self.accessibilityLabel = strings.StoryFeed_ContextAddStory } else { + self.isAccessibilityElement = false let shimmerNode: ShimmerEffectNode if let current = self.placeholderNode { shimmerNode = current @@ -304,6 +311,9 @@ final class ShareControllerPeerGridItemNode: GridItemNode { shimmerNode.update(backgroundColor: theme.list.itemBlocksBackgroundColor, foregroundColor: theme.list.mediaPlaceholderColor, shimmeringColor: theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4), shapes: shapes, horizontal: true, size: self.bounds.size) } + if item != nil { + self.isAccessibilityElement = true + } self.currentState = (environment, context, theme, strings, item, search) self.setNeedsLayout() if let effectivePresence { @@ -322,6 +332,29 @@ final class ShareControllerPeerGridItemNode: GridItemNode { } self.peerNode.updateSelection(selected: selected, animated: animated) + self.accessibilityValue = selected ? self.currentState?.strings.VoiceOver_Chat_Selected : nil + if selected { + self.accessibilityTraits.insert(.selected) + } else { + self.accessibilityTraits.remove(.selected) + } + } + + 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 { + controllerInteraction.disabledPeerSelected(peer) + } else { + controllerInteraction.togglePeer(peer, self.currentState?.search ?? false) + } + case .story: + controllerInteraction.shareStory?() + } + return true } override func layout() { diff --git a/submodules/ShareController/Sources/ShareInputFieldNode.swift b/submodules/ShareController/Sources/ShareInputFieldNode.swift index 014c980eb98..cab97511271 100644 --- a/submodules/ShareController/Sources/ShareInputFieldNode.swift +++ b/submodules/ShareController/Sources/ShareInputFieldNode.swift @@ -217,6 +217,7 @@ public final class ShareInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegat self.textInputNode.attributedText = NSAttributedString(string: newValue, font: Font.regular(17.0), textColor: self.theme.textColor) self.placeholderNode.isHidden = !newValue.isEmpty || self.inputCopyText != nil self.clearButton.isHidden = newValue.isEmpty + self.clearButton.isAccessibilityElement = !newValue.isEmpty } } @@ -244,6 +245,7 @@ public final class ShareInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegat self.textInputNode.textContainerInset = UIEdgeInsets(top: self.inputInsets.top, left: 0.0, bottom: self.inputInsets.bottom, right: 0.0) self.textInputNode.keyboardAppearance = theme.keyboard.keyboardAppearance self.textInputNode.tintColor = theme.accentColor + self.textInputNode.textView.accessibilityHint = placeholder self.placeholderNode = ASTextNode() self.placeholderNode.isUserInteractionEnabled = false @@ -256,6 +258,9 @@ public final class ShareInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegat self.clearButton.displaysAsynchronously = false self.clearButton.setImage(generateClearIcon(color: theme.clearButtonColor), for: []) self.clearButton.isHidden = true + self.clearButton.isAccessibilityElement = false + self.clearButton.accessibilityLabel = strings.WebSearch_RecentSectionClear + self.clearButton.accessibilityTraits = .button super.init() @@ -352,6 +357,7 @@ public final class ShareInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegat public func editableTextNodeDidBeginEditing(_ editableTextNode: ASEditableTextNode) { self.clearButton.isHidden = false + self.clearButton.isAccessibilityElement = true if self.selectTextOnce { self.selectTextOnce = false @@ -364,6 +370,7 @@ public final class ShareInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegat public func editableTextNodeDidFinishEditing(_ editableTextNode: ASEditableTextNode) { self.placeholderNode.isHidden = !(editableTextNode.textView.text ?? "").isEmpty || self.inputCopyText != nil self.clearButton.isHidden = true + self.clearButton.isAccessibilityElement = false } private func calculateTextFieldMetrics(width: CGFloat) -> CGFloat { diff --git a/submodules/ShareController/Sources/ShareSearchBarNode.swift b/submodules/ShareController/Sources/ShareSearchBarNode.swift index a9f76440429..67b01a43563 100644 --- a/submodules/ShareController/Sources/ShareSearchBarNode.swift +++ b/submodules/ShareController/Sources/ShareSearchBarNode.swift @@ -19,7 +19,7 @@ final class ShareSearchBarNode: ASDisplayNode, UITextFieldDelegate { var textUpdated: ((String) -> Void)? - init(theme: PresentationTheme, placeholder: String) { + init(theme: PresentationTheme, strings: PresentationStrings, placeholder: String) { self.backgroundNode = ASImageNode() self.backgroundNode.isLayerBacked = true self.backgroundNode.displaysAsynchronously = false @@ -38,6 +38,9 @@ final class ShareSearchBarNode: ASDisplayNode, UITextFieldDelegate { self.clearButton.displaysAsynchronously = false self.clearButton.setImage(generateClearIcon(color: theme.actionSheet.inputClearButtonColor), for: []) self.clearButton.isHidden = true + self.clearButton.isAccessibilityElement = false + self.clearButton.accessibilityLabel = strings.WebSearch_RecentSectionClear + self.clearButton.accessibilityTraits = .button self.textInputNode = TextFieldNode() self.textInputNode.fixOffset = false @@ -108,7 +111,9 @@ final class ShareSearchBarNode: ASDisplayNode, UITextFieldDelegate { } @objc func textFieldDidChangeText() { - self.clearButton.isHidden = self.textInputNode.textField.text?.isEmpty ?? true + let isEmpty = self.textInputNode.textField.text?.isEmpty ?? true + self.clearButton.isHidden = isEmpty + self.clearButton.isAccessibilityElement = !isEmpty self.textUpdated?(self.textInputNode.textField.text ?? "") } diff --git a/submodules/ShareController/Sources/ShareSearchContainerNode.swift b/submodules/ShareController/Sources/ShareSearchContainerNode.swift index faef506e643..a53d67687ac 100644 --- a/submodules/ShareController/Sources/ShareSearchContainerNode.swift +++ b/submodules/ShareController/Sources/ShareSearchContainerNode.swift @@ -241,7 +241,7 @@ final class ShareSearchContainerNode: ASDisplayNode, ShareContentContainerNode { self.contentGridNode = GridNode() self.contentGridNode.isHidden = true - self.searchNode = ShareSearchBarNode(theme: theme, placeholder: strings.Common_Search) + self.searchNode = ShareSearchBarNode(theme: theme, strings: strings, placeholder: strings.Common_Search) self.cancelButtonNode = HighlightableButtonNode() self.cancelButtonNode.setTitle(strings.Common_Cancel, with: cancelFont, with: theme.actionSheet.controlAccentColor, for: []) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift index da2567dc2f3..bb8d13d9835 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift @@ -804,19 +804,8 @@ public class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:))) } - @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { - if let action = action as? ChatMessageAccessibilityCustomAction { - switch action.action { - case .reply: - if let item = self.item { - item.controllerInteraction.setupReply(item.message.id) - } - case .options: - if let item = self.item { - item.controllerInteraction.openMessageContextMenu(item.message, false, self, self.imageNode.frame, nil, nil) - } - } - } + @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) -> Bool { + return self.performAccessibilityCustomAction(action, sourceNode: self, sourceRect: self.imageNode.frame) } override public func asyncLayout() -> (_ item: ChatMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: ChatMessageMerge, _ mergedBottom: ChatMessageMerge, _ dateHeaderAtBottom: ChatMessageHeaderSpec) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation, ListViewItemApply, Bool) -> Void) { @@ -2798,6 +2787,8 @@ public class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { guard let item = self.item else { return } + let isSelected = item.controllerInteraction.selectionState.map { $0.selectedIds.contains(item.message.id) } + self.updateAccessibilityData(ChatMessageAccessibilityData(item: item, isSelected: isSelected)) if case let .replyThread(replyThreadMessage) = item.chatLocation, replyThreadMessage.effectiveTopId == item.message.id { return diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 2413f1bc580..a60e8d488ce 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -5684,28 +5684,17 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:))) } - @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { - if let action = action as? ChatMessageAccessibilityCustomAction { - switch action.action { - case .reply: - if let item = self.item { - item.controllerInteraction.setupReply(item.message.id) - } - case .options: - if let item = self.item { - var subFrame = self.backgroundNode.frame - if case .group = item.content { - for contentNode in self.contentNodes { - if contentNode.item?.message.stableId == item.message.stableId { - subFrame = contentNode.frame.insetBy(dx: 0.0, dy: -4.0) - break - } - } - } - item.controllerInteraction.openMessageContextMenu(item.message, false, self, subFrame, nil, nil) - } + @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) -> Bool { + var subFrame = self.backgroundNode.frame + if let item = self.item, case .group = item.content { + for contentNode in self.contentNodes { + if contentNode.item?.message.stableId == item.message.stableId { + subFrame = contentNode.frame.insetBy(dx: 0.0, dy: -4.0) + break + } } } + return self.performAccessibilityCustomAction(action, sourceNode: self, sourceRect: subFrame) } override public func shouldAnimateHorizontalFrameTransition() -> Bool { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift index 6daacd49eb1..1e3104b5476 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift @@ -249,19 +249,8 @@ public class ChatMessageInstantVideoItemNode: ChatMessageItemView, ASGestureReco super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:))) } - @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { - if let action = action as? ChatMessageAccessibilityCustomAction { - switch action.action { - case .reply: - if let item = self.item { - item.controllerInteraction.setupReply(item.message.id) - } - case .options: - if let item = self.item { - item.controllerInteraction.openMessageContextMenu(item.message, false, self, self.interactiveVideoNode.frame, nil, nil) - } - } - } + @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) -> Bool { + return self.performAccessibilityCustomAction(action, sourceNode: self, sourceRect: self.interactiveVideoNode.frame) } override public func asyncLayout() -> (_ item: ChatMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: ChatMessageMerge, _ mergedBottom: ChatMessageMerge, _ dateHeaderAtBottom: ChatMessageHeaderSpec) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation, ListViewItemApply, Bool) -> Void) { @@ -1193,6 +1182,8 @@ public class ChatMessageInstantVideoItemNode: ChatMessageItemView, ASGestureReco guard let item = self.item else { return } + let isSelected = item.controllerInteraction.selectionState.map { $0.selectedIds.contains(item.message.id) } + self.updateAccessibilityData(ChatMessageAccessibilityData(item: item, isSelected: isSelected)) if case let .replyThread(replyThreadMessage) = item.chatLocation, replyThreadMessage.effectiveTopId == item.message.id { return diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift index 42b5743520d..9dc4d087af7 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift @@ -66,7 +66,11 @@ private let fileSizeFormatter: ByteCountFormatter = { public enum ChatMessageAccessibilityCustomActionType { case reply + case react case options + case copy + case forward + case delete } public final class ChatMessageAccessibilityCustomAction: UIAccessibilityCustomAction { @@ -445,15 +449,16 @@ public final class ChatMessageAccessibilityData { if isSelected { result += item.presentationData.strings.VoiceOver_Chat_Selected result += "\n" + traits.insert(.selected) } - traits.insert(.startsMediaSession) } result += "\(text)" - let dateString = DateFormatter.localizedString(from: Date(timeIntervalSince1970: Double(message.timestamp)), dateStyle: .medium, timeStyle: .short) - - result += "\n\(dateString)" + if !isReply { + let dateString = DateFormatter.localizedString(from: Date(timeIntervalSince1970: Double(message.timestamp)), dateStyle: .medium, timeStyle: .short) + result += "\n\(dateString)" + } if !isIncoming && !isReply { result += "\n" if item.sending { @@ -462,14 +467,13 @@ public final class ChatMessageAccessibilityData { result += item.presentationData.strings.VoiceOver_Chat_Failed } else { if item.read { - if announceIncomingAuthors { - result += item.presentationData.strings.VoiceOver_Chat_SeenByRecipients - } else { - result += item.presentationData.strings.VoiceOver_Chat_SeenByRecipient - } + result += item.presentationData.strings.Conversation_ChecksTooltip_Read + } else { + result += item.presentationData.strings.Conversation_ChecksTooltip_Delivered } for attribute in message.attributes { if let attribute = attribute as? ConsumableContentMessageAttribute { + result += "\n" if !attribute.consumed { if announceIncomingAuthors { result += item.presentationData.strings.VoiceOver_Chat_NotPlayedByRecipients @@ -600,6 +604,16 @@ public final class ChatMessageAccessibilityData { if canReply { customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.VoiceOver_MessageContextReply, target: nil, selector: #selector(self.noop), action: .reply)) } + if canAddMessageReactions(message: EngineMessage(item.message)) { + customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.MediaEditor_Shortcut_Reaction, target: nil, selector: #selector(self.noop), action: .react)) + } + if !item.message.text.isEmpty { + customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.Conversation_ContextMenuCopy, target: nil, selector: #selector(self.noop), action: .copy)) + } + if item.controllerInteraction.canPerformAccessibilityMessageActions { + customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.VoiceOver_MessageContextForward, target: nil, selector: #selector(self.noop), action: .forward)) + customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.VoiceOver_MessageContextDelete, target: nil, selector: #selector(self.noop), action: .delete)) + } customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.VoiceOver_MessageContextOpenMessageMenu, target: nil, selector: #selector(self.noop), action: .options)) } @@ -726,6 +740,31 @@ open class ChatMessageItemView: ListViewItemNode, ChatMessageItemNodeProtocol { public func restoreAccessibilityFocus() { UIAccessibility.post(notification: .layoutChanged, argument: self.messageAccessibilityNode?.view ?? self.view) } + + public func performAccessibilityCustomAction(_ customAction: UIAccessibilityCustomAction, sourceNode: ASDisplayNode, sourceRect: CGRect) -> Bool { + guard let action = customAction as? ChatMessageAccessibilityCustomAction, let item = self.item else { + return false + } + + switch action.action { + case .reply: + item.controllerInteraction.setupReply(item.message.id) + case .react: + item.controllerInteraction.updateMessageReaction(item.message, .default, false, nil) + case .options: + item.controllerInteraction.openMessageContextMenu(item.message, false, sourceNode, sourceRect, nil, nil) + case .copy: + guard !item.message.text.isEmpty else { + return false + } + item.controllerInteraction.copyText(item.message.text) + case .forward: + item.controllerInteraction.accessibilityForwardMessage(item.message) + case .delete: + item.controllerInteraction.accessibilityDeleteMessage(item.message) + } + return true + } override open func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { if let item = item as? ChatMessageItem { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift index 30a4ac9a9db..cd67ee899f8 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift @@ -392,19 +392,8 @@ public class ChatMessageStickerItemNode: ChatMessageItemView { super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:))) } - @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { - if let action = action as? ChatMessageAccessibilityCustomAction { - switch action.action { - case .reply: - if let item = self.item { - item.controllerInteraction.setupReply(item.message.id) - } - case .options: - if let item = self.item { - item.controllerInteraction.openMessageContextMenu(item.message, false, self, self.imageNode.frame, nil, nil) - } - } - } + @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) -> Bool { + return self.performAccessibilityCustomAction(action, sourceNode: self, sourceRect: self.imageNode.frame) } override public func asyncLayout() -> (_ item: ChatMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: ChatMessageMerge, _ mergedBottom: ChatMessageMerge, _ dateHeaderAtBottom: ChatMessageHeaderSpec) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation, ListViewItemApply, Bool) -> Void) { @@ -1787,6 +1776,8 @@ public class ChatMessageStickerItemNode: ChatMessageItemView { guard let item = self.item else { return } + let isSelected = item.controllerInteraction.selectionState.map { $0.selectedIds.contains(item.message.id) } + self.updateAccessibilityData(ChatMessageAccessibilityData(item: item, isSelected: isSelected)) if case let .replyThread(replyThreadMessage) = item.chatLocation, replyThreadMessage.effectiveTopId == item.message.id { return diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index 0546d4be9b9..9a11653344f 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -278,6 +278,9 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol public let openMessageStats: (EngineMessage.Id) -> Void public let editMessageMedia: (EngineMessage.Id, Bool) -> Void public let copyText: (String) -> Void + public let accessibilityForwardMessage: (EngineRawMessage) -> Void + public let accessibilityDeleteMessage: (EngineRawMessage) -> Void + public let canPerformAccessibilityMessageActions: Bool public let displayUndo: (UndoOverlayContent) -> Void public let isAnimatingMessage: (UInt32) -> Bool public let getMessageTransitionNode: () -> ChatMessageTransitionProtocol? @@ -504,6 +507,9 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol displayTodoToggleUnavailable: @escaping (EngineMessage.Id) -> Void, canEditMessageRichText: @escaping (EngineRawMessage) -> Bool = { _ in false }, toggleMessageRichTextCheckbox: @escaping (EngineMessage.Id, [Int], Bool) -> Void = { _, _, _ in }, + accessibilityForwardMessage: @escaping (EngineRawMessage) -> Void = { _ in }, + accessibilityDeleteMessage: @escaping (EngineRawMessage) -> Void = { _ in }, + canPerformAccessibilityMessageActions: Bool = false, openStarsPurchase: @escaping (Int64?) -> Void, openRankInfo: @escaping (EnginePeer, ChatRankInfoScreenRole, String) -> Void, openSetPeerAvatar: @escaping () -> Void, @@ -597,6 +603,9 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol self.openMessageStats = openMessageStats self.editMessageMedia = editMessageMedia self.copyText = copyText + self.accessibilityForwardMessage = accessibilityForwardMessage + self.accessibilityDeleteMessage = accessibilityDeleteMessage + self.canPerformAccessibilityMessageActions = canPerformAccessibilityMessageActions self.displayUndo = displayUndo self.isAnimatingMessage = isAnimatingMessage self.getMessageTransitionNode = getMessageTransitionNode diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift index 5365b2f8942..a5a2d832b0f 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift @@ -80,6 +80,9 @@ private final class VisualMediaItemNode: ASDisplayNode { self.mediaBadgeNode.frame = CGRect(origin: CGPoint(x: 6.0, y: 6.0), size: CGSize(width: 50.0, height: 50.0)) super.init() + + self.isAccessibilityElement = true + self.accessibilityTraits = [.button, .image] self.addSubnode(self.containerNode) self.containerNode.addSubnode(self.imageNode) @@ -299,6 +302,17 @@ private final class VisualMediaItemNode: ASDisplayNode { self.mediaBadgeNode.isHidden = true } self.item = (item, media, size, mediaDimensions) + + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + if media is TelegramMediaImage { + self.accessibilityLabel = presentationData.strings.VoiceOver_Chat_Photo + } else { + self.accessibilityLabel = presentationData.strings.VoiceOver_Chat_Video + } + self.accessibilityValue = item.message.text.isEmpty ? nil : item.message.text + self.accessibilityCustomActions = [ + UIAccessibilityCustomAction(name: presentationData.strings.VoiceOver_MessageContextOpenMessageMenu, target: self, selector: #selector(self.accessibilityOpenContextMenu(_:))) + ] self.updateHiddenMedia() } @@ -343,6 +357,11 @@ private final class VisualMediaItemNode: ASDisplayNode { if let selectedIds = self.interaction.selectedMessageIds { let selected = selectedIds.contains(item.message.id) + if selected { + self.accessibilityTraits.insert(.selected) + } else { + self.accessibilityTraits.remove(.selected) + } if let selectionNode = self.selectionNode { selectionNode.updateSelected(selected, animated: animated) @@ -367,6 +386,7 @@ private final class VisualMediaItemNode: ASDisplayNode { } } } else { + self.accessibilityTraits.remove(.selected) if let selectionNode = self.selectionNode { self.selectionNode = nil if animated { @@ -380,6 +400,26 @@ private final class VisualMediaItemNode: ASDisplayNode { } } } + + override func accessibilityActivate() -> Bool { + guard let item = self.item?.0 else { + return false + } + if let selectedMessageIds = self.interaction.selectedMessageIds { + self.interaction.toggleSelection(item.message.id, !selectedMessageIds.contains(item.message.id)) + } else { + self.interaction.openMessage(item.message) + } + return true + } + + @objc private func accessibilityOpenContextMenu(_ action: UIAccessibilityCustomAction) -> Bool { + guard let item = self.item?.0 else { + return false + } + self.interaction.openMessageContextActions(item.message, self.containerNode, self.containerNode.bounds, nil) + return true + } func transitionNode() -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { let imageNode = self.imageNode diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderMultiLineTextFieldNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderMultiLineTextFieldNode.swift index 9d253d3f71c..6834ba5c5e2 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderMultiLineTextFieldNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderMultiLineTextFieldNode.swift @@ -53,6 +53,7 @@ final class PeerInfoHeaderMultiLineTextFieldNode: ASDisplayNode, PeerInfoHeaderT self.clearButtonNode = HighlightableButtonNode() self.clearButtonNode.isHidden = true self.clearButtonNode.isAccessibilityElement = false + self.clearButtonNode.accessibilityTraits = .button self.maskNode = ASImageNode() self.maskNode.isUserInteractionEnabled = false @@ -130,7 +131,9 @@ final class PeerInfoHeaderMultiLineTextFieldNode: ASDisplayNode, PeerInfoHeaderT let attributedPlaceholderText = NSAttributedString(string: placeholder, font: titleFont, textColor: presentationData.theme.list.itemPlaceholderTextColor) if self.textNode.attributedPlaceholderText == nil || !self.textNode.attributedPlaceholderText!.isEqual(to: attributedPlaceholderText) { self.textNode.attributedPlaceholderText = attributedPlaceholderText + self.textNode.textView.accessibilityHint = attributedPlaceholderText.string } + self.clearButtonNode.accessibilityLabel = presentationData.strings.WebSearch_RecentSectionClear if let updateText = updateText { let attributedText = NSAttributedString(string: updateText, font: titleFont, textColor: presentationData.theme.list.itemPrimaryTextColor) @@ -184,7 +187,7 @@ final class PeerInfoHeaderMultiLineTextFieldNode: ASDisplayNode, PeerInfoHeaderT let isHidden = !self.textNode.isFirstResponder() || self.text.isEmpty self.clearIconNode.isHidden = isHidden self.clearButtonNode.isHidden = isHidden - self.clearButtonNode.isAccessibilityElement = isHidden + self.clearButtonNode.isAccessibilityElement = !isHidden } func editableTextNode(_ editableTextNode: ASEditableTextNode, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderSingleLineTextFieldNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderSingleLineTextFieldNode.swift index 762bb6b22a7..7c1bbb7af30 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderSingleLineTextFieldNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderSingleLineTextFieldNode.swift @@ -36,6 +36,7 @@ final class PeerInfoHeaderSingleLineTextFieldNode: ASDisplayNode, PeerInfoHeader self.clearButtonNode = HighlightableButtonNode() self.clearButtonNode.isHidden = true self.clearButtonNode.isAccessibilityElement = false + self.clearButtonNode.accessibilityTraits = .button self.topSeparator = ASDisplayNode() @@ -84,12 +85,13 @@ final class PeerInfoHeaderSingleLineTextFieldNode: ASDisplayNode, PeerInfoHeader let isHidden = !self.textNode.textField.isFirstResponder || self.text.isEmpty self.clearIconNode.isHidden = isHidden self.clearButtonNode.isHidden = isHidden - self.clearButtonNode.isAccessibilityElement = isHidden + self.clearButtonNode.isAccessibilityElement = !isHidden } func update(width: CGFloat, safeInset: CGFloat, isSettings: Bool, hasPrevious: Bool, hasNext: Bool, placeholder: String, isEnabled: Bool, presentationData: PresentationData, updateText: String?) -> CGFloat { let titleFont = Font.regular(presentationData.listsFontSize.itemListBaseFontSize) self.textNode.textField.font = titleFont + self.clearButtonNode.accessibilityLabel = presentationData.strings.WebSearch_RecentSectionClear if self.theme !== presentationData.theme { self.theme = presentationData.theme diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 23695c21df0..f40282c31c1 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -5820,7 +5820,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }, queue: .mainQueue()) self.richTextCheckboxDebounceTimers[messageId] = timer timer.start() - }, openStarsPurchase: { [weak self] amount in + }, accessibilityForwardMessage: { [weak self] message in + self?.interfaceInteraction?.forwardMessages([message]) + }, accessibilityDeleteMessage: { [weak self] message in + self?.interfaceInteraction?.deleteMessages([message], nil, { _ in }) + }, canPerformAccessibilityMessageActions: true, openStarsPurchase: { [weak self] amount in self?.interfaceInteraction?.openStarsPurchase(amount) }, openRankInfo: { [weak self] peer, role, rank in guard let self, let chatPeer = self.presentationInterfaceState.renderedPeer?.peer else { From 452baa37305e4e6397f81b4789e1bdf42fb42d34 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:04:28 +0500 Subject: [PATCH 03/11] Improve VoiceOver accessibility for modal interfaces Add modal focus containment and restoration for alerts and action sheets. Improve Archive Info semantics, Dynamic Type, Reduce Motion and Reduce Transparency support, initial alert focus, and VoiceOver escape handling. --- .../Source/ActionSheetController.swift | 44 +++++++++++++- .../Source/ActionSheetControllerNode.swift | 19 ++++++ .../Display/Source/AlertContentNode.swift | 4 ++ .../Display/Source/AlertController.swift | 39 +++++++++++- .../Display/Source/AlertControllerNode.swift | 21 +++++-- .../Display/Source/TextAlertController.swift | 11 +++- .../Sources/ArchiveInfoContentComponent.swift | 59 +++++++++++++++---- .../Sources/ArchiveInfoScreen.swift | 42 ++++++++++++- 8 files changed, 218 insertions(+), 21 deletions(-) diff --git a/submodules/Display/Source/ActionSheetController.swift b/submodules/Display/Source/ActionSheetController.swift index 8727b6e887b..9b808d284ea 100644 --- a/submodules/Display/Source/ActionSheetController.swift +++ b/submodules/Display/Source/ActionSheetController.swift @@ -22,6 +22,7 @@ open class ActionSheetController: ViewController, PresentableController, Standal private var groups: [ActionSheetItemGroup] = [] private var isDismissed: Bool = false + private weak var previousAccessibilityFocus: AnyObject? public var dismissed: ((Bool) -> Void)? @@ -48,13 +49,27 @@ open class ActionSheetController: ViewController, PresentableController, Standal } } + open override func accessibilityPerformEscape() -> Bool { + if self.isDismissed { + return false + } + self.isDismissed = true + self.actionSheetNode.animateOut(cancelled: true) + return true + } + open override func loadDisplayNode() { self.displayNode = ActionSheetControllerNode(theme: self.theme, allowInputInset: self.allowInputInset) self.displayNodeDidLoad() self.actionSheetNode.dismiss = { [weak self] cancelled in - self?.dismissed?(cancelled) - self?.presentingViewController?.dismiss(animated: false) + guard let self else { + return + } + self.dismissed?(cancelled) + self.presentingViewController?.dismiss(animated: false, completion: { [weak self] in + self?.restoreAccessibilityFocus() + }) } self.actionSheetNode.setGroups(self.groups) @@ -72,8 +87,31 @@ open class ActionSheetController: ViewController, PresentableController, Standal self.viewDidAppear(completion: {}) } + open override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if self.previousAccessibilityFocus == nil { + self.previousAccessibilityFocus = UIAccessibility.focusedElement(using: .notificationVoiceOver) as AnyObject? + } + } + public func viewDidAppear(completion: @escaping () -> Void) { - self.actionSheetNode.animateIn(completion: completion) + self.actionSheetNode.animateIn { [weak self] in + completion() + + guard let self else { + return + } + UIAccessibility.post(notification: .screenChanged, argument: self.actionSheetNode.view) + } + } + + private func restoreAccessibilityFocus() { + guard let previousAccessibilityFocus = self.previousAccessibilityFocus else { + return + } + self.previousAccessibilityFocus = nil + UIAccessibility.post(notification: .layoutChanged, argument: previousAccessibilityFocus) } public func setItemGroups(_ groups: [ActionSheetItemGroup]) { diff --git a/submodules/Display/Source/ActionSheetControllerNode.swift b/submodules/Display/Source/ActionSheetControllerNode.swift index fc4d61c7528..f50a3a0e206 100644 --- a/submodules/Display/Source/ActionSheetControllerNode.swift +++ b/submodules/Display/Source/ActionSheetControllerNode.swift @@ -63,6 +63,14 @@ final class ActionSheetControllerNode: ASDisplayNode, ASScrollViewDelegate { self.itemGroupsContainerNode.isUserInteractionEnabled = false super.init() + + self.view.accessibilityViewIsModal = true + self.dismissTapView.isAccessibilityElement = false + self.dismissTapView.accessibilityElementsHidden = true + self.leftDimView.accessibilityElementsHidden = true + self.rightDimView.accessibilityElementsHidden = true + self.topDimView.accessibilityElementsHidden = true + self.bottomDimView.accessibilityElementsHidden = true self.scrollView.delegate = self.wrappedScrollViewDelegate @@ -150,6 +158,12 @@ final class ActionSheetControllerNode: ASDisplayNode, ASScrollViewDelegate { func animateIn(completion: @escaping () -> Void) { + if UIAccessibility.isReduceMotionEnabled { + self.itemGroupsContainerNode.isUserInteractionEnabled = true + completion() + return + } + let tempDimView = UIView() tempDimView.backgroundColor = self.theme.dimColor tempDimView.frame = self.bounds.offsetBy(dx: 0.0, dy: -self.bounds.size.height) @@ -172,6 +186,11 @@ final class ActionSheetControllerNode: ASDisplayNode, ASScrollViewDelegate { } func animateOut(cancelled: Bool) { + if UIAccessibility.isReduceMotionEnabled { + self.dismiss(cancelled) + return + } + let tempDimView = UIView() tempDimView.backgroundColor = self.theme.dimColor tempDimView.frame = self.bounds.offsetBy(dx: 0.0, dy: -self.bounds.size.height) diff --git a/submodules/Display/Source/AlertContentNode.swift b/submodules/Display/Source/AlertContentNode.swift index b95b6e38ad1..ce205a3eae0 100644 --- a/submodules/Display/Source/AlertContentNode.swift +++ b/submodules/Display/Source/AlertContentNode.swift @@ -4,6 +4,10 @@ import AsyncDisplayKit open class AlertContentNode: ASDisplayNode { open var requestLayout: ((ContainedViewLayoutTransition) -> Void)? + + open var accessibilityInitialFocusNode: ASDisplayNode? { + return nil + } open var dismissOnOutsideTap: Bool { return true diff --git a/submodules/Display/Source/AlertController.swift b/submodules/Display/Source/AlertController.swift index 19e64a13fd0..0c59f97bb96 100644 --- a/submodules/Display/Source/AlertController.swift +++ b/submodules/Display/Source/AlertController.swift @@ -87,6 +87,7 @@ open class AlertController: ViewController, StandalonePresentableController, Key private let allowInputInset: Bool private weak var existingAlertController: AlertController? + private weak var previousAccessibilityFocus: AnyObject? public var willDismiss: (() -> Void)? public var dismissed: ((Bool) -> Void)? @@ -130,10 +131,22 @@ open class AlertController: ViewController, StandalonePresentableController, Key override open func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + self.existingAlertController?.previousAccessibilityFocus = nil self.existingAlertController?.dismiss(completion: nil) self.existingAlertController = nil self.controllerNode.animateIn() + UIAccessibility.post(notification: .screenChanged, argument: self.contentNode.accessibilityInitialFocusNode?.view ?? self.contentNode.view) + } + + override open func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if let existingAlertController = self.existingAlertController { + self.previousAccessibilityFocus = existingAlertController.previousAccessibilityFocus + } else if self.previousAccessibilityFocus == nil { + self.previousAccessibilityFocus = UIAccessibility.focusedElement(using: .notificationVoiceOver) as AnyObject? + } } override open func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { @@ -147,7 +160,10 @@ open class AlertController: ViewController, StandalonePresentableController, Key self.isDismissed = true self.dismissed?(false) } - self.presentingViewController?.dismiss(animated: false, completion: completion) + self.presentingViewController?.dismiss(animated: false, completion: { [weak self] in + self?.restoreAccessibilityFocus() + completion?() + }) } open func dismissAnimated() { @@ -156,6 +172,27 @@ open class AlertController: ViewController, StandalonePresentableController, Key } } + override open func accessibilityPerformEscape() -> Bool { + guard self.contentNode.dismissOnOutsideTap, !self.isDismissed else { + return false + } + self.willDismiss?() + self.controllerNode.animateOut { [weak self] in + self?.dismissed?(true) + self?.isDismissed = true + self?.dismiss() + } + return true + } + + private func restoreAccessibilityFocus() { + guard let previousAccessibilityFocus = self.previousAccessibilityFocus else { + return + } + self.previousAccessibilityFocus = nil + UIAccessibility.post(notification: .layoutChanged, argument: previousAccessibilityFocus) + } + public var keyShortcuts: [KeyShortcut] { return [ KeyShortcut( diff --git a/submodules/Display/Source/AlertControllerNode.swift b/submodules/Display/Source/AlertControllerNode.swift index 8f4970eaef1..3dfce2befd9 100644 --- a/submodules/Display/Source/AlertControllerNode.swift +++ b/submodules/Display/Source/AlertControllerNode.swift @@ -50,18 +50,22 @@ final class AlertControllerNode: ASDisplayNode { self.containerNode.layer.masksToBounds = true self.backgroundNode = ASDisplayNode() - self.backgroundNode.backgroundColor = theme.backgroundColor + self.backgroundNode.backgroundColor = UIAccessibility.isReduceTransparencyEnabled ? theme.backgroundColor.withAlphaComponent(1.0) : theme.backgroundColor // self.effectNode = ASDisplayNode(viewBlock: { // return UIVisualEffectView(effect: UIBlurEffect(style: theme.backgroundType == .light ? .light : .dark)) // }) - self.effectView = UIVisualEffectView(effect: UIBlurEffect(style: theme.backgroundType == .light ? .light : .dark)) + self.effectView = UIVisualEffectView(effect: UIAccessibility.isReduceTransparencyEnabled ? nil : UIBlurEffect(style: theme.backgroundType == .light ? .light : .dark)) self.contentNode = contentNode super.init() + self.view.accessibilityViewIsModal = true + self.dimContainerView.isAccessibilityElement = false + self.dimContainerView.accessibilityElementsHidden = true + self.view.addSubview(self.dimContainerView) self.dimContainerView.addSubview(self.centerDimView) self.dimContainerView.addSubview(self.topDimView) @@ -104,12 +108,16 @@ final class AlertControllerNode: ASDisplayNode { } func updateTheme(_ theme: AlertControllerTheme) { - self.effectView.effect = UIBlurEffect(style: theme.backgroundType == .light ? .light : .dark) - self.backgroundNode.backgroundColor = theme.backgroundColor + self.effectView.effect = UIAccessibility.isReduceTransparencyEnabled ? nil : UIBlurEffect(style: theme.backgroundType == .light ? .light : .dark) + self.backgroundNode.backgroundColor = UIAccessibility.isReduceTransparencyEnabled ? theme.backgroundColor.withAlphaComponent(1.0) : theme.backgroundColor self.contentNode.updateTheme(theme) } func animateIn() { + if UIAccessibility.isReduceMotionEnabled { + return + } + if let previousNode = self.existingAlertControllerNode { let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .spring) @@ -146,6 +154,11 @@ final class AlertControllerNode: ASDisplayNode { } func animateOut(completion: @escaping () -> Void) { + if UIAccessibility.isReduceMotionEnabled { + completion() + return + } + self.containerNode.layer.removeAllAnimations() //self.centerDimView.backgroundColor = UIColor(white: 0.0, alpha: 0.5) //self.centerDimView.image = nil diff --git a/submodules/Display/Source/TextAlertController.swift b/submodules/Display/Source/TextAlertController.swift index 2224e1a4d0c..b19cfdd73de 100644 --- a/submodules/Display/Source/TextAlertController.swift +++ b/submodules/Display/Source/TextAlertController.swift @@ -134,7 +134,11 @@ public final class TextAlertContentActionNode: HighlightableButtonNode { self.setAttributedTitle(attributedString, for: []) self.accessibilityLabel = self.action.title - self.accessibilityTraits = [.button] + var accessibilityTraits: UIAccessibilityTraits = [.button] + if !self.actionEnabled { + accessibilityTraits.insert(.notEnabled) + } + self.accessibilityTraits = accessibilityTraits } @objc func pressed() { @@ -171,6 +175,10 @@ public final class TextAlertContentNode: AlertContentNode { return self._dismissOnOutsideTap } + override public var accessibilityInitialFocusNode: ASDisplayNode? { + return self.titleNode ?? self.textNode + } + private var highlightedItemIndex: Int? = nil public var textAttributeAction: (NSAttributedString.Key, (Any) -> Void)? { @@ -209,6 +217,7 @@ public final class TextAlertContentNode: AlertContentNode { titleNode.truncationType = .end titleNode.isAccessibilityElement = true titleNode.accessibilityLabel = title.string + titleNode.accessibilityTraits = [.header] self.titleNode = titleNode } else { self.titleNode = nil diff --git a/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoContentComponent.swift b/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoContentComponent.swift index fb4c0c5aa38..fcfda7714b5 100644 --- a/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoContentComponent.swift +++ b/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoContentComponent.swift @@ -56,6 +56,7 @@ public final class ArchiveInfoContentComponent: Component { private let title = ComponentView() private let mainText = ComponentView() + private let settingsButton: UIButton private var chevronImage: UIImage? @@ -68,6 +69,7 @@ public final class ArchiveInfoContentComponent: Component { self.iconBackground = UIImageView() self.iconForeground = UIImageView() + self.settingsButton = UIButton(type: .custom) super.init(frame: frame) @@ -86,6 +88,15 @@ public final class ArchiveInfoContentComponent: Component { self.scrollView.addSubview(self.iconBackground) self.scrollView.addSubview(self.iconForeground) + self.scrollView.addSubview(self.settingsButton) + + self.iconBackground.isAccessibilityElement = false + self.iconBackground.accessibilityElementsHidden = true + self.iconForeground.isAccessibilityElement = false + self.iconForeground.accessibilityElementsHidden = true + + self.settingsButton.accessibilityTraits = [.button, .link] + self.settingsButton.addTarget(self, action: #selector(self.openSettingsPressed), for: .touchUpInside) } required init(coder: NSCoder) { @@ -105,6 +116,8 @@ public final class ArchiveInfoContentComponent: Component { let sideInset: CGFloat = 16.0 let sideIconInset: CGFloat = 40.0 + let titleFontSize = UIFontMetrics(forTextStyle: .headline).scaledValue(for: 19.0) + let bodyFontSize = UIFontMetrics(forTextStyle: .body).scaledValue(for: 15.0) var contentHeight: CGFloat = 0.0 @@ -128,7 +141,7 @@ public final class ArchiveInfoContentComponent: Component { contentHeight += 15.0 let titleString = NSMutableAttributedString() - titleString.append(NSAttributedString(string: component.strings.ArchiveInfo_Title, font: Font.semibold(19.0), textColor: component.theme.list.itemPrimaryTextColor)) + titleString.append(NSAttributedString(string: component.strings.ArchiveInfo_Title, font: Font.semibold(titleFontSize), textColor: component.theme.list.itemPrimaryTextColor)) let imageAttachment = NSTextAttachment() imageAttachment.image = self.iconBackground.image titleString.append(NSAttributedString(attachment: imageAttachment)) @@ -137,7 +150,8 @@ public final class ArchiveInfoContentComponent: Component { transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(titleString), - maximumNumberOfLines: 1 + horizontalAlignment: .center, + maximumNumberOfLines: 0 )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) @@ -146,6 +160,9 @@ public final class ArchiveInfoContentComponent: Component { if titleView.superview == nil { self.scrollView.addSubview(titleView) } + titleView.isAccessibilityElement = true + titleView.accessibilityLabel = component.strings.ArchiveInfo_Title + titleView.accessibilityTraits.insert(.header) transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: contentHeight), size: titleSize)) } contentHeight += titleSize.height @@ -161,15 +178,15 @@ public final class ArchiveInfoContentComponent: Component { let mainText = NSMutableAttributedString() mainText.append(parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes( body: MarkdownAttributeSet( - font: Font.regular(15.0), + font: Font.regular(bodyFontSize), textColor: component.theme.list.itemSecondaryTextColor ), bold: MarkdownAttributeSet( - font: Font.semibold(15.0), + font: Font.semibold(bodyFontSize), textColor: component.theme.list.itemSecondaryTextColor ), link: MarkdownAttributeSet( - font: Font.regular(15.0), + font: Font.regular(bodyFontSize), textColor: component.theme.list.itemAccentColor, additionalAttributes: [:] ), @@ -214,7 +231,12 @@ public final class ArchiveInfoContentComponent: Component { if mainTextView.superview == nil { self.scrollView.addSubview(mainTextView) } - transition.setFrame(view: mainTextView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - mainTextSize.width) * 0.5), y: contentHeight), size: mainTextSize)) + mainTextView.accessibilityElementsHidden = true + let mainTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - mainTextSize.width) * 0.5), y: contentHeight), size: mainTextSize) + transition.setFrame(view: mainTextView, frame: mainTextFrame) + self.settingsButton.accessibilityLabel = mainText.string + transition.setFrame(view: self.settingsButton, frame: mainTextFrame) + self.scrollView.bringSubviewToFront(self.settingsButton) } contentHeight += mainTextSize.height @@ -269,7 +291,7 @@ public final class ArchiveInfoContentComponent: Component { let titleSize = item.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: itemDesc.title, font: Font.semibold(15.0), textColor: component.theme.list.itemPrimaryTextColor)), + text: .plain(NSAttributedString(string: itemDesc.title, font: Font.semibold(bodyFontSize), textColor: component.theme.list.itemPrimaryTextColor)), maximumNumberOfLines: 0, lineSpacing: 0.2 )), @@ -279,7 +301,7 @@ public final class ArchiveInfoContentComponent: Component { let textSize = item.text.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: itemDesc.text, font: Font.regular(15.0), textColor: component.theme.list.itemSecondaryTextColor)), + text: .plain(NSAttributedString(string: itemDesc.text, font: Font.regular(bodyFontSize), textColor: component.theme.list.itemSecondaryTextColor)), maximumNumberOfLines: 0, lineSpacing: 0.18 )), @@ -291,6 +313,8 @@ public final class ArchiveInfoContentComponent: Component { if iconView.superview == nil { self.scrollView.addSubview(iconView) } + iconView.isAccessibilityElement = false + iconView.accessibilityElementsHidden = true transition.setFrame(view: iconView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight + 4.0), size: iconSize)) } @@ -298,7 +322,10 @@ public final class ArchiveInfoContentComponent: Component { if titleView.superview == nil { self.scrollView.addSubview(titleView) } - transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: sideInset + sideIconInset, y: contentHeight), size: titleSize)) + titleView.isAccessibilityElement = true + titleView.accessibilityLabel = "\(itemDesc.title). \(itemDesc.text)" + let titleFrame = CGRect(origin: CGPoint(x: sideInset + sideIconInset, y: contentHeight), size: titleSize) + transition.setFrame(view: titleView, frame: titleFrame) } contentHeight += titleSize.height contentHeight += 2.0 @@ -307,7 +334,15 @@ public final class ArchiveInfoContentComponent: Component { if textView.superview == nil { self.scrollView.addSubview(textView) } - transition.setFrame(view: textView, frame: CGRect(origin: CGPoint(x: sideInset + sideIconInset, y: contentHeight), size: textSize)) + textView.accessibilityElementsHidden = true + let textFrame = CGRect(origin: CGPoint(x: sideInset + sideIconInset, y: contentHeight), size: textSize) + transition.setFrame(view: textView, frame: textFrame) + item.title.view?.accessibilityFrameInContainerSpace = CGRect( + x: sideInset, + y: textFrame.minY - titleSize.height - 2.0, + width: availableSize.width - sideInset * 2.0, + height: titleSize.height + 2.0 + textSize.height + ) } contentHeight += textSize.height } @@ -321,6 +356,10 @@ public final class ArchiveInfoContentComponent: Component { return size } + + @objc private func openSettingsPressed() { + self.component?.openSettings() + } } public func makeView() -> View { diff --git a/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoScreen.swift b/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoScreen.swift index 4c79c49686b..a8252f59bb9 100644 --- a/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoScreen.swift +++ b/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoScreen.swift @@ -36,6 +36,7 @@ private final class ArchiveInfoSheetContentComponent: Component { final class View: UIView { private let content = ComponentView() private let button = ComponentView() + private let accessibilityButton: UIButton fileprivate let playButtonAnimation = ActionSlot() private var didPlayAnimation = false @@ -43,7 +44,15 @@ private final class ArchiveInfoSheetContentComponent: Component { private var component: ArchiveInfoSheetContentComponent? override init(frame: CGRect) { + self.accessibilityButton = UIButton(type: .custom) + super.init(frame: frame) + + self.accessibilityViewIsModal = true + + self.accessibilityButton.accessibilityTraits = .button + self.accessibilityButton.addTarget(self, action: #selector(self.closePressed), for: .touchUpInside) + self.addSubview(self.accessibilityButton) } required init?(coder: NSCoder) { @@ -80,6 +89,9 @@ private final class ArchiveInfoSheetContentComponent: Component { contentHeight += contentSize.height contentHeight += 30.0 + let buttonFontSize = UIFontMetrics(forTextStyle: .headline).scaledValue(for: 17.0) + let buttonHeight = max(52.0, ceil(buttonFontSize + 35.0)) + var buttonTitle: [AnyComponentWithIdentity] = [] buttonTitle.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(LottieComponent( content: LottieComponent.AppBundleContent(name: "anim_ok"), @@ -92,11 +104,12 @@ private final class ArchiveInfoSheetContentComponent: Component { text: environment.strings.ArchiveInfo_CloseAction, badge: 0, textColor: environment.theme.list.itemCheckColors.foregroundColor, + fontSize: buttonFontSize, badgeBackground: environment.theme.list.itemCheckColors.foregroundColor, badgeForeground: environment.theme.list.itemCheckColors.fillColor )))) - let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: environment.safeInsets.bottom, innerDiameter: 52.0, sideInset: 30.0) + let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: environment.safeInsets.bottom, innerDiameter: buttonHeight, sideInset: 30.0) let buttonSize = self.button.update( transition: transition, component: AnyComponent(ButtonComponent( @@ -119,7 +132,7 @@ private final class ArchiveInfoSheetContentComponent: Component { } )), environment: {}, - containerSize: CGSize(width: availableSize.width - buttonInsets.left - buttonInsets.right, height: 52.0) + containerSize: CGSize(width: availableSize.width - buttonInsets.left - buttonInsets.right, height: buttonHeight) ) let buttonFrame = CGRect(origin: CGPoint(x: buttonInsets.left, y: contentHeight), size: buttonSize) if let buttonView = self.button.view { @@ -130,11 +143,18 @@ private final class ArchiveInfoSheetContentComponent: Component { } transition.setFrame(view: buttonView, frame: buttonFrame) } + self.accessibilityButton.accessibilityLabel = environment.strings.ArchiveInfo_CloseAction + transition.setFrame(view: self.accessibilityButton, frame: buttonFrame) + self.bringSubviewToFront(self.accessibilityButton) contentHeight += buttonSize.height contentHeight += buttonInsets.bottom return CGSize(width: availableSize.width, height: contentHeight) } + + @objc private func closePressed() { + self.component?.dismiss() + } } func makeView() -> View { @@ -260,6 +280,7 @@ private final class ArchiveInfoScreenComponent: Component { if let sheetView = self.sheet.view { if sheetView.superview == nil { self.addSubview(sheetView) + sheetView.accessibilityViewIsModal = true } transition.setFrame(view: sheetView, frame: CGRect(origin: CGPoint(), size: availableSize)) } @@ -278,7 +299,12 @@ private final class ArchiveInfoScreenComponent: Component { } public class ArchiveInfoScreen: ViewControllerComponentContainer { + private let buttonAction: (() -> Void)? + private var isDismissingFromAccessibility = false + public init(context: AccountContext, settings: GlobalPrivacySettings, buttonAction: (() -> Void)? = nil) { + self.buttonAction = buttonAction + super.init(context: context, component: ArchiveInfoScreenComponent( context: context, settings: settings, @@ -305,5 +331,17 @@ public class ArchiveInfoScreen: ViewControllerComponentContainer { super.viewDidAppear(animated) self.view.disablesInteractiveModalDismiss = true + UIAccessibility.post(notification: .screenChanged, argument: self.view) + } + + override public func accessibilityPerformEscape() -> Bool { + if self.isDismissingFromAccessibility { + return false + } + self.isDismissingFromAccessibility = true + self.dismiss(completion: { [buttonAction = self.buttonAction] in + buttonAction?() + }) + return true } } From 859180104972501ec9c550664356910f5063f5f5 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:57:37 +0500 Subject: [PATCH 04/11] Improve Dynamic Type support for accessible modals Scale and reflow action sheet and text alert content for accessibility text sizes. Update modal layouts when accessibility settings change, improve deterministic focus targeting, and respect Reduce Motion and Reduce Transparency. --- submodules/Display/Source/Accessibility.swift | 26 +++++ .../Source/ActionSheetButtonItem.swift | 18 ++-- .../Source/ActionSheetCheckboxItem.swift | 51 +++++++--- .../Source/ActionSheetController.swift | 27 ++++- .../Source/ActionSheetControllerNode.swift | 4 + .../Source/ActionSheetItemGroupNode.swift | 5 +- .../Source/ActionSheetSwitchItem.swift | 19 ++-- .../Display/Source/ActionSheetTextItem.swift | 5 +- .../Display/Source/AlertContentNode.swift | 4 + .../Display/Source/AlertController.swift | 21 ++++ submodules/Display/Source/ListView.swift | 12 --- .../Display/Source/TextAlertController.swift | 99 ++++++++++++------- 12 files changed, 204 insertions(+), 87 deletions(-) diff --git a/submodules/Display/Source/Accessibility.swift b/submodules/Display/Source/Accessibility.swift index 36be42c7ee8..86093e4c3fe 100644 --- a/submodules/Display/Source/Accessibility.swift +++ b/submodules/Display/Source/Accessibility.swift @@ -20,6 +20,32 @@ public func addAccessibilityChildren(of node: ASDisplayNode, container: Any, to } } +public func firstAccessibilityElement(in view: UIView) -> Any? { + guard !view.isHidden, view.alpha > 0.01, !view.accessibilityElementsHidden else { + return nil + } + if view.isAccessibilityElement { + return view + } + if let accessibilityElements = view.accessibilityElements { + for element in accessibilityElements { + if let elementView = element as? UIView { + if let result = firstAccessibilityElement(in: elementView) { + return result + } + } else { + return element + } + } + } + for subview in view.subviews { + if let result = firstAccessibilityElement(in: subview) { + return result + } + } + return nil +} + public func smartInvertColorsEnabled() -> Bool { if #available(iOSApplicationExtension 11.0, iOS 11.0, *), UIAccessibility.isInvertColorsEnabled { return true diff --git a/submodules/Display/Source/ActionSheetButtonItem.swift b/submodules/Display/Source/ActionSheetButtonItem.swift index 7a37d67160c..9211d152870 100644 --- a/submodules/Display/Source/ActionSheetButtonItem.swift +++ b/submodules/Display/Source/ActionSheetButtonItem.swift @@ -48,9 +48,6 @@ public class ActionSheetButtonItem: ActionSheetItem { public class ActionSheetButtonNode: ActionSheetItemNode { private let theme: ActionSheetControllerTheme - private let defaultFont: UIFont - private let boldFont: UIFont - private var item: ActionSheetButtonItem? private let button: HighlightTrackingButton @@ -61,16 +58,13 @@ public class ActionSheetButtonNode: ActionSheetItemNode { override public init(theme: ActionSheetControllerTheme) { self.theme = theme - - self.defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0)) - self.boldFont = Font.medium(floor(theme.baseFontSize * 20.0 / 17.0)) - + self.button = HighlightTrackingButton() self.button.isAccessibilityElement = false self.label = ImmediateTextNode() self.label.isUserInteractionEnabled = false - self.label.maximumNumberOfLines = 1 + self.label.maximumNumberOfLines = 0 self.label.displaysAsynchronously = false self.label.truncationType = .end @@ -146,9 +140,9 @@ public class ActionSheetButtonNode: ActionSheetItemNode { } switch item.font { case .default: - textFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0)) + textFont = Font.regular(UIFontMetrics(forTextStyle: .body).scaledValue(for: floor(theme.baseFontSize * 20.0 / 17.0))) case .bold: - textFont = Font.medium(floor(theme.baseFontSize * 20.0 / 17.0)) + textFont = Font.medium(UIFontMetrics(forTextStyle: .body).scaledValue(for: floor(theme.baseFontSize * 20.0 / 17.0))) } self.label.attributedText = NSAttributedString(string: item.title, font: textFont, textColor: textColor) self.label.isAccessibilityElement = false @@ -165,11 +159,11 @@ public class ActionSheetButtonNode: ActionSheetItemNode { } public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { - let size = CGSize(width: constrainedSize.width, height: 57.0) + let labelSize = self.label.updateLayout(CGSize(width: max(1.0, constrainedSize.width - 32.0), height: constrainedSize.height)) + let size = CGSize(width: constrainedSize.width, height: max(57.0, labelSize.height + 28.0)) self.button.frame = CGRect(origin: CGPoint(), size: size) - let labelSize = self.label.updateLayout(CGSize(width: max(1.0, size.width - 10.0), height: size.height)) self.label.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - labelSize.width) / 2.0), y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize) self.accessibilityArea.frame = CGRect(origin: CGPoint(), size: size) diff --git a/submodules/Display/Source/ActionSheetCheckboxItem.swift b/submodules/Display/Source/ActionSheetCheckboxItem.swift index ec1828d03e0..50eba712dc2 100644 --- a/submodules/Display/Source/ActionSheetCheckboxItem.swift +++ b/submodules/Display/Source/ActionSheetCheckboxItem.swift @@ -40,11 +40,10 @@ public class ActionSheetCheckboxItem: ActionSheetItem { } public class ActionSheetCheckboxItemNode: ActionSheetItemNode { - private let defaultFont: UIFont - private let theme: ActionSheetControllerTheme private var item: ActionSheetCheckboxItem? + private var usesVerticalTextLayout = false private let button: HighlightTrackingButton private let titleNode: ImmediateTextNode @@ -55,19 +54,18 @@ public class ActionSheetCheckboxItemNode: ActionSheetItemNode { override public init(theme: ActionSheetControllerTheme) { self.theme = theme - self.defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0)) self.button = HighlightTrackingButton() self.button.isAccessibilityElement = false self.titleNode = ImmediateTextNode() - self.titleNode.maximumNumberOfLines = 1 + self.titleNode.maximumNumberOfLines = 0 self.titleNode.isUserInteractionEnabled = false self.titleNode.displaysAsynchronously = false self.titleNode.isAccessibilityElement = false self.labelNode = ImmediateTextNode() - self.labelNode.maximumNumberOfLines = 1 + self.labelNode.maximumNumberOfLines = 0 self.labelNode.isUserInteractionEnabled = false self.labelNode.displaysAsynchronously = false self.labelNode.isAccessibilityElement = false @@ -120,13 +118,17 @@ public class ActionSheetCheckboxItemNode: ActionSheetItemNode { func setItem(_ item: ActionSheetCheckboxItem) { self.item = item - let defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0)) + let baseFontSize = floor(theme.baseFontSize * 20.0 / 17.0) + let scaledFontSize = UIFontMetrics(forTextStyle: .body).scaledValue(for: baseFontSize) + let defaultFont = Font.regular(scaledFontSize) + self.usesVerticalTextLayout = !item.label.isEmpty && scaledFontSize > baseFontSize * 1.2 self.titleNode.attributedText = NSAttributedString(string: item.title, font: defaultFont, textColor: self.theme.primaryTextColor) self.labelNode.attributedText = NSAttributedString(string: item.label, font: defaultFont, textColor: self.theme.secondaryTextColor) self.checkNode.isHidden = !item.value self.accessibilityArea.accessibilityLabel = item.title + self.accessibilityArea.accessibilityValue = item.label.isEmpty ? nil : item.label var accessibilityTraits: UIAccessibilityTraits = [.button] if item.value { @@ -136,21 +138,40 @@ public class ActionSheetCheckboxItemNode: ActionSheetItemNode { } public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { - let size = CGSize(width: constrainedSize.width, height: 57.0) - - self.button.frame = CGRect(origin: CGPoint(), size: size) - var titleOrigin: CGFloat = 50.0 var checkOrigin: CGFloat = 27.0 + var rightInset: CGFloat = 15.0 if let item = self.item, item.style == .alignRight { titleOrigin = 24.0 - checkOrigin = size.width - 22.0 + checkOrigin = constrainedSize.width - 22.0 + rightInset = 50.0 + } + + let contentWidth = max(1.0, constrainedSize.width - titleOrigin - rightInset) + let labelSize: CGSize + let titleSize: CGSize + let textHeight: CGFloat + if self.usesVerticalTextLayout { + titleSize = self.titleNode.updateLayout(CGSize(width: contentWidth, height: constrainedSize.height)) + labelSize = self.labelNode.updateLayout(CGSize(width: contentWidth, height: constrainedSize.height)) + textHeight = titleSize.height + 4.0 + labelSize.height + } else { + labelSize = self.labelNode.updateLayout(CGSize(width: contentWidth * 0.45, height: constrainedSize.height)) + titleSize = self.titleNode.updateLayout(CGSize(width: max(1.0, contentWidth - labelSize.width - 8.0), height: constrainedSize.height)) + textHeight = max(titleSize.height, labelSize.height) } + let size = CGSize(width: constrainedSize.width, height: max(57.0, textHeight + 28.0)) + + self.button.frame = CGRect(origin: CGPoint(), size: size) - let labelSize = self.labelNode.updateLayout(CGSize(width: size.width - 44.0 - 15.0 - 8.0, height: size.height)) - let titleSize = self.titleNode.updateLayout(CGSize(width: size.width - 44.0 - labelSize.width - 15.0 - 8.0, height: size.height)) - self.titleNode.frame = CGRect(origin: CGPoint(x: titleOrigin, y: floorToScreenPixels((size.height - titleSize.height) / 2.0)), size: titleSize) - self.labelNode.frame = CGRect(origin: CGPoint(x: size.width - 15.0 - labelSize.width, y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize) + if self.usesVerticalTextLayout { + let textOrigin = floorToScreenPixels((size.height - textHeight) / 2.0) + self.titleNode.frame = CGRect(origin: CGPoint(x: titleOrigin, y: textOrigin), size: titleSize) + self.labelNode.frame = CGRect(origin: CGPoint(x: titleOrigin, y: textOrigin + titleSize.height + 4.0), size: labelSize) + } else { + self.titleNode.frame = CGRect(origin: CGPoint(x: titleOrigin, y: floorToScreenPixels((size.height - titleSize.height) / 2.0)), size: titleSize) + self.labelNode.frame = CGRect(origin: CGPoint(x: size.width - 15.0 - labelSize.width, y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize) + } if let image = self.checkNode.image { self.checkNode.frame = CGRect(origin: CGPoint(x: floor(checkOrigin - (image.size.width / 2.0)), y: floor((size.height - image.size.height) / 2.0)), size: image.size) diff --git a/submodules/Display/Source/ActionSheetController.swift b/submodules/Display/Source/ActionSheetController.swift index 9b808d284ea..91821ca42cf 100644 --- a/submodules/Display/Source/ActionSheetController.swift +++ b/submodules/Display/Source/ActionSheetController.swift @@ -23,6 +23,8 @@ open class ActionSheetController: ViewController, PresentableController, Standal private var isDismissed: Bool = false private weak var previousAccessibilityFocus: AnyObject? + private var contentSizeCategoryObserver: NSObjectProtocol? + private var reduceTransparencyObserver: NSObjectProtocol? public var dismissed: ((Bool) -> Void)? @@ -36,11 +38,34 @@ open class ActionSheetController: ViewController, PresentableController, Standal self.statusBar.statusBarStyle = .Ignore self.blocksBackgroundWhenInOverlay = true + + self.contentSizeCategoryObserver = NotificationCenter.default.addObserver(forName: UIContentSizeCategory.didChangeNotification, object: nil, queue: .main, using: { [weak self] _ in + guard let self, self.isViewLoaded else { + return + } + self.actionSheetNode.setGroups(self.groups) + UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: self.actionSheetNode.view) ?? self.actionSheetNode.view) + }) + self.reduceTransparencyObserver = NotificationCenter.default.addObserver(forName: UIAccessibility.reduceTransparencyStatusDidChangeNotification, object: nil, queue: .main, using: { [weak self] _ in + guard let self, self.isViewLoaded else { + return + } + self.actionSheetNode.setGroups(self.groups) + }) } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + deinit { + if let contentSizeCategoryObserver = self.contentSizeCategoryObserver { + NotificationCenter.default.removeObserver(contentSizeCategoryObserver) + } + if let reduceTransparencyObserver = self.reduceTransparencyObserver { + NotificationCenter.default.removeObserver(reduceTransparencyObserver) + } + } public func dismissAnimated() { if !self.isDismissed { @@ -102,7 +127,7 @@ open class ActionSheetController: ViewController, PresentableController, Standal guard let self else { return } - UIAccessibility.post(notification: .screenChanged, argument: self.actionSheetNode.view) + UIAccessibility.post(notification: .screenChanged, argument: firstAccessibilityElement(in: self.actionSheetNode.view) ?? self.actionSheetNode.view) } } diff --git a/submodules/Display/Source/ActionSheetControllerNode.swift b/submodules/Display/Source/ActionSheetControllerNode.swift index f50a3a0e206..e49a8b3eb96 100644 --- a/submodules/Display/Source/ActionSheetControllerNode.swift +++ b/submodules/Display/Source/ActionSheetControllerNode.swift @@ -254,6 +254,10 @@ final class ActionSheetControllerNode: ASDisplayNode, ASScrollViewDelegate { func setGroups(_ groups: [ActionSheetItemGroup]) { self.itemGroupsContainerNode.setGroups(groups) + + if let validLayout = self.validLayout { + self.containerLayoutUpdated(validLayout, transition: .immediate) + } } func updateItem(groupIndex: Int, itemIndex: Int, _ f: (ActionSheetItem) -> ActionSheetItem) { diff --git a/submodules/Display/Source/ActionSheetItemGroupNode.swift b/submodules/Display/Source/ActionSheetItemGroupNode.swift index af9d79fc277..b4abcbe216f 100644 --- a/submodules/Display/Source/ActionSheetItemGroupNode.swift +++ b/submodules/Display/Source/ActionSheetItemGroupNode.swift @@ -40,8 +40,11 @@ final class ActionSheetItemGroupNode: ASDisplayNode, ASScrollViewDelegate { self.clippingNode = ASDisplayNode() self.clippingNode.clipsToBounds = true self.clippingNode.cornerRadius = 16.0 + if UIAccessibility.isReduceTransparencyEnabled { + self.clippingNode.backgroundColor = self.theme.itemBackgroundColor.withAlphaComponent(1.0) + } - self.backgroundEffectView = UIVisualEffectView(effect: UIBlurEffect(style: self.theme.backgroundType == .light ? .light : .dark)) + self.backgroundEffectView = UIVisualEffectView(effect: UIAccessibility.isReduceTransparencyEnabled ? nil : UIBlurEffect(style: self.theme.backgroundType == .light ? .light : .dark)) self.scrollNode = ASScrollNode() self.scrollNode.canCancelAllTouchesInViews = true diff --git a/submodules/Display/Source/ActionSheetSwitchItem.swift b/submodules/Display/Source/ActionSheetSwitchItem.swift index 75d0651a30f..6763dd1f2d2 100644 --- a/submodules/Display/Source/ActionSheetSwitchItem.swift +++ b/submodules/Display/Source/ActionSheetSwitchItem.swift @@ -49,7 +49,7 @@ public class ActionSheetSwitchNode: ActionSheetItemNode { self.label = ImmediateTextNode() self.label.isUserInteractionEnabled = false - self.label.maximumNumberOfLines = 1 + self.label.maximumNumberOfLines = 0 self.label.displaysAsynchronously = false self.label.truncationType = .end self.label.isAccessibilityElement = false @@ -86,7 +86,7 @@ public class ActionSheetSwitchNode: ActionSheetItemNode { func setItem(_ item: ActionSheetSwitchItem) { self.item = item - let defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0)) + let defaultFont = Font.regular(UIFontMetrics(forTextStyle: .body).scaledValue(for: floor(theme.baseFontSize * 20.0 / 17.0))) self.label.attributedText = NSAttributedString(string: item.title, font: defaultFont, textColor: self.theme.primaryTextColor) self.label.isAccessibilityElement = false @@ -103,13 +103,6 @@ public class ActionSheetSwitchNode: ActionSheetItemNode { } public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { - let size = CGSize(width: constrainedSize.width, height: 57.0) - - self.button.frame = CGRect(origin: CGPoint(), size: size) - - let labelSize = self.label.updateLayout(CGSize(width: max(1.0, size.width - 51.0 - 16.0 * 2.0), height: size.height)) - self.label.frame = CGRect(origin: CGPoint(x: 16.0, y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize) - var switchSize = CGSize(width: 51.0, height: 31.0) if let switchView = self.switchNode.view as? UISwitch { if self.switchNode.bounds.size.width.isZero { @@ -117,6 +110,12 @@ public class ActionSheetSwitchNode: ActionSheetItemNode { } switchSize = switchView.bounds.size } + + let labelSize = self.label.updateLayout(CGSize(width: max(1.0, constrainedSize.width - switchSize.width - 16.0 * 3.0), height: constrainedSize.height)) + let size = CGSize(width: constrainedSize.width, height: max(57.0, labelSize.height + 28.0)) + + self.button.frame = CGRect(origin: CGPoint(), size: size) + self.label.frame = CGRect(origin: CGPoint(x: 16.0, y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize) self.switchNode.frame = CGRect(origin: CGPoint(x: size.width - 16.0 - switchSize.width, y: floor((size.height - switchSize.height) / 2.0)), size: switchSize) self.accessibilityArea.frame = CGRect(origin: CGPoint(), size: size) @@ -127,7 +126,7 @@ public class ActionSheetSwitchNode: ActionSheetItemNode { @objc func buttonPressed() { let value = !self.switchNode.isOn - self.switchNode.setOn(value, animated: true) + self.switchNode.setOn(value, animated: !UIAccessibility.isReduceMotionEnabled) self.item?.action(value) } } diff --git a/submodules/Display/Source/ActionSheetTextItem.swift b/submodules/Display/Source/ActionSheetTextItem.swift index 7921a5fe45a..ec7ecbfc750 100644 --- a/submodules/Display/Source/ActionSheetTextItem.swift +++ b/submodules/Display/Source/ActionSheetTextItem.swift @@ -78,8 +78,9 @@ public class ActionSheetTextNode: ActionSheetItemNode { fontSize = 15.0 } - let defaultFont = Font.regular(floor(self.theme.baseFontSize * fontSize / 17.0)) - let boldFont = Font.semibold(floor(self.theme.baseFontSize * fontSize / 17.0)) + let scaledFontSize = UIFontMetrics(forTextStyle: .body).scaledValue(for: floor(self.theme.baseFontSize * fontSize / 17.0)) + let defaultFont = Font.regular(scaledFontSize) + let boldFont = Font.semibold(scaledFontSize) if item.parseMarkdown { let body = MarkdownAttributeSet(font: defaultFont, textColor: self.theme.secondaryTextColor) diff --git a/submodules/Display/Source/AlertContentNode.swift b/submodules/Display/Source/AlertContentNode.swift index ce205a3eae0..faee3a61f38 100644 --- a/submodules/Display/Source/AlertContentNode.swift +++ b/submodules/Display/Source/AlertContentNode.swift @@ -23,6 +23,10 @@ open class AlertContentNode: ASDisplayNode { } + open func contentSizeCategoryUpdated() { + + } + open func performHighlightedAction() { } diff --git a/submodules/Display/Source/AlertController.swift b/submodules/Display/Source/AlertController.swift index 0c59f97bb96..e2306b24d70 100644 --- a/submodules/Display/Source/AlertController.swift +++ b/submodules/Display/Source/AlertController.swift @@ -88,6 +88,8 @@ open class AlertController: ViewController, StandalonePresentableController, Key private weak var existingAlertController: AlertController? private weak var previousAccessibilityFocus: AnyObject? + private var contentSizeCategoryObserver: NSObjectProtocol? + private var reduceTransparencyObserver: NSObjectProtocol? public var willDismiss: (() -> Void)? public var dismissed: ((Bool) -> Void)? @@ -103,11 +105,30 @@ open class AlertController: ViewController, StandalonePresentableController, Key self.blocksBackgroundWhenInOverlay = true self.statusBar.statusBarStyle = .Ignore + + self.contentSizeCategoryObserver = NotificationCenter.default.addObserver(forName: UIContentSizeCategory.didChangeNotification, object: nil, queue: .main, using: { [weak self] _ in + self?.contentNode.contentSizeCategoryUpdated() + }) + self.reduceTransparencyObserver = NotificationCenter.default.addObserver(forName: UIAccessibility.reduceTransparencyStatusDidChangeNotification, object: nil, queue: .main, using: { [weak self] _ in + guard let self, self.isViewLoaded else { + return + } + self.controllerNode.updateTheme(self.theme) + }) } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + deinit { + if let contentSizeCategoryObserver = self.contentSizeCategoryObserver { + NotificationCenter.default.removeObserver(contentSizeCategoryObserver) + } + if let reduceTransparencyObserver = self.reduceTransparencyObserver { + NotificationCenter.default.removeObserver(reduceTransparencyObserver) + } + } private var isDismissed = false override open func loadDisplayNode() { diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index dd000586849..92ff34d3cc6 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -5493,15 +5493,3 @@ private func containsAccessibilityFocus(_ view: UIView) -> Bool { } return false } - -private func firstAccessibilityElement(in view: UIView) -> UIView? { - if view.isAccessibilityElement && !view.isHidden && view.alpha > 0.01 { - return view - } - for subview in view.subviews { - if let result = firstAccessibilityElement(in: subview) { - return result - } - } - return nil -} diff --git a/submodules/Display/Source/TextAlertController.swift b/submodules/Display/Source/TextAlertController.swift index b19cfdd73de..e1872c4c011 100644 --- a/submodules/Display/Source/TextAlertController.swift +++ b/submodules/Display/Source/TextAlertController.swift @@ -46,7 +46,7 @@ public final class TextAlertContentActionNode: HighlightableButtonNode { super.init() - self.titleNode.maximumNumberOfLines = 2 + self.titleNode.maximumNumberOfLines = 0 self.highligthedChanged = { [weak self] value in if let strongSelf = self { @@ -110,7 +110,8 @@ public final class TextAlertContentActionNode: HighlightableButtonNode { } private func updateTitle() { - var font = Font.regular(theme.baseFontSize) + let fontSize = UIFontMetrics(forTextStyle: .body).scaledValue(for: theme.baseFontSize) + var font = Font.regular(fontSize) var color: UIColor switch self.action.type { case .defaultAction, .genericAction: @@ -120,7 +121,7 @@ public final class TextAlertContentActionNode: HighlightableButtonNode { } switch self.action.type { case .defaultAction, .defaultDestructiveAction: - font = Font.semibold(theme.baseFontSize) + font = Font.semibold(fontSize) case .destructiveAction, .genericAction: break } @@ -167,6 +168,9 @@ public final class TextAlertContentNode: AlertContentNode { private let actionNodesSeparator: ASDisplayNode private let actionNodes: [TextAlertContentActionNode] private let actionVerticalSeparators: [ASDisplayNode] + private let dynamicTypeTitle: String? + private let dynamicTypeText: String? + private let dynamicTypeParseMarkdown: Bool private var validLayout: CGSize? @@ -204,16 +208,19 @@ public final class TextAlertContentNode: AlertContentNode { } } - public init(theme: AlertControllerTheme, title: NSAttributedString?, text: NSAttributedString, actions: [TextAlertAction], actionLayout: TextAlertContentActionLayout, dismissOnOutsideTap: Bool, linkAction: (([NSAttributedString.Key: Any], Int) -> Void)? = nil) { + public init(theme: AlertControllerTheme, title: NSAttributedString?, text: NSAttributedString, actions: [TextAlertAction], actionLayout: TextAlertContentActionLayout, dismissOnOutsideTap: Bool, linkAction: (([NSAttributedString.Key: Any], Int) -> Void)? = nil, dynamicTypeTitle: String? = nil, dynamicTypeText: String? = nil, dynamicTypeParseMarkdown: Bool = false) { self.theme = theme self.actionLayout = actionLayout self._dismissOnOutsideTap = dismissOnOutsideTap + self.dynamicTypeTitle = dynamicTypeTitle + self.dynamicTypeText = dynamicTypeText + self.dynamicTypeParseMarkdown = dynamicTypeParseMarkdown if let title = title { let titleNode = ImmediateTextNode() 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 @@ -362,6 +369,18 @@ public final class TextAlertContentNode: AlertContentNode { } } + override public func contentSizeCategoryUpdated() { + for actionNode in self.actionNodes { + actionNode.updateTheme(self.theme) + } + if let dynamicTypeText = self.dynamicTypeText { + let attributedStrings = standardTextAlertAttributedStrings(theme: self.theme, title: self.dynamicTypeTitle, text: dynamicTypeText, parseMarkdown: self.dynamicTypeParseMarkdown) + self.titleNode?.attributedText = attributedStrings.title + self.textNode.attributedText = attributedStrings.text + } + self.requestLayout?(.immediate) + } + override public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { self.validLayout = size @@ -376,24 +395,21 @@ public final class TextAlertContentNode: 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 @@ -401,9 +417,9 @@ public final class TextAlertContentNode: AlertContentNode { 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 @@ -423,10 +439,11 @@ public final class TextAlertContentNode: 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 { @@ -456,11 +473,12 @@ public final class TextAlertContentNode: 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) @@ -472,16 +490,18 @@ public final class TextAlertContentNode: AlertContentNode { } } -public func textAlertController(theme: AlertControllerTheme, title: NSAttributedString?, text: NSAttributedString, actions: [TextAlertAction], actionLayout: TextAlertContentActionLayout = .horizontal, dismissOnOutsideTap: Bool = true, linkAction: (([NSAttributedString.Key: Any], Int) -> Void)? = nil) -> AlertController { - return AlertController(theme: theme, contentNode: TextAlertContentNode(theme: theme, title: title, text: text, actions: actions, actionLayout: actionLayout, dismissOnOutsideTap: dismissOnOutsideTap, linkAction: linkAction)) -} +private func standardTextAlertAttributedStrings(theme: AlertControllerTheme, title: String?, text: String, parseMarkdown: Bool) -> (title: NSAttributedString?, text: NSAttributedString) { + let titleFontSize = UIFontMetrics(forTextStyle: .headline).scaledValue(for: theme.baseFontSize) + let bodyBaseFontSize = title == nil ? theme.baseFontSize : floor(theme.baseFontSize * 13.0 / 17.0) + let bodyFontSize = UIFontMetrics(forTextStyle: .body).scaledValue(for: bodyBaseFontSize) -public func standardTextAlertController(theme: AlertControllerTheme, title: String?, text: String, actions: [TextAlertAction], actionLayout: TextAlertContentActionLayout = .horizontal, allowInputInset: Bool = true, parseMarkdown: Bool = false, dismissOnOutsideTap: Bool = true, linkAction: (([NSAttributedString.Key: Any], Int) -> Void)? = nil) -> AlertController { - var dismissImpl: (() -> Void)? + let attributedTitle = title.flatMap { + NSAttributedString(string: $0, font: Font.semibold(titleFontSize), textColor: theme.primaryColor, paragraphAlignment: .center) + } let attributedText: NSAttributedString if parseMarkdown { - let font = title == nil ? Font.semibold(theme.baseFontSize) : Font.regular(floor(theme.baseFontSize * 13.0 / 17.0)) - let boldFont = title == nil ? Font.bold(theme.baseFontSize) : Font.semibold(floor(theme.baseFontSize * 13.0 / 17.0)) + let font = title == nil ? Font.semibold(bodyFontSize) : Font.regular(bodyFontSize) + let boldFont = title == nil ? Font.bold(bodyFontSize) : Font.semibold(bodyFontSize) let body = MarkdownAttributeSet(font: font, textColor: theme.primaryColor) let bold = MarkdownAttributeSet(font: boldFont, textColor: theme.primaryColor) let link = MarkdownAttributeSet(font: font, textColor: theme.accentColor) @@ -489,14 +509,25 @@ public func standardTextAlertController(theme: AlertControllerTheme, title: Stri return ("URL", url) }), textAlignment: .center) } else { - attributedText = NSAttributedString(string: text, font: title == nil ? Font.semibold(theme.baseFontSize) : Font.regular(floor(theme.baseFontSize * 13.0 / 17.0)), textColor: theme.primaryColor, paragraphAlignment: .center) + let font = title == nil ? Font.semibold(bodyFontSize) : Font.regular(bodyFontSize) + attributedText = NSAttributedString(string: text, font: font, textColor: theme.primaryColor, paragraphAlignment: .center) } - let controller = AlertController(theme: theme, contentNode: TextAlertContentNode(theme: theme, title: title != nil ? NSAttributedString(string: title!, font: Font.semibold(theme.baseFontSize), textColor: theme.primaryColor, paragraphAlignment: .center) : nil, text: attributedText, actions: actions.map { action in + return (attributedTitle, attributedText) +} + +public func textAlertController(theme: AlertControllerTheme, title: NSAttributedString?, text: NSAttributedString, actions: [TextAlertAction], actionLayout: TextAlertContentActionLayout = .horizontal, dismissOnOutsideTap: Bool = true, linkAction: (([NSAttributedString.Key: Any], Int) -> Void)? = nil) -> AlertController { + return AlertController(theme: theme, contentNode: TextAlertContentNode(theme: theme, title: title, text: text, actions: actions, actionLayout: actionLayout, dismissOnOutsideTap: dismissOnOutsideTap, linkAction: linkAction)) +} + +public func standardTextAlertController(theme: AlertControllerTheme, title: String?, text: String, actions: [TextAlertAction], actionLayout: TextAlertContentActionLayout = .horizontal, allowInputInset: Bool = true, parseMarkdown: Bool = false, dismissOnOutsideTap: Bool = true, linkAction: (([NSAttributedString.Key: Any], Int) -> Void)? = nil) -> AlertController { + var dismissImpl: (() -> Void)? + let attributedStrings = standardTextAlertAttributedStrings(theme: theme, title: title, text: text, parseMarkdown: parseMarkdown) + let controller = AlertController(theme: theme, contentNode: TextAlertContentNode(theme: theme, title: attributedStrings.title, text: attributedStrings.text, actions: actions.map { action in return TextAlertAction(type: action.type, title: action.title, action: { dismissImpl?() action.action() }) - }, actionLayout: actionLayout, dismissOnOutsideTap: dismissOnOutsideTap, linkAction: linkAction), allowInputInset: allowInputInset) + }, actionLayout: actionLayout, dismissOnOutsideTap: dismissOnOutsideTap, linkAction: linkAction, dynamicTypeTitle: title, dynamicTypeText: text, dynamicTypeParseMarkdown: parseMarkdown), allowInputInset: allowInputInset) dismissImpl = { [weak controller] in controller?.dismissAnimated() } From 512637ef878207115e1af2fca12e2f144861dada Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:13:46 +0500 Subject: [PATCH 05/11] Improve VoiceOver support for alerts and Voice Control - expose alert links and text entities as accessibility actions - improve initial focus and Dynamic Type layouts in text alerts - add accessible URL authorization options and selected states - distinguish interactive messages for Voice Control - expose the actual chat input field as an interactive target --- .../TextAlertWithEntitiesController.swift | 94 ++++++++++--- .../Display/Source/AlertController.swift | 5 +- .../Display/Source/TextAlertController.swift | 54 ++++++++ .../Sources/ChatMessageItemView.swift | 3 + .../Sources/ChatTextInputPanelNode.swift | 19 ++- .../ChatMessageActionUrlAuthController.swift | 126 ++++++++++++++---- 6 files changed, 253 insertions(+), 48 deletions(-) diff --git a/submodules/AlertUI/Sources/TextAlertWithEntitiesController.swift b/submodules/AlertUI/Sources/TextAlertWithEntitiesController.swift index a9e506b81ce..9818396c02e 100644 --- a/submodules/AlertUI/Sources/TextAlertWithEntitiesController.swift +++ b/submodules/AlertUI/Sources/TextAlertWithEntitiesController.swift @@ -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 @@ -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)? { @@ -47,6 +61,7 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode { self.textNode.highlightAttributeAction = nil self.textNode.tapAttributeAction = nil } + self.updateTextAccessibilityActions() } } @@ -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 @@ -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) { @@ -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 @@ -214,24 +269,21 @@ 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 @@ -239,9 +291,9 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode { 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 @@ -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 { @@ -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) diff --git a/submodules/Display/Source/AlertController.swift b/submodules/Display/Source/AlertController.swift index e2306b24d70..fa7c3571d48 100644 --- a/submodules/Display/Source/AlertController.swift +++ b/submodules/Display/Source/AlertController.swift @@ -157,7 +157,10 @@ open class AlertController: ViewController, StandalonePresentableController, Key self.existingAlertController = nil self.controllerNode.animateIn() - UIAccessibility.post(notification: .screenChanged, argument: self.contentNode.accessibilityInitialFocusNode?.view ?? self.contentNode.view) + UIAccessibility.post( + notification: .screenChanged, + argument: self.contentNode.accessibilityInitialFocusNode?.view ?? firstAccessibilityElement(in: self.contentNode.view) ?? self.contentNode.view + ) } override open func viewWillAppear(_ animated: Bool) { diff --git a/submodules/Display/Source/TextAlertController.swift b/submodules/Display/Source/TextAlertController.swift index e1872c4c011..4b7cc74d0f5 100644 --- a/submodules/Display/Source/TextAlertController.swift +++ b/submodules/Display/Source/TextAlertController.swift @@ -158,6 +158,16 @@ public enum TextAlertContentActionLayout { case vertical } +private final class TextAlertAccessibilityCustomAction: 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) + } +} + public final class TextAlertContentNode: AlertContentNode { private var theme: AlertControllerTheme private let actionLayout: TextAlertContentActionLayout @@ -171,6 +181,7 @@ public final class TextAlertContentNode: AlertContentNode { private let dynamicTypeTitle: String? private let dynamicTypeText: String? private let dynamicTypeParseMarkdown: Bool + private let linkAction: (([NSAttributedString.Key: Any], Int) -> Void)? private var validLayout: CGSize? @@ -205,6 +216,7 @@ public final class TextAlertContentNode: AlertContentNode { self.textNode.highlightAttributeAction = nil self.textNode.tapAttributeAction = nil } + self.updateTextAccessibilityActions() } } @@ -215,6 +227,7 @@ public final class TextAlertContentNode: AlertContentNode { self.dynamicTypeTitle = dynamicTypeTitle self.dynamicTypeText = dynamicTypeText self.dynamicTypeParseMarkdown = dynamicTypeParseMarkdown + self.linkAction = linkAction if let title = title { let titleNode = ImmediateTextNode() titleNode.attributedText = title @@ -297,6 +310,46 @@ public final class TextAlertContentNode: AlertContentNode { for separatorNode in self.actionVerticalSeparators { self.addSubnode(separatorNode) } + + self.updateTextAccessibilityActions() + } + + private func updateTextAccessibilityActions() { + guard let attributedText = self.textNode.attributedText, attributedText.length != 0 else { + self.textNode.accessibilityCustomActions = nil + return + } + + var accessibilityActions: [UIAccessibilityCustomAction] = [] + attributedText.enumerateAttributes(in: NSRange(location: 0, length: attributedText.length), options: []) { [weak self] attributes, range, _ in + guard let self else { + return + } + let actionName = attributedText.attributedSubstring(from: range).string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !actionName.isEmpty else { + return + } + + if self.textAttributeAction == nil, attributes[NSAttributedString.Key(rawValue: "URL")] != nil, let linkAction = self.linkAction { + accessibilityActions.append(TextAlertAccessibilityCustomAction(name: actionName, target: self, selector: #selector(self.performTextAccessibilityAction(_:)), perform: { + linkAction(attributes, range.location) + })) + } + if let (attribute, textAttributeAction) = self.textAttributeAction, let value = attributes[attribute] { + accessibilityActions.append(TextAlertAccessibilityCustomAction(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? TextAlertAccessibilityCustomAction else { + return false + } + action.perform() + return true } func setHighlightedItemIndex(_ index: Int?, update: Bool = false) { @@ -377,6 +430,7 @@ public final class TextAlertContentNode: AlertContentNode { let attributedStrings = standardTextAlertAttributedStrings(theme: self.theme, title: self.dynamicTypeTitle, text: dynamicTypeText, parseMarkdown: self.dynamicTypeParseMarkdown) self.titleNode?.attributedText = attributedStrings.title self.textNode.attributedText = attributedStrings.text + self.updateTextAccessibilityActions() } self.requestLayout?(.immediate) } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift index 9dc4d087af7..71263876310 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift @@ -90,6 +90,7 @@ public final class ChatMessageAccessibilityData { public let traits: UIAccessibilityTraits public let customActions: [ChatMessageAccessibilityCustomAction]? public let singleUrl: String? + public let respondsToUserInteraction: Bool public init(item: ChatMessageItem, isSelected: Bool?) { var hint: String? @@ -633,6 +634,7 @@ public final class ChatMessageAccessibilityData { self.traits = traits self.customActions = customActions.isEmpty ? nil : customActions self.singleUrl = singleUrl + self.respondsToUserInteraction = singleUrl != nil || !item.message.media.isEmpty } @objc private func noop() { @@ -724,6 +726,7 @@ open class ChatMessageItemView: ListViewItemNode, ChatMessageItemNodeProtocol { accessibilityNode.accessibilityValue = accessibilityData.value accessibilityNode.accessibilityHint = accessibilityData.hint accessibilityNode.accessibilityTraits = accessibilityData.traits + accessibilityNode.view.accessibilityRespondsToUserInteraction = accessibilityData.respondsToUserInteraction if let customActions = accessibilityData.customActions { accessibilityNode.accessibilityCustomActions = customActions.map { action in return ChatMessageAccessibilityCustomAction(name: action.name, target: customActionTarget, selector: customActionSelector, action: action.action) diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index 0d0d35b31c4..da7c676ff45 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -71,6 +71,21 @@ public let chatTextInputMinFontSize: CGFloat = 5.0 private let minInputFontSize = chatTextInputMinFontSize +private func accessibilityTextInputView(in view: UIView) -> UIView { + if view is UITextInput { + return view + } + for subview in view.subviews { + if !subview.isHidden && subview.alpha > 0.0 { + let result = accessibilityTextInputView(in: subview) + if result is UITextInput { + return result + } + } + } + return view +} + private func calclulateTextFieldMinHeight(_ presentationInterfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics) -> CGFloat { var baseFontSize = max(minInputFontSize, presentationInterfaceState.fontSize.baseDisplaySize) if "".isEmpty { @@ -3201,8 +3216,10 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg richTextInputNode.textContainerInset = textInputViewRealInsets richTextInputNode.textFieldFrame = actualTextFieldFrame richTextInputNode.updateLayout(size: textFieldFrame.size) + let accessibilityInputView = accessibilityTextInputView(in: richTextInputNode.inputView) let accessibilityBounds = richTextInputNode.inputView.bounds.inset(by: richTextInputNode.inputHitTestSlop) - richTextInputNode.inputView.accessibilityFrame = UIAccessibility.convertToScreenCoordinates(accessibilityBounds, in: richTextInputNode.inputView) + accessibilityInputView.accessibilityFrame = UIAccessibility.convertToScreenCoordinates(accessibilityBounds, in: richTextInputNode.inputView) + accessibilityInputView.accessibilityRespondsToUserInteraction = true self.updateInputField(textInputFrame: textFieldFrame, transition: ComponentTransition(transition)) if shouldUpdateLayout { richTextInputNode.layoutInputField() diff --git a/submodules/TelegramUI/Sources/ChatMessageActionUrlAuthController.swift b/submodules/TelegramUI/Sources/ChatMessageActionUrlAuthController.swift index 1b29e65e763..05431e45d48 100644 --- a/submodules/TelegramUI/Sources/ChatMessageActionUrlAuthController.swift +++ b/submodules/TelegramUI/Sources/ChatMessageActionUrlAuthController.swift @@ -11,14 +11,22 @@ import TextFormat import AccountContext import Markdown -private let textFont = Font.regular(13.0) -private let boldTextFont = Font.semibold(13.0) - private func formattedText(_ text: String, color: UIColor, textAlignment: NSTextAlignment = .natural) -> NSAttributedString { + let textFont = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: Font.regular(13.0)) + let boldTextFont = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: Font.semibold(13.0)) return parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: color), bold: MarkdownAttributeSet(font: boldTextFont, textColor: color), link: MarkdownAttributeSet(font: textFont, textColor: color), linkAttribute: { _ in return nil}), textAlignment: textAlignment) } +private final class ChatMessageActionUrlAuthOptionNode: ASTextNode { + var activate: (() -> Bool)? + + override func accessibilityActivate() -> Bool { + return self.activate?() ?? false + } +} + private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { + private var theme: AlertControllerTheme private let strings: PresentationStrings private let nameDisplayOrder: PresentationPersonNameOrder private let defaultUrl: String @@ -29,9 +37,9 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { private let titleNode: ASTextNode private let textNode: ASTextNode private let authorizeCheckNode: InteractiveCheckNode - private let authorizeLabelNode: ASTextNode + private let authorizeLabelNode: ChatMessageActionUrlAuthOptionNode private let allowWriteCheckNode: InteractiveCheckNode - private let allowWriteLabelNode: ASTextNode + private let allowWriteLabelNode: ChatMessageActionUrlAuthOptionNode private let actionNodesSeparator: ASDisplayNode private let actionNodes: [TextAlertContentActionNode] @@ -42,6 +50,10 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { override var dismissOnOutsideTap: Bool { return self.isUserInteractionEnabled } + + override var accessibilityInitialFocusNode: ASDisplayNode? { + return self.titleNode + } var authorize: Bool = true { didSet { @@ -52,16 +64,19 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { if !self.authorize && self.allowWriteAccess { self.allowWriteAccess = false } + self.updateOptionAccessibility() } } var allowWriteAccess: Bool = true { didSet { self.allowWriteCheckNode.setSelected(self.allowWriteAccess, animated: true) + self.updateOptionAccessibility() } } init(theme: AlertControllerTheme, ptheme: PresentationTheme, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, defaultUrl: String, domain: String, bot: EnginePeer, requestWriteAccess: Bool, displayName: String, actions: [TextAlertAction]) { + self.theme = theme self.strings = strings self.nameDisplayOrder = nameDisplayOrder self.defaultUrl = defaultUrl @@ -70,22 +85,26 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { self.displayName = displayName self.titleNode = ASTextNode() - self.titleNode.maximumNumberOfLines = 2 + self.titleNode.maximumNumberOfLines = 0 + self.titleNode.isAccessibilityElement = true + self.titleNode.accessibilityTraits = [.header] self.textNode = ASTextNode() self.textNode.maximumNumberOfLines = 0 self.authorizeCheckNode = InteractiveCheckNode(theme: CheckNodeTheme(backgroundColor: theme.accentColor, strokeColor: theme.contrastColor, borderColor: theme.controlBorderColor, overlayBorder: false, hasInset: false, hasShadow: false)) self.authorizeCheckNode.setSelected(true, animated: false) - self.authorizeLabelNode = ASTextNode() - self.authorizeLabelNode.maximumNumberOfLines = 4 + self.authorizeLabelNode = ChatMessageActionUrlAuthOptionNode() + self.authorizeLabelNode.maximumNumberOfLines = 0 self.authorizeLabelNode.isUserInteractionEnabled = true + self.authorizeLabelNode.isAccessibilityElement = true self.allowWriteCheckNode = InteractiveCheckNode(theme: CheckNodeTheme(backgroundColor: theme.accentColor, strokeColor: theme.contrastColor, borderColor: theme.controlBorderColor, overlayBorder: false, hasInset: false, hasShadow: false)) self.allowWriteCheckNode.setSelected(true, animated: false) - self.allowWriteLabelNode = ASTextNode() - self.allowWriteLabelNode.maximumNumberOfLines = 4 + self.allowWriteLabelNode = ChatMessageActionUrlAuthOptionNode() + self.allowWriteLabelNode.maximumNumberOfLines = 0 self.allowWriteLabelNode.isUserInteractionEnabled = true + self.allowWriteLabelNode.isAccessibilityElement = true self.actionNodesSeparator = ASDisplayNode() self.actionNodesSeparator.isLayerBacked = true @@ -105,6 +124,9 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { self.actionVerticalSeparators = actionVerticalSeparators super.init() + + self.authorizeCheckNode.isAccessibilityElement = false + self.allowWriteCheckNode.isAccessibilityElement = false self.addSubnode(self.titleNode) self.addSubnode(self.textNode) @@ -136,6 +158,20 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { strongSelf.allowWriteAccess = !strongSelf.allowWriteAccess } } + self.authorizeLabelNode.activate = { [weak self] in + guard let self else { + return false + } + self.authorize = !self.authorize + return true + } + self.allowWriteLabelNode.activate = { [weak self] in + guard let self, self.authorize else { + return false + } + self.allowWriteAccess = !self.allowWriteAccess + return true + } self.updateTheme(theme) } @@ -158,11 +194,19 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { } override func updateTheme(_ theme: AlertControllerTheme) { - self.titleNode.attributedText = NSAttributedString(string: strings.Conversation_OpenBotLinkTitle, font: Font.bold(17.0), textColor: theme.primaryColor, paragraphAlignment: .center) + self.theme = theme + let titleFont = UIFontMetrics(forTextStyle: .headline).scaledFont(for: Font.bold(17.0)) + self.titleNode.attributedText = NSAttributedString(string: strings.Conversation_OpenBotLinkTitle, font: titleFont, textColor: theme.primaryColor, paragraphAlignment: .center) self.textNode.attributedText = formattedText(strings.Conversation_OpenBotLinkText(self.defaultUrl).string, color: theme.primaryColor, textAlignment: .center) self.authorizeLabelNode.attributedText = formattedText(strings.Conversation_OpenBotLinkLogin(self.domain, self.displayName).string, color: theme.primaryColor) self.allowWriteLabelNode.attributedText = formattedText(strings.Conversation_OpenBotLinkAllowMessages(self.bot.displayTitle(strings: self.strings, displayOrder: self.nameDisplayOrder)).string, color: theme.primaryColor) + self.titleNode.accessibilityLabel = self.titleNode.attributedText?.string + self.textNode.isAccessibilityElement = true + self.textNode.accessibilityLabel = self.textNode.attributedText?.string + self.authorizeLabelNode.accessibilityLabel = self.authorizeLabelNode.attributedText?.string + self.allowWriteLabelNode.accessibilityLabel = self.allowWriteLabelNode.attributedText?.string + self.updateOptionAccessibility() self.actionNodesSeparator.backgroundColor = theme.separatorColor for actionNode in self.actionNodes { @@ -176,6 +220,26 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { _ = self.updateLayout(size: size, transition: .immediate) } } + + private func updateOptionAccessibility() { + self.authorizeLabelNode.accessibilityTraits = self.authorize ? [.button, .selected] : [.button] + var allowWriteTraits: UIAccessibilityTraits = [.button] + if self.allowWriteAccess { + allowWriteTraits.insert(.selected) + } + if !self.authorize { + allowWriteTraits.insert(.notEnabled) + } + self.allowWriteLabelNode.accessibilityTraits = allowWriteTraits + } + + override func contentSizeCategoryUpdated() { + self.updateTheme(self.theme) + for actionNode in self.actionNodes { + actionNode.updateTheme(self.theme) + } + self.requestLayout?(.immediate) + } override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { var size = size @@ -200,8 +264,11 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { var entriesHeight: CGFloat = 0.0 let authorizeSize = self.authorizeLabelNode.measure(condensedSize) - transition.updateFrame(node: self.authorizeLabelNode, frame: CGRect(origin: CGPoint(x: 46.0, y: origin.y), size: authorizeSize)) - transition.updateFrame(node: self.authorizeCheckNode, frame: CGRect(origin: CGPoint(x: 12.0, y: origin.y - 2.0), size: checkSize)) + let authorizeLabelFrame = CGRect(origin: CGPoint(x: 46.0, y: origin.y), size: authorizeSize) + let authorizeCheckFrame = CGRect(origin: CGPoint(x: 12.0, y: origin.y - 2.0), size: checkSize) + transition.updateFrame(node: self.authorizeLabelNode, frame: authorizeLabelFrame) + transition.updateFrame(node: self.authorizeCheckNode, frame: authorizeCheckFrame) + self.authorizeLabelNode.view.accessibilityFrameInContainerSpace = authorizeLabelFrame.union(authorizeCheckFrame) origin.y += authorizeSize.height entriesHeight += authorizeSize.height @@ -210,21 +277,27 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { entriesHeight += 16.0 let allowWriteSize = self.allowWriteLabelNode.measure(condensedSize) - transition.updateFrame(node: self.allowWriteLabelNode, frame: CGRect(origin: CGPoint(x: 46.0, y: origin.y), size: allowWriteSize)) - transition.updateFrame(node: self.allowWriteCheckNode, frame: CGRect(origin: CGPoint(x: 12.0, y: origin.y - 2.0), size: checkSize)) + let allowWriteLabelFrame = CGRect(origin: CGPoint(x: 46.0, y: origin.y), size: allowWriteSize) + let allowWriteCheckFrame = CGRect(origin: CGPoint(x: 12.0, y: origin.y - 2.0), size: checkSize) + transition.updateFrame(node: self.allowWriteLabelNode, frame: allowWriteLabelFrame) + transition.updateFrame(node: self.allowWriteCheckNode, frame: allowWriteCheckFrame) + self.allowWriteLabelNode.view.accessibilityFrameInContainerSpace = allowWriteLabelFrame.union(allowWriteCheckFrame) origin.y += allowWriteSize.height entriesHeight += allowWriteSize.height } - let actionButtonHeight: CGFloat = 44.0 + let minimumActionButtonHeight: CGFloat = 44.0 var minActionsWidth: CGFloat = 0.0 - let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count)) + let maxActionWidth: CGFloat = self.actionNodes.isEmpty ? size.width : floor(size.width / CGFloat(self.actionNodes.count)) let actionTitleInsets: CGFloat = 8.0 - var effectiveActionLayout = TextAlertContentActionLayout.horizontal + var effectiveActionLayout: TextAlertContentActionLayout = self.traitCollection.preferredContentSizeCategory.isAccessibilityCategory ? .vertical : .horizontal + 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 { @@ -243,9 +316,9 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { 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 resultWidth = contentWidth + insets.left + insets.right @@ -254,7 +327,7 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { transition.updateFrame(node: 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 { @@ -284,11 +357,12 @@ private final class ChatMessageActionUrlAuthAlertContentNode: 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) From 41b9bd32dc4944847940755569dd73df04c76b53 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:12:03 +0500 Subject: [PATCH 06/11] Improve VoiceOver reply navigation and peer info actions - add an accessible action to navigate from a reply to its original message - preserve and restore VoiceOver focus during reply navigation - distinguish interactive accessibility areas for Voice Control - expose functional VoiceOver activation across peer info rows - add contact, address, business hours, and personal channel semantics --- .../Source/AccessibilityAreaNode.swift | 35 +++++++++++- .../Sources/ChatMessageItemView.swift | 14 ++++- .../Sources/ChatControllerInteraction.swift | 1 + .../ListItems/PeerInfoScreenActionItem.swift | 10 ++++ .../ListItems/PeerInfoScreenAddressItem.swift | 12 ++++ .../PeerInfoScreenBusinessHoursItem.swift | 45 +++++++++++++++ .../PeerInfoScreenCommunityItem.swift | 5 ++ .../PeerInfoScreenContactInfoItem.swift | 56 ++++++++++++++++++- .../PeerInfoScreenDisclosureItem.swift | 10 ++++ .../PeerInfoScreenLabeledValueItem.swift | 10 ++++ .../PeerInfoScreenPersonalChannelItem.swift | 11 ++++ .../ListItems/PeerInfoScreenSwitchItem.swift | 5 +- .../Sources/ChatHistoryListNode.swift | 21 ++++++- 13 files changed, 226 insertions(+), 9 deletions(-) diff --git a/submodules/Display/Source/AccessibilityAreaNode.swift b/submodules/Display/Source/AccessibilityAreaNode.swift index 5975b2fa319..d7d35607524 100644 --- a/submodules/Display/Source/AccessibilityAreaNode.swift +++ b/submodules/Display/Source/AccessibilityAreaNode.swift @@ -7,9 +7,21 @@ public protocol AccessibilityFocusableNode { } public final class AccessibilityAreaNode: ASDisplayNode { - public var activate: (() -> Bool)? - public var increment: (() -> Void)? - public var decrement: (() -> Void)? + public var activate: (() -> Bool)? { + didSet { + self.updateRespondsToUserInteraction() + } + } + public var increment: (() -> Void)? { + didSet { + self.updateRespondsToUserInteraction() + } + } + public var decrement: (() -> Void)? { + didSet { + self.updateRespondsToUserInteraction() + } + } public var focused: (() -> Void)? override public init() { @@ -17,6 +29,23 @@ public final class AccessibilityAreaNode: ASDisplayNode { self.isAccessibilityElement = true } + + override public func didLoad() { + super.didLoad() + + self.updateRespondsToUserInteraction() + } + + private func updateRespondsToUserInteraction() { + if self.isNodeLoaded { + self.view.accessibilityRespondsToUserInteraction = self.activate != nil + || self.increment != nil + || self.decrement != nil + || self.accessibilityTraits.contains(.button) + || self.accessibilityTraits.contains(.link) + || self.accessibilityTraits.contains(.adjustable) + } + } override public func accessibilityActivate() -> Bool { return self.activate?() ?? false diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift index 71263876310..1ef092933d7 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift @@ -66,6 +66,7 @@ private let fileSizeFormatter: ByteCountFormatter = { public enum ChatMessageAccessibilityCustomActionType { case reply + case navigateToReply(EngineMessage.Id) case react case options case copy @@ -514,6 +515,7 @@ public final class ChatMessageAccessibilityData { var (label, value) = dataForMessage(item.message, false) var replyValue: String? + var replyMessageId: EngineMessage.Id? for attribute in item.message.attributes { if let attribute = attribute as? TextEntitiesMessageAttribute { @@ -542,7 +544,11 @@ public final class ChatMessageAccessibilityData { break } } - } else if let attribute = attribute as? ReplyMessageAttribute, let replyMessage = item.message.associatedMessages[attribute.messageId] { + } else if let attribute = attribute as? ReplyMessageAttribute { + replyMessageId = attribute.messageId + guard let replyMessage = item.message.associatedMessages[attribute.messageId] else { + continue + } var replyLabel: String if replyMessage.flags.contains(.Incoming) { if let author = replyMessage.author { @@ -605,6 +611,9 @@ public final class ChatMessageAccessibilityData { if canReply { customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.VoiceOver_MessageContextReply, target: nil, selector: #selector(self.noop), action: .reply)) } + if let replyMessageId { + customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.VoiceOver_Chat_GoToOriginalMessage, target: nil, selector: #selector(self.noop), action: .navigateToReply(replyMessageId))) + } if canAddMessageReactions(message: EngineMessage(item.message)) { customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.MediaEditor_Shortcut_Reaction, target: nil, selector: #selector(self.noop), action: .react)) } @@ -752,6 +761,9 @@ open class ChatMessageItemView: ListViewItemNode, ChatMessageItemNodeProtocol { switch action.action { case .reply: item.controllerInteraction.setupReply(item.message.id) + case let .navigateToReply(messageId): + item.controllerInteraction.accessibilityNavigationTargetMessageId = messageId + item.controllerInteraction.navigateToMessage(item.message.id, messageId, NavigateToMessageParams(timestamp: nil, quote: nil)) case .react: item.controllerInteraction.updateMessageReaction(item.message, .default, false, nil) case .options: diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index 9a11653344f..74b28aa7394 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -281,6 +281,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol public let accessibilityForwardMessage: (EngineRawMessage) -> Void public let accessibilityDeleteMessage: (EngineRawMessage) -> Void public let canPerformAccessibilityMessageActions: Bool + public var accessibilityNavigationTargetMessageId: EngineMessage.Id? public let displayUndo: (UndoOverlayContent) -> Void public let isAnimatingMessage: (UInt32) -> Bool public let getMessageTransitionNode: () -> ChatMessageTransitionProtocol? diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenActionItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenActionItem.swift index 64ab4e9b1d1..872e6ae9e6a 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenActionItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenActionItem.swift @@ -100,6 +100,16 @@ private final class PeerInfoScreenActionItemNode: PeerInfoScreenItemNode { self.item = item self.selectionNode.pressed = item.action + if let action = item.action { + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { + action() + return true + } + } else { + self.activateArea.accessibilityTraits = [.staticText] + self.activateArea.activate = nil + } let sideInset: CGFloat = 16.0 + safeInsets.left var leftInset = (item.icon == nil && item.iconSignal == nil ? sideInset : sideInset + 29.0 + 16.0) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenAddressItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenAddressItem.swift index c67a082d945..46f875cfa6c 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenAddressItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenAddressItem.swift @@ -177,6 +177,17 @@ private final class PeerInfoScreenAddressItemNode: PeerInfoScreenItemNode { self.item = item self.presentationData = presentationData + + if let action = item.action { + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { + action() + return true + } + } else { + self.activateArea.accessibilityTraits = [.staticText] + self.activateArea.activate = nil + } self.containerNode.isGestureEnabled = item.contextAction != nil @@ -231,6 +242,7 @@ private final class PeerInfoScreenAddressItemNode: PeerInfoScreenItemNode { self.activateArea.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: height)) self.activateArea.accessibilityLabel = item.label + self.activateArea.accessibilityValue = item.text let contentSize = CGSize(width: width, height: height) self.containerNode.frame = CGRect(origin: CGPoint(), size: contentSize) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenBusinessHoursItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenBusinessHoursItem.swift index a4c4781af56..817af7543a4 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenBusinessHoursItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenBusinessHoursItem.swift @@ -14,6 +14,15 @@ import BundleIconComponent import PlainButtonComponent import AccountContext +private final class PeerInfoBusinessHoursAccessibilityAction: 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) + } +} + func businessHoursTextToCopy(businessHours: TelegramBusinessHours, presentationData: PresentationData, displayLocalTimezone: Bool) -> String { var text = "" @@ -297,6 +306,15 @@ private final class PeerInfoScreenBusinessHoursItemNode: PeerInfoScreenItemNode self.item = item self.presentationData = presentationData self.theme = presentationData.theme + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { [weak self] in + guard let self else { + return false + } + self.isExpanded = !self.isExpanded + self.item?.requestLayout(true) + return true + } self.containerNode.isGestureEnabled = item.contextAction != nil @@ -445,6 +463,7 @@ private final class PeerInfoScreenBusinessHoursItemNode: PeerInfoScreenItemNode } var timezoneSwitchButtonSize: CGSize? + var accessibilityTimezoneSwitchTitle: String? if hasTimezoneDependentEntries { let timezoneSwitchButton: ComponentView if let current = self.timezoneSwitchButton { @@ -459,6 +478,7 @@ private final class PeerInfoScreenBusinessHoursItemNode: PeerInfoScreenItemNode } else { timezoneSwitchTitle = presentationData.strings.PeerInfo_BusinessHours_TimezoneSwitchBusiness } + accessibilityTimezoneSwitchTitle = timezoneSwitchTitle timezoneSwitchButtonSize = timezoneSwitchButton.update( transition: .immediate, component: AnyComponent(PlainButtonComponent( @@ -651,6 +671,23 @@ private final class PeerInfoScreenBusinessHoursItemNode: PeerInfoScreenItemNode self.activateArea.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: height)) self.activateArea.accessibilityLabel = item.label + self.activateArea.accessibilityValue = businessHoursTextToCopy(businessHours: item.businessHours, presentationData: presentationData, displayLocalTimezone: self.displayLocalTimezone) + if let accessibilityTimezoneSwitchTitle { + self.activateArea.accessibilityCustomActions = [ + PeerInfoBusinessHoursAccessibilityAction(name: accessibilityTimezoneSwitchTitle, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { [weak self] in + guard let self else { + return + } + self.displayLocalTimezone = !self.displayLocalTimezone + if !self.isExpanded { + self.isExpanded = true + } + self.item?.requestLayout(true) + }) + ] + } else { + self.activateArea.accessibilityCustomActions = nil + } let contentSize = CGSize(width: width, height: height) self.containerNode.frame = CGRect(origin: CGPoint(), size: contentSize) @@ -671,6 +708,14 @@ private final class PeerInfoScreenBusinessHoursItemNode: PeerInfoScreenItemNode return height } + + @objc private func performAccessibilityAction(_ action: UIAccessibilityCustomAction) -> Bool { + guard let action = action as? PeerInfoBusinessHoursAccessibilityAction else { + return false + } + action.perform() + return true + } private func updateTouchesAtPoint(_ point: CGPoint?) { } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenCommunityItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenCommunityItem.swift index 4ccf64b2ea6..89cfdf443b2 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenCommunityItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenCommunityItem.swift @@ -101,6 +101,11 @@ private final class PeerInfoScreenCommunityItemNode: PeerInfoScreenItemNode { self.item = item self.selectionNode.pressed = item.action + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { + item.action() + return true + } let sideInset: CGFloat = 16.0 + safeInsets.left let avatarSize: CGFloat = 30.0 diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenContactInfoItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenContactInfoItem.swift index a8313a54f2e..5b538a4e2a2 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenContactInfoItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenContactInfoItem.swift @@ -8,6 +8,15 @@ import AppBundle import TelegramStringFormatting import ContextUI +private final class PeerInfoContactAccessibilityAction: 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 PeerInfoScreenContactInfoItem: PeerInfoScreenItem { let id: AnyHashable let username: String @@ -244,6 +253,34 @@ private final class PeerInfoScreenContactInfoItemNode: PeerInfoScreenItemNode { self.item = item self.theme = presentationData.theme + + var accessibilityActions: [UIAccessibilityCustomAction] = [] + if let usernameAction = item.usernameAction, !item.username.isEmpty { + accessibilityActions.append(PeerInfoContactAccessibilityAction(name: item.username, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { [weak self] in + guard let self else { + return + } + usernameAction(self.contextSourceNode) + })) + } + if let phoneAction = item.phoneAction, !item.phoneNumber.isEmpty { + accessibilityActions.append(PeerInfoContactAccessibilityAction(name: item.phoneNumber, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { [weak self] in + guard let self else { + return + } + phoneAction(self.contextSourceNode) + })) + } + self.activateArea.accessibilityCustomActions = accessibilityActions.isEmpty ? nil : accessibilityActions + self.activateArea.accessibilityTraits = accessibilityActions.isEmpty ? [.staticText] : [.button] + if let primaryAction = accessibilityActions.first as? PeerInfoContactAccessibilityAction { + self.activateArea.activate = { + primaryAction.perform() + return true + } + } else { + self.activateArea.activate = nil + } // if let action = item.action { // self.selectionNode.pressed = { [weak self] in @@ -324,8 +361,15 @@ private final class PeerInfoScreenContactInfoItemNode: PeerInfoScreenItemNode { self.bottomSeparatorNode.isHidden = hasBottomCorners self.activateArea.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: height)) - self.activateArea.accessibilityLabel = item.username - self.activateArea.accessibilityValue = item.phoneNumber + self.activateArea.accessibilityLabel = item.username.isEmpty ? item.phoneNumber : item.username + var accessibilityValues: [String] = [] + if !item.username.isEmpty && !item.phoneNumber.isEmpty { + accessibilityValues.append(item.phoneNumber) + } + if let additionalText = item.additionalText, !additionalText.isEmpty { + accessibilityValues.append(additionalText) + } + self.activateArea.accessibilityValue = accessibilityValues.isEmpty ? nil : accessibilityValues.joined(separator: ". ") let contentSize = CGSize(width: width, height: height) self.containerNode.frame = CGRect(origin: CGPoint(), size: contentSize) @@ -347,6 +391,14 @@ private final class PeerInfoScreenContactInfoItemNode: PeerInfoScreenItemNode { return height } + + @objc private func performAccessibilityAction(_ action: UIAccessibilityCustomAction) -> Bool { + guard let action = action as? PeerInfoContactAccessibilityAction else { + return false + } + action.perform() + return true + } private func updateTouchesAtPoint(_ point: CGPoint?) { guard let _ = self.item, let theme = self.theme else { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureItem.swift index f788f65067d..9f0f3ca89c4 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureItem.swift @@ -152,6 +152,16 @@ private final class PeerInfoScreenDisclosureItemNode: PeerInfoScreenItemNode { self.item = item self.selectionNode.pressed = item.action + if let action = item.action { + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { + action() + return true + } + } else { + self.activateArea.accessibilityTraits = [.staticText] + self.activateArea.activate = nil + } let sideInset: CGFloat = 16.0 + safeInsets.left let leftInset = (item.icon == nil && item.iconSignal == nil ? sideInset : sideInset + 29.0 + 16.0) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenLabeledValueItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenLabeledValueItem.swift index 37d162fdaa8..9a675ae6c90 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenLabeledValueItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenLabeledValueItem.swift @@ -484,8 +484,18 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { action(strongSelf.contextSourceNode, nil) } } + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { [weak self] in + guard let self else { + return false + } + action(self.contextSourceNode, nil) + return true + } } else { self.selectionNode.pressed = nil + self.activateArea.accessibilityTraits = [.staticText] + self.activateArea.activate = nil } let sideInset: CGFloat = 16.0 + safeInsets.left diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenPersonalChannelItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenPersonalChannelItem.swift index 1bf164df4ea..e539bec5076 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenPersonalChannelItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenPersonalChannelItem.swift @@ -430,6 +430,17 @@ private final class PeerInfoScreenPersonalChannelItemNode: PeerInfoScreenItemNod self.item = item self.presentationData = presentationData self.theme = presentationData.theme + self.activateArea.isAccessibilityElement = !item.data.isLoading + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { + item.action() + return true + } + if let peer = item.data.peer.chatMainPeer { + self.activateArea.accessibilityLabel = EnginePeer(peer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + } else { + self.activateArea.accessibilityLabel = nil + } self.selectionNode.pressed = { [weak self] in if let strongSelf = self { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenSwitchItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenSwitchItem.swift index 10cff7c74cc..cd2f9909a0c 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenSwitchItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenSwitchItem.swift @@ -86,7 +86,7 @@ private final class PeerInfoScreenSwitchItemNode: PeerInfoScreenItemNode { guard let strongSelf = self, let item = strongSelf.item else { return false } - let value = !strongSelf.switchNode.isOn + let value = item.isLocked ? strongSelf.switchNode.isOn : !strongSelf.switchNode.isOn item.toggled?(value) return true } @@ -163,7 +163,8 @@ private final class PeerInfoScreenSwitchItemNode: PeerInfoScreenItemNode { self.activateArea.accessibilityLabel = item.text self.activateArea.accessibilityValue = item.value ? presentationData.strings.VoiceOver_Common_On : presentationData.strings.VoiceOver_Common_Off - self.activateArea.accessibilityHint = presentationData.strings.VoiceOver_Common_SwitchHint + self.activateArea.accessibilityHint = item.isLocked ? nil : presentationData.strings.VoiceOver_Common_SwitchHint + self.activateArea.accessibilityTraits = [.button] let textSize = self.textNode.updateLayout(CGSize(width: width - leftInset - rightInset, height: .greatestFiniteMagnitude)) let textFrame = CGRect(origin: CGPoint(x: leftInset, y: 16.0), size: textSize) diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index c1b8580fda5..5ac5770692a 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -3874,6 +3874,7 @@ public final class ChatHistoryListNodeImpl: ASDisplayNode, ChatHistoryNode, Chat self.hasActiveTransition = true let transition = self.enqueuedHistoryViewTransitions.removeFirst() + let accessibilityNavigationTargetMessageId = UIAccessibility.isVoiceOverRunning ? self.controllerInteraction.accessibilityNavigationTargetMessageId : nil var accessibilityFocusedMessageId: MessageId? if UIAccessibility.isVoiceOverRunning { self.forEachVisibleMessageItemNode { itemNode in @@ -4439,7 +4440,25 @@ public final class ChatHistoryListNodeImpl: ASDisplayNode, ChatHistoryNode, Chat strongSelf.hasActiveTransition = false - if let accessibilityFocusedMessageId { + if let accessibilityNavigationTargetMessageId { + var didRestoreAccessibilityNavigationTarget = false + strongSelf.forEachVisibleMessageItemNode { itemNode in + if let item = itemNode.item, item.content.contains(where: { $0.0.id == accessibilityNavigationTargetMessageId }) { + didRestoreAccessibilityNavigationTarget = true + if strongSelf.controllerInteraction.accessibilityNavigationTargetMessageId == accessibilityNavigationTargetMessageId { + strongSelf.controllerInteraction.accessibilityNavigationTargetMessageId = nil + } + itemNode.restoreAccessibilityFocus() + } + } + if !didRestoreAccessibilityNavigationTarget, let accessibilityFocusedMessageId { + strongSelf.forEachVisibleMessageItemNode { itemNode in + if let item = itemNode.item, item.content.contains(where: { $0.0.id == accessibilityFocusedMessageId }) { + itemNode.restoreAccessibilityFocus() + } + } + } + } else if let accessibilityFocusedMessageId { strongSelf.forEachVisibleMessageItemNode { itemNode in if let item = itemNode.item, item.content.contains(where: { $0.0.id == accessibilityFocusedMessageId }) { itemNode.restoreAccessibilityFocus() From 2cff1ba8883502765e90562df4f97d26aac153c2 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:24:42 +0500 Subject: [PATCH 07/11] Improve VoiceOver accessibility for peer info members and header Add accessible activation and custom member actions to peer info rows. Improve encryption key, interactive subtitle, hidden status badge, and edit photo semantics while avoiding duplicate accessibility elements. --- ...nfoScreenDisclosureEncryptionKeyItem.swift | 16 +++++ .../ListItems/PeerInfoScreenMemberItem.swift | 58 +++++++++++++++++++ .../PeerInfoHeaderEditingContentNode.swift | 2 + .../Sources/PeerInfoHeaderNode.swift | 4 ++ .../Sources/PeerInfoSubtitleBadgeView.swift | 6 ++ 5 files changed, 86 insertions(+) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureEncryptionKeyItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureEncryptionKeyItem.swift index 4a942dc0bec..224797a7db7 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureEncryptionKeyItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureEncryptionKeyItem.swift @@ -32,6 +32,7 @@ private final class PeerInfoScreenDisclosureEncryptionKeyItemNode: PeerInfoScree private let arrowNode: ASImageNode private let bottomSeparatorNode: ASDisplayNode private let maskNode: ASImageNode + private let activateArea: AccessibilityAreaNode private var item: PeerInfoScreenDisclosureEncryptionKeyItem? @@ -59,6 +60,8 @@ private final class PeerInfoScreenDisclosureEncryptionKeyItemNode: PeerInfoScree self.maskNode = ASImageNode() self.maskNode.isUserInteractionEnabled = false + + self.activateArea = AccessibilityAreaNode() super.init() @@ -72,6 +75,7 @@ private final class PeerInfoScreenDisclosureEncryptionKeyItemNode: PeerInfoScree self.addSubnode(self.keyNode) self.addSubnode(self.arrowNode) self.addSubnode(self.maskNode) + self.addSubnode(self.activateArea) } override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat { @@ -86,6 +90,17 @@ private final class PeerInfoScreenDisclosureEncryptionKeyItemNode: PeerInfoScree self.item = item self.selectionNode.pressed = item.action + self.activateArea.accessibilityLabel = item.text + if let action = item.action { + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { + action() + return true + } + } else { + self.activateArea.accessibilityTraits = [.staticText] + self.activateArea.activate = nil + } let sideInset: CGFloat = 16.0 + safeInsets.left @@ -128,6 +143,7 @@ private final class PeerInfoScreenDisclosureEncryptionKeyItemNode: PeerInfoScree transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: sideInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset, height: UIScreenPixel))) transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0) + self.activateArea.frame = CGRect(origin: .zero, size: CGSize(width: width, height: height)) return height } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift index b6527b86b1c..dfea2cd23b1 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift @@ -9,6 +9,15 @@ import AccountContext import TelegramCore import ItemListUI +private final class PeerInfoMemberAccessibilityAction: 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) + } +} + enum PeerInfoScreenMemberItemAction { case open case promote @@ -58,6 +67,7 @@ private final class PeerInfoScreenMemberItemNode: PeerInfoScreenItemNode { private let selectionNode: PeerInfoScreenSelectableBackgroundNode private let maskNode: ASImageNode private let bottomSeparatorNode: ASDisplayNode + private let activateArea: AccessibilityAreaNode private var item: PeerInfoScreenMemberItem? private var itemNode: ItemListPeerItemNode? @@ -72,6 +82,8 @@ private final class PeerInfoScreenMemberItemNode: PeerInfoScreenItemNode { self.bottomSeparatorNode = ASDisplayNode() self.bottomSeparatorNode.isLayerBacked = true + + self.activateArea = AccessibilityAreaNode() super.init() @@ -81,6 +93,7 @@ private final class PeerInfoScreenMemberItemNode: PeerInfoScreenItemNode { self.addSubnode(self.bottomSeparatorNode) self.addSubnode(self.selectionNode) + self.addSubnode(self.activateArea) } override func didLoad() { @@ -258,6 +271,42 @@ private final class PeerInfoScreenMemberItemNode: PeerInfoScreenItemNode { } itemNode.visibility = .visible(1.0, .infinite) + itemNode.isAccessibilityElement = false + + self.activateArea.accessibilityLabel = itemNode.accessibilityLabel + self.activateArea.accessibilityValue = itemNode.accessibilityValue + if let action = item.action { + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { + action(.open) + return true + } + } else { + self.activateArea.accessibilityTraits = [.staticText] + self.activateArea.activate = nil + } + + var accessibilityActions: [UIAccessibilityCustomAction] = [] + if let memberAction = item.action, actions.contains(.promote), case .channel = item.enclosingPeer { + accessibilityActions.append(PeerInfoMemberAccessibilityAction(name: presentationData.strings.GroupInfo_ActionPromote, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { + memberAction(.promote) + })) + } + if let memberAction = item.action, actions.contains(.restrict), case .channel = item.enclosingPeer { + accessibilityActions.append(PeerInfoMemberAccessibilityAction(name: presentationData.strings.GroupInfo_ActionRestrict, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { + memberAction(.restrict) + })) + } + if let memberAction = item.action, actions.contains(.restrict) { + accessibilityActions.append(PeerInfoMemberAccessibilityAction(name: presentationData.strings.Common_Delete, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { + memberAction(.remove) + })) + } else if let memberAction = item.action, actions.contains(.logout) { + accessibilityActions.append(PeerInfoMemberAccessibilityAction(name: presentationData.strings.Settings_Context_Logout, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { + memberAction(.remove) + })) + } + self.activateArea.accessibilityCustomActions = accessibilityActions.isEmpty ? nil : accessibilityActions let height = itemNode.contentSize.height @@ -279,6 +328,7 @@ private final class PeerInfoScreenMemberItemNode: PeerInfoScreenItemNode { let highlightNodeOffset: CGFloat = topItem == nil ? 0.0 : UIScreenPixel self.selectionNode.update(size: CGSize(width: width, height: height + highlightNodeOffset), theme: presentationData.theme, transition: transition) transition.updateFrame(node: self.selectionNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -highlightNodeOffset), size: CGSize(width: width, height: height + highlightNodeOffset))) + self.activateArea.frame = CGRect(origin: .zero, size: CGSize(width: width, height: height)) var separatorInset: CGFloat = sideInset if bottomItem != nil { @@ -292,6 +342,14 @@ private final class PeerInfoScreenMemberItemNode: PeerInfoScreenItemNode { return height } + + @objc private func performAccessibilityAction(_ action: UIAccessibilityCustomAction) -> Bool { + guard let action = action as? PeerInfoMemberAccessibilityAction else { + return false + } + action.perform() + return true + } private func updateTouchesAtPoint(_ point: CGPoint?) { guard let item = self.item else { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift index 8a704e1cb13..950aaac88c9 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift @@ -27,6 +27,8 @@ final class PeerInfoHeaderEditingContentNode: ASDisplayNode { self.avatarTextNode = ImmediateTextNode() self.avatarButtonNode = HighlightableButtonNode() + self.avatarButtonNode.isAccessibilityElement = true + self.avatarButtonNode.accessibilityTraits = [.button] super.init() diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift index fdfd1cee79e..15b320a8e52 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift @@ -1382,6 +1382,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { subtitleNodeLayout = self.subtitleNode.updateLayout(text: subtitleStringText, states: subtitleStates, mainState: TitleNodeStateRegular) } self.subtitleNode.accessibilityLabel = subtitleStringText + self.subtitleNode.isAccessibilityElement = !subtitleIsButton var subtitleButtonHorizontalOffset: CGFloat = 0.0 if subtitleIsButton { @@ -1401,6 +1402,8 @@ final class PeerInfoHeaderNode: ASDisplayNode { subtitleBackgroundButton = HighlightTrackingButtonNode() self.subtitleBackgroundButton = subtitleBackgroundButton self.subtitleNode.addSubnode(subtitleBackgroundButton) + subtitleBackgroundButton.isAccessibilityElement = true + subtitleBackgroundButton.accessibilityTraits = [.button] subtitleBackgroundButton.addTarget(self, action: #selector(self.subtitleBackgroundPressed), forControlEvents: .touchUpInside) subtitleBackgroundButton.highligthedChanged = { [weak self] highlighted in @@ -1416,6 +1419,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { } } } + subtitleBackgroundButton.accessibilityLabel = subtitleStringText let subtitleArrowNode: ASImageNode if let current = self.subtitleArrowNode { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSubtitleBadgeView.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSubtitleBadgeView.swift index 0e194b6af97..94332864224 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSubtitleBadgeView.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSubtitleBadgeView.swift @@ -20,6 +20,9 @@ final class PeerInfoSubtitleBadgeView: HighlightTrackingButton { self.backgroundView.isUserInteractionEnabled = false super.init(frame: CGRect()) + + self.isAccessibilityElement = true + self.accessibilityTraits = [.button] self.addSubview(self.backgroundView) @@ -64,6 +67,8 @@ final class PeerInfoSubtitleBadgeView: HighlightTrackingButton { } func update(title: String, fillColor: UIColor, foregroundColor: UIColor) -> CGSize { + self.accessibilityLabel = title + let labelSize = self.labelView.update( transition: .immediate, component: AnyComponent(Text(text: title, font: Font.regular(11.0), color: foregroundColor)), @@ -80,6 +85,7 @@ final class PeerInfoSubtitleBadgeView: HighlightTrackingButton { if let labelComponentView = self.labelView.view { if labelComponentView.superview == nil { labelComponentView.isUserInteractionEnabled = false + labelComponentView.accessibilityElementsHidden = true self.addSubview(labelComponentView) } labelComponentView.frame = CGRect(origin: CGPoint(x: floor((size.width - labelSize.width) * 0.5), y: floor((size.height - labelSize.height) * 0.5)), size: labelSize) From 2c9ee5661b60c635b09e37a1e8985762974b9abc Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:01:52 +0500 Subject: [PATCH 08/11] Improve VoiceOver accessibility for peer info media panes Add accessible avatar and header semantics, expose Story and shared-media grid items to VoiceOver, preserve focus identity, selection state, traversal order, and context actions, and improve Recommended Peers controls. --- .../Panes/PeerInfoRecommendedPeersPane.swift | 6 +- ...PeerInfoAvatarTransformContainerNode.swift | 21 ++++ .../Sources/PeerInfoEditingAvatarNode.swift | 17 +++ .../Sources/PeerInfoHeaderNode.swift | 51 +++++++++ .../Sources/PeerInfoStoryPaneNode.swift | 99 +++++++++++++++++ .../Sources/PeerInfoVisualMediaPaneNode.swift | 105 ++++++++++++++++++ 6 files changed, 298 insertions(+), 1 deletion(-) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift index c518841d851..2998ab90689 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift @@ -363,12 +363,16 @@ final class PeerInfoRecommendedPeersPaneNode: ASDisplayNode, PeerInfoPaneNode { unlockButton.animationLoopTime = 2.5 unlockButton.animation = "premium_unlock" unlockButton.iconPosition = .right - unlockButton.title = isBots ? presentationData.strings.PeerInfo_SimilarBots_ShowMore : presentationData.strings.Channel_SimilarChannels_ShowMore unlockButton.pressed = { [weak self] in self?.unlockPressed() } } + let unlockTitle = isBots ? presentationData.strings.PeerInfo_SimilarBots_ShowMore : presentationData.strings.Channel_SimilarChannels_ShowMore + unlockButton.title = unlockTitle + unlockButton.isAccessibilityElement = true + unlockButton.accessibilityLabel = unlockTitle + unlockButton.accessibilityTraits = [.button] if themeUpdated { let topColor = presentationData.theme.list.plainBackgroundColor.withAlphaComponent(0.0) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift index 89dc09d0d02..30308bb378e 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift @@ -24,6 +24,7 @@ final class PeerInfoAvatarTransformContainerNode: ASDisplayNode { let containerNode: ContextControllerSourceNode let avatarNode: AvatarNode + private let accessibilityArea: AccessibilityAreaNode private(set) var avatarStoryView: ComponentView? var videoNode: UniversalVideoNode? var markupNode: AvatarVideoNode? @@ -60,13 +61,18 @@ final class PeerInfoAvatarTransformContainerNode: ASDisplayNode { let avatarFont = avatarPlaceholderFont(size: floor(100.0 * 16.0 / 37.0)) self.avatarNode = AvatarNode(font: avatarFont) + self.accessibilityArea = AccessibilityAreaNode() super.init() self.addSubnode(self.containerNode) self.containerNode.addSubnode(self.avatarNode) + self.containerNode.addSubnode(self.accessibilityArea) self.containerNode.frame = CGRect(origin: CGPoint(x: -50.0, y: -50.0), size: CGSize(width: 100.0, height: 100.0)) self.avatarNode.frame = self.containerNode.bounds + self.accessibilityArea.frame = self.containerNode.bounds + self.accessibilityArea.accessibilityTraits = [.button, .image] + self.avatarNode.isAccessibilityElement = false let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) self.avatarNode.view.addGestureRecognizer(tapGestureRecognizer) @@ -252,8 +258,22 @@ final class PeerInfoAvatarTransformContainerNode: ASDisplayNode { func update(peer: EnginePeer?, threadId: Int64?, threadInfo: EngineMessageHistoryThread.Info?, item: PeerInfoAvatarListItem?, theme: PresentationTheme, avatarSize: CGFloat, isExpanded: Bool, isSettings: Bool) { self.params = Params(peer: peer, threadId: threadId, threadInfo: threadInfo, item: item, theme: theme, avatarSize: avatarSize, isExpanded: isExpanded, isSettings: isSettings) + self.accessibilityArea.isAccessibilityElement = peer != nil if let peer = peer { + self.accessibilityArea.accessibilityLabel = threadInfo?.title ?? peer.compactDisplayTitle + self.accessibilityArea.activate = { [weak self] in + guard let self else { + return false + } + if threadInfo != nil { + self.emojiTapped?() + } else { + self.tapped?() + } + return true + } + let previousItem = self.item var item = item self.item = item @@ -343,6 +363,7 @@ final class PeerInfoAvatarTransformContainerNode: ASDisplayNode { self.containerNode.frame = CGRect(origin: CGPoint(x: -avatarSize / 2.0, y: -avatarSize / 2.0), size: CGSize(width: avatarSize, height: avatarSize)) self.avatarNode.frame = self.containerNode.bounds + self.accessibilityArea.frame = self.containerNode.bounds self.avatarNode.font = avatarPlaceholderFont(size: floor(avatarSize * 16.0 / 37.0)) if let item = item { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift index 98fc8ba70d5..f9761f0759e 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift @@ -16,6 +16,7 @@ import GalleryUI final class PeerInfoEditingAvatarNode: ASDisplayNode { private let context: AccountContext let avatarNode: AvatarNode + private let accessibilityArea: AccessibilityAreaNode fileprivate var videoNode: UniversalVideoNode? fileprivate var markupNode: AvatarVideoNode? private var videoContent: NativeVideoContent? @@ -30,11 +31,23 @@ final class PeerInfoEditingAvatarNode: ASDisplayNode { self.context = context let avatarFont = avatarPlaceholderFont(size: floor(100.0 * 16.0 / 37.0)) self.avatarNode = AvatarNode(font: avatarFont) + self.accessibilityArea = AccessibilityAreaNode() super.init() self.addSubnode(self.avatarNode) + self.addSubnode(self.accessibilityArea) self.avatarNode.frame = CGRect(origin: CGPoint(x: -50.0, y: -50.0), size: CGSize(width: 100.0, height: 100.0)) + self.accessibilityArea.frame = self.avatarNode.frame + self.accessibilityArea.accessibilityTraits = [.button, .image] + self.accessibilityArea.activate = { [weak self] in + guard let self else { + return false + } + self.tapped?(false) + return true + } + self.avatarNode.isAccessibilityElement = false self.avatarNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } @@ -59,8 +72,11 @@ final class PeerInfoEditingAvatarNode: ASDisplayNode { var removedPhotoResourceIds = Set() func update(peer: EnginePeer?, threadData: MessageHistoryThreadData?, chatLocation: ChatLocation, item: PeerInfoAvatarListItem?, updatingAvatar: PeerInfoUpdatingAvatar?, uploadProgress: AvatarUploadProgress?, theme: PresentationTheme, avatarSize: CGFloat, isEditing: Bool) { guard let peer = peer else { + self.accessibilityArea.isAccessibilityElement = false return } + self.accessibilityArea.isAccessibilityElement = true + self.accessibilityArea.accessibilityLabel = peer.compactDisplayTitle let canEdit = canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData) @@ -86,6 +102,7 @@ final class PeerInfoEditingAvatarNode: ASDisplayNode { self.avatarNode.font = avatarPlaceholderFont(size: floor(avatarSize * 16.0 / 37.0)) self.avatarNode.setPeer(context: self.context, theme: theme, peer: peer, overrideImage: overrideImage, clipStyle: .none, synchronousLoad: false, displayDimensions: CGSize(width: avatarSize, height: avatarSize)) self.avatarNode.frame = CGRect(origin: CGPoint(x: -avatarSize / 2.0, y: -avatarSize / 2.0), size: CGSize(width: avatarSize, height: avatarSize)) + self.accessibilityArea.frame = self.avatarNode.frame var isForum = false let avatarCornerRadius: CGFloat diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift index 15b320a8e52..51823b4fc88 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift @@ -45,6 +45,15 @@ import BundleIconComponent import MarqueeComponent import EdgeEffect +private final class PeerInfoHeaderAccessibilityAction: 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 PeerInfoHeaderNavigationTransition { let sourceNavigationBar: NavigationBar let sourceTitleView: ChatTitleView @@ -1370,6 +1379,13 @@ final class PeerInfoHeaderNode: ASDisplayNode { TitleNodeStateRegular: MultiScaleTextState(attributes: titleAttributes, constrainedSize: titleConstrainedSize), TitleNodeStateExpanded: MultiScaleTextState(attributes: smallTitleAttributes, constrainedSize: titleConstrainedSize) ], mainState: TitleNodeStateRegular) + if let peer, peer.isScam { + self.titleNode.accessibilityLabel = "\(titleStringText), \(presentationData.strings.Message_ScamAccount)" + } else if let peer, peer.isFake { + self.titleNode.accessibilityLabel = "\(titleStringText), \(presentationData.strings.Message_FakeAccount)" + } else { + self.titleNode.accessibilityLabel = titleStringText + } let subtitleStates: [AnyHashable: MultiScaleTextState] = [ TitleNodeStateRegular: MultiScaleTextState(attributes: subtitleAttributes, constrainedSize: titleConstrainedSize), @@ -1383,6 +1399,26 @@ final class PeerInfoHeaderNode: ASDisplayNode { } self.subtitleNode.accessibilityLabel = subtitleStringText self.subtitleNode.isAccessibilityElement = !subtitleIsButton + var subtitleAccessibilityActions: [UIAccessibilityCustomAction] = [] + if self.isSettings, case let .user(user) = peer { + if let phone = user.phone, !phone.isEmpty { + subtitleAccessibilityActions.append(PeerInfoHeaderAccessibilityAction(name: presentationData.strings.Settings_CopyPhoneNumber, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { [weak self] in + guard let self else { + return + } + self.displayCopyContextMenu?(self.subtitleNodeRawContainer, true, false) + })) + } + if let username = user.addressName, !username.isEmpty { + subtitleAccessibilityActions.append(PeerInfoHeaderAccessibilityAction(name: presentationData.strings.Settings_CopyUsername, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { [weak self] in + guard let self else { + return + } + self.displayCopyContextMenu?(self.subtitleNodeRawContainer, false, true) + })) + } + } + self.subtitleNode.accessibilityCustomActions = subtitleAccessibilityActions.isEmpty ? nil : subtitleAccessibilityActions var subtitleButtonHorizontalOffset: CGFloat = 0.0 if subtitleIsButton { @@ -2711,6 +2747,13 @@ final class PeerInfoHeaderNode: ASDisplayNode { musicView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } + musicView.isAccessibilityElement = true + musicView.accessibilityLabel = musicString.string + musicView.accessibilityTraits = [.button] + musicView.accessibilityElementsHidden = false + for subview in musicView.subviews { + subview.accessibilityElementsHidden = true + } if additive { musicTransition.updateFrameAdditiveToCenter(view: musicView, frame: musicFrame) } else { @@ -2760,6 +2803,14 @@ final class PeerInfoHeaderNode: ASDisplayNode { private func actionButtonPressed(_ buttonNode: PeerInfoHeaderActionButtonNode, gesture: ContextGesture?) { self.performButtonAction?(buttonNode.key, nil, gesture) } + + @objc private func performAccessibilityAction(_ action: UIAccessibilityCustomAction) -> Bool { + guard let action = action as? PeerInfoHeaderAccessibilityAction else { + return false + } + action.perform() + return true + } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { var result = super.point(inside: point, with: event) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift index 921303febf3..ce181a144b8 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift @@ -1468,6 +1468,9 @@ private final class StorySearchHeaderComponent: Component { let insets = UIEdgeInsets(top: 7.0, left: 16.0, bottom: 7.0, right: 16.0) let titleString = component.strings.StoryList_GridHeaderLocationSearch(Int32(component.count)) + self.isAccessibilityElement = true + self.accessibilityLabel = titleString + self.accessibilityTraits = [.staticText] let titleSize = self.title.update( transition: .immediate, @@ -1479,6 +1482,7 @@ private final class StorySearchHeaderComponent: Component { ) if let titleView = self.title.view { if titleView.superview == nil { + titleView.accessibilityElementsHidden = true self.addSubview(titleView) } titleView.frame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: titleSize) @@ -1497,6 +1501,27 @@ private final class StorySearchHeaderComponent: Component { } } +private final class StoryGridAccessibilityElement: UIAccessibilityElement { + var activate: () -> Bool = { + return false + } + var openContextMenu: () -> Bool = { + return false + } + + init(accessibilityContainer container: Any) { + super.init(accessibilityContainer: container) + } + + override func accessibilityActivate() -> Bool { + return self.activate() + } + + @objc func accessibilityOpenContextMenu(_ action: UIAccessibilityCustomAction) -> Bool { + return self.openContextMenu() + } +} + public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScrollViewDelegate, ASGestureRecognizerDelegate { public enum Scope { case peer(id: EnginePeer.Id, isSaved: Bool, isArchived: Bool) @@ -1579,6 +1604,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr private let itemGrid: SparseItemGrid private let itemGridBinding: SparseItemGridBindingImpl + private var storyAccessibilityElements: [EngineStoryId: StoryGridAccessibilityElement] = [:] private let directMediaImageCache: DirectMediaImageCache private var items: SparseItemGrid.Items? private var pinnedIds: Set = Set() @@ -2046,6 +2072,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } strongSelf.paneDidScroll?() strongSelf.cancelPreviewGestures() + strongSelf.updateAccessibilityElements() if strongSelf.locationViewState.displayingMapModeOptions { strongSelf.locationViewState.displayingMapModeOptions = false @@ -3500,6 +3527,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } itemLayer.updateSelection(theme: self.itemGridBinding.checkNodeTheme, isSelected: isSelected, animated: animated) } + self.updateAccessibilityElements() var isSelecting = false if let selectedIds = self._itemInteraction?.selectedIds, !selectedIds.isEmpty { @@ -3546,6 +3574,67 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr self.gridSelectionGesture = nil } } + + private func updateAccessibilityElements() { + var accessibilityElements: [UIAccessibilityElement] = [] + var validStoryIds: Set = [] + self.itemGrid.forEachVisibleItem { [weak self] displayItem in + guard let self, let itemLayer = displayItem.layer as? ItemLayer, let item = itemLayer.item, !itemLayer.isHidden else { + return + } + validStoryIds.insert(item.storyId) + + let accessibilityElement: StoryGridAccessibilityElement + if let current = self.storyAccessibilityElements[item.storyId] { + accessibilityElement = current + } else { + accessibilityElement = StoryGridAccessibilityElement(accessibilityContainer: self.itemGrid.view) + self.storyAccessibilityElements[item.storyId] = accessibilityElement + } + accessibilityElement.activate = { [weak self, weak itemLayer] in + guard let self, let itemLayer else { + return false + } + self.itemGridBinding.onTap(item: item, itemLayer: itemLayer, point: CGPoint(x: itemLayer.bounds.midX, y: itemLayer.bounds.midY)) + return true + } + accessibilityElement.openContextMenu = { [weak self, weak itemLayer] in + guard let self, let itemLayer else { + return false + } + let rect = self.itemGrid.frameForItem(layer: itemLayer) + self.openContextMenu(item: item.story, itemLayer: itemLayer, rect: rect, gesture: nil) + return true + } + var label = item.story.media._asMedia() is TelegramMediaFile ? self.presentationData.strings.VoiceOver_Chat_Video : self.presentationData.strings.VoiceOver_Chat_Photo + if let authorPeer = item.authorPeer { + label += ", \(authorPeer.displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder))" + } + accessibilityElement.accessibilityLabel = label + accessibilityElement.accessibilityTraits = [.button, .image] + accessibilityElement.accessibilityCustomActions = [ + UIAccessibilityCustomAction(name: self.presentationData.strings.VoiceOver_MessageContextOpenMessageMenu, target: accessibilityElement, selector: #selector(StoryGridAccessibilityElement.accessibilityOpenContextMenu(_:))) + ] + if self.itemInteraction.selectedIds?.contains(item.story.id) == true { + accessibilityElement.accessibilityTraits.insert(.selected) + accessibilityElement.accessibilityValue = self.presentationData.strings.VoiceOver_Chat_Selected + } + accessibilityElement.accessibilityFrameInContainerSpace = self.itemGrid.frameForItem(layer: itemLayer) + accessibilityElements.append(accessibilityElement) + } + accessibilityElements.sort { lhs, rhs in + let lhsFrame = lhs.accessibilityFrameInContainerSpace + let rhsFrame = rhs.accessibilityFrameInContainerSpace + if abs(lhsFrame.minY - rhsFrame.minY) > UIScreenPixel { + return lhsFrame.minY < rhsFrame.minY + } else { + return lhsFrame.minX < rhsFrame.minX + } + } + self.storyAccessibilityElements = self.storyAccessibilityElements.filter { validStoryIds.contains($0.key) } + self.itemGrid.view.isAccessibilityElement = false + self.itemGrid.view.accessibilityElements = accessibilityElements + } private func updateHiddenItems() { self.itemGrid.forEachVisibleItem { itemValue in @@ -3564,6 +3653,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } } } + self.updateAccessibilityElements() } private func presentDeleteConfirmation(ids: Set) { @@ -4854,6 +4944,9 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr self.itemGrid.pinchEnabled = items.count > 2 && !self.isReordering self.itemGrid.update(size: size, insets: UIEdgeInsets(top: gridTopInset, left: sideInset, bottom: listBottomInset, right: sideInset), useSideInsets: !isList, scrollIndicatorInsets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: bottomInset, right: sideInset), lockScrollingAtTop: isScrollingLockedAtTop, fixedItemHeight: fixedItemHeight, fixedItemAspect: fixedItemAspect, adjustForSmallCount: adjustForSmallCount, items: items, theme: self.itemGridBinding.chatPresentationData.theme.theme, synchronous: wasFirstTime ? .full : .none, transition: animateGridItems ? .spring(duration: 0.35) : .immediate) + DispatchQueue.main.async { [weak self] in + self?.updateAccessibilityElements() + } } self.listBottomInset = listBottomInset @@ -5760,6 +5853,12 @@ private final class BottomActionsPanelComponent: Component { if itemComponenView.superview == nil { self.addSubview(itemComponenView) } + itemComponenView.isAccessibilityElement = true + itemComponenView.accessibilityLabel = item.title + itemComponenView.accessibilityTraits = item.isEnabled ? [.button] : [.button, .notEnabled] + for subview in itemComponenView.subviews { + subview.accessibilityElementsHidden = true + } itemComponenView.frame = itemFrame } } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift index 40b4fd0bf53..2f4de550bc8 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift @@ -1094,6 +1094,27 @@ public protocol PeerInfoScreenNodeProtocol: AnyObject { func displaySharedMediaFastScrollingTooltip() } +private final class VisualMediaGridAccessibilityElement: UIAccessibilityElement { + var activate: () -> Bool = { + return false + } + var openContextMenu: () -> Bool = { + return false + } + + init(accessibilityContainer container: Any) { + super.init(accessibilityContainer: container) + } + + override func accessibilityActivate() -> Bool { + return self.activate() + } + + @objc func accessibilityOpenContextMenu(_ action: UIAccessibilityCustomAction) -> Bool { + return self.openContextMenu() + } +} + public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScrollViewDelegate, ASGestureRecognizerDelegate { public enum ContentType { case photoOrVideo @@ -1134,6 +1155,7 @@ public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, private let contextGestureContainerNode: ContextControllerSourceNode private let itemGrid: SparseItemGrid private let itemGridBinding: SparseItemGridBindingImpl + private var gridAccessibilityElements: [MessageId: VisualMediaGridAccessibilityElement] = [:] private let listBackgroundView: UIImageView private let listMaskView: UIImageView private let directMediaImageCache: DirectMediaImageCache @@ -1352,6 +1374,7 @@ public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, strongSelf.paneDidScroll?() strongSelf.cancelPreviewGestures() + strongSelf.updateGridAccessibilityElements() } self.itemGridBinding.coveringInsetOffsetUpdatedImpl = { [weak self] transition in @@ -1894,6 +1917,83 @@ public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, } return nil } + + private func updateGridAccessibilityElements() { + switch self.contentType { + case .files, .voiceAndVideoMessages, .music: + self.gridAccessibilityElements.removeAll() + self.itemGrid.view.accessibilityElements = nil + return + case .photo, .video, .photoOrVideo, .gifs: + break + } + + var accessibilityElements: [UIAccessibilityElement] = [] + var validMessageIds: Set = [] + self.itemGrid.forEachVisibleItem { [weak self] displayItem in + guard let self, let itemLayer = displayItem.layer as? GenericItemLayer, let item = itemLayer.item, !itemLayer.isHidden else { + return + } + validMessageIds.insert(item.message.id) + + let accessibilityElement: VisualMediaGridAccessibilityElement + if let current = self.gridAccessibilityElements[item.message.id] { + accessibilityElement = current + } else { + accessibilityElement = VisualMediaGridAccessibilityElement(accessibilityContainer: self.itemGrid.view) + self.gridAccessibilityElements[item.message.id] = accessibilityElement + } + accessibilityElement.activate = { [weak self, weak itemLayer] in + guard let self, let itemLayer else { + return false + } + self.itemGridBinding.onTap(item: item, itemLayer: itemLayer, point: CGPoint(x: itemLayer.bounds.midX, y: itemLayer.bounds.midY)) + return true + } + accessibilityElement.openContextMenu = { [weak self, weak itemLayer] in + guard let self, let itemLayer else { + return false + } + let rect = self.itemGrid.frameForItem(layer: itemLayer) + self.chatControllerInteraction.openMessageContextActions(item.message, self, rect, nil) + return true + } + + var isVideo = false + for media in item.message.effectiveMedia { + if let image = media as? TelegramMediaImage, image.video != nil { + isVideo = true + break + } else if let file = media as? TelegramMediaFile, file.isVideo || file.isAnimated { + isVideo = true + break + } + } + accessibilityElement.accessibilityLabel = isVideo ? self.presentationData.strings.VoiceOver_Chat_Video : self.presentationData.strings.VoiceOver_Chat_Photo + accessibilityElement.accessibilityValue = item.message.text.isEmpty ? nil : item.message.text + accessibilityElement.accessibilityTraits = [.button, .image] + accessibilityElement.accessibilityCustomActions = [ + UIAccessibilityCustomAction(name: self.presentationData.strings.VoiceOver_MessageContextOpenMessageMenu, target: accessibilityElement, selector: #selector(VisualMediaGridAccessibilityElement.accessibilityOpenContextMenu(_:))) + ] + if self.chatControllerInteraction.selectionState?.selectedIds.contains(item.message.id) == true { + accessibilityElement.accessibilityTraits.insert(.selected) + } + accessibilityElement.accessibilityFrameInContainerSpace = self.itemGrid.frameForItem(layer: itemLayer) + accessibilityElements.append(accessibilityElement) + } + accessibilityElements.sort { lhs, rhs in + let lhsFrame = lhs.accessibilityFrameInContainerSpace + let rhsFrame = rhs.accessibilityFrameInContainerSpace + if abs(lhsFrame.minY - rhsFrame.minY) > UIScreenPixel { + return lhsFrame.minY < rhsFrame.minY + } else { + return lhsFrame.minX < rhsFrame.minX + } + } + self.gridAccessibilityElements = self.gridAccessibilityElements.filter { validMessageIds.contains($0.key) } + self.itemGrid.view.isAccessibilityElement = false + self.itemGrid.view.accessibilityElements = accessibilityElements + } public func updateHiddenMedia() { self.itemGrid.forEachVisibleItem { item in @@ -1912,6 +2012,7 @@ public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, itemLayer.isHidden = false } } + self.updateGridAccessibilityElements() } public func transferVelocity(_ velocity: CGFloat) { @@ -2173,6 +2274,7 @@ public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, } itemLayer.updateSelection(theme: self.itemGridBinding.checkNodeTheme, isSelected: self.chatControllerInteraction.selectionState?.selectedIds.contains(item.message.id), animated: animated) } + self.updateGridAccessibilityElements() let isSelecting = self.chatControllerInteraction.selectionState != nil self.itemGrid.pinchEnabled = !isSelecting @@ -2285,6 +2387,9 @@ public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, let listSideInset = isList ? sideInset + 16.0 : sideInset self.itemGrid.update(size: size, insets: UIEdgeInsets(top: topInset, left: listSideInset, bottom: bottomInset, right: listSideInset), useSideInsets: !isList, scrollIndicatorInsets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: bottomInset, right: sideInset), lockScrollingAtTop: isScrollingLockedAtTop, fixedItemHeight: fixedItemHeight, fixedItemAspect: nil, items: items, theme: self.itemGridBinding.chatPresentationData.theme.theme, synchronous: wasFirstTime ? .full : .none) + DispatchQueue.main.async { [weak self] in + self?.updateGridAccessibilityElements() + } if let initialMessageIndexValue = self.initialMessageIndex, items.items.contains(where: { item in if let _ = item as? VisualMediaItem { return true From 1e328cb629f6aea05a48e36a1dba50c7be1c396f Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:14:40 +0500 Subject: [PATCH 09/11] Improve VoiceOver behavior in embedded peer info panes Align GIF activation and selection actions with touch behavior, contain accessibility focus during Saved Messages search, and pass the active VoiceOver state to embedded chat and Story map layouts. --- .../Sources/PeerInfoChatListPaneNode.swift | 7 +- .../Sources/PeerInfoChatPaneNode.swift | 2 +- .../Sources/Panes/PeerInfoGifPaneNode.swift | 74 +++++++++++-------- .../Sources/PeerInfoStoryPaneNode.swift | 2 +- 4 files changed, 52 insertions(+), 33 deletions(-) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatListPaneNode/Sources/PeerInfoChatListPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatListPaneNode/Sources/PeerInfoChatListPaneNode.swift index bdc20e8af83..e11b62000fb 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatListPaneNode/Sources/PeerInfoChatListPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatListPaneNode/Sources/PeerInfoChatListPaneNode.swift @@ -414,6 +414,7 @@ public final class PeerInfoChatListPaneNode: ASDisplayNode, PeerInfoPaneNode, AS self.insertSubnode(chatController.displayNode, aboveSubnode: self.chatListNode) chatController.displayNode.alpha = 0.0 chatController.displayNode.clipsToBounds = true + chatController.displayNode.accessibilityElementsHidden = true self.updateChatController(transition: .immediate) @@ -442,6 +443,8 @@ public final class PeerInfoChatListPaneNode: ASDisplayNode, PeerInfoPaneNode, AS } if let contentNode = chatController.customNavigationBarContentNode { self.removeChatWhenNotSearching = true + chatController.displayNode.accessibilityElementsHidden = false + self.chatListNode.accessibilityElementsHidden = true chatController.displayNode.layer.allowsGroupOpacity = true if transition.isAnimated { @@ -480,6 +483,8 @@ public final class PeerInfoChatListPaneNode: ASDisplayNode, PeerInfoPaneNode, AS self.chatController = nil let displayNode = chatController.displayNode + displayNode.accessibilityElementsHidden = true + self.chatListNode.accessibilityElementsHidden = false chatController.displayNode.layer.allowsGroupOpacity = true chatController.displayNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak displayNode] _ in displayNode?.removeFromSupernode() @@ -583,7 +588,7 @@ public final class PeerInfoChatListPaneNode: ASDisplayNode, PeerInfoPaneNode, AS let combinedBottomInset = bottomInset transition.updateFrame(node: chatController.displayNode, frame: chatFrame) chatController.updateIsScrollingLockedAtTop(isScrollingLockedAtTop: isScrollingLockedAtTop) - chatController.containerLayoutUpdated(ContainerViewLayout(size: chatFrame.size, metrics: LayoutMetrics(widthClass: .compact, heightClass: .compact, orientation: nil), deviceMetrics: deviceMetrics, intrinsicInsets: UIEdgeInsets(top: topInset + navigationHeight, left: sideInset, bottom: combinedBottomInset, right: sideInset), safeInsets: UIEdgeInsets(top: navigationHeight + topInset + 4.0, left: sideInset, bottom: combinedBottomInset, right: sideInset), additionalInsets: UIEdgeInsets(), statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: false), transition: transition) + chatController.containerLayoutUpdated(ContainerViewLayout(size: chatFrame.size, metrics: LayoutMetrics(widthClass: .compact, heightClass: .compact, orientation: nil), deviceMetrics: deviceMetrics, intrinsicInsets: UIEdgeInsets(top: topInset + navigationHeight, left: sideInset, bottom: combinedBottomInset, right: sideInset), safeInsets: UIEdgeInsets(top: navigationHeight + topInset + 4.0, left: sideInset, bottom: combinedBottomInset, right: sideInset), additionalInsets: UIEdgeInsets(), statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: UIAccessibility.isVoiceOverRunning), transition: transition) } public func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatPaneNode/Sources/PeerInfoChatPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatPaneNode/Sources/PeerInfoChatPaneNode.swift index 31a2d6d09df..68c57aaa20f 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatPaneNode/Sources/PeerInfoChatPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatPaneNode/Sources/PeerInfoChatPaneNode.swift @@ -304,7 +304,7 @@ public final class PeerInfoChatPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScro let combinedBottomInset = bottomInset transition.updateFrame(node: self.chatController.displayNode, frame: chatFrame) self.chatController.updateIsScrollingLockedAtTop(isScrollingLockedAtTop: isScrollingLockedAtTop) - self.chatController.containerLayoutUpdated(ContainerViewLayout(size: chatFrame.size, metrics: LayoutMetrics(widthClass: .compact, heightClass: .compact, orientation: nil), deviceMetrics: deviceMetrics, intrinsicInsets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: combinedBottomInset, right: sideInset), safeInsets: UIEdgeInsets(top: 0.0 + 4.0, left: sideInset, bottom: combinedBottomInset, right: sideInset), additionalInsets: UIEdgeInsets(), statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: false), transition: transition) + self.chatController.containerLayoutUpdated(ContainerViewLayout(size: chatFrame.size, metrics: LayoutMetrics(widthClass: .compact, heightClass: .compact, orientation: nil), deviceMetrics: deviceMetrics, intrinsicInsets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: combinedBottomInset, right: sideInset), safeInsets: UIEdgeInsets(top: 0.0 + 4.0, left: sideInset, bottom: combinedBottomInset, right: sideInset), additionalInsets: UIEdgeInsets(), statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: UIAccessibility.isVoiceOverRunning), transition: transition) } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift index a5a2d832b0f..99c2bbffb30 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift @@ -119,34 +119,38 @@ private final class VisualMediaItemNode: ASDisplayNode { if case .ended = recognizer.state { if let (gesture, _) = recognizer.lastRecognizedGestureAndLocation { if case .tap = gesture { - if let (item, _, _, _) = self.item { - var media: EngineRawMedia? - for value in item.message.effectiveMedia { - if let image = value as? TelegramMediaImage { - media = image - break - } else if let file = value as? TelegramMediaFile { - media = file - break - } - } - - if let media = media { - if let file = media as? TelegramMediaFile { - if isMediaStreamable(message: EngineMessage(item.message), media: file) { - self.interaction.openMessage(item.message) - } else { - self.progressPressed() - } - } else { - self.interaction.openMessage(item.message) - } - } - } + let _ = self.activateMedia() } } } } + + private func activateMedia() -> Bool { + guard let item = self.item?.0 else { + return false + } + + var media: EngineRawMedia? + for value in item.message.effectiveMedia { + if let image = value as? TelegramMediaImage { + media = image + break + } else if let file = value as? TelegramMediaFile { + media = file + break + } + } + + guard let media else { + return false + } + if let file = media as? TelegramMediaFile, !isMediaStreamable(message: EngineMessage(item.message), media: file) { + self.progressPressed() + } else { + self.interaction.openMessage(item.message) + } + return true + } private func progressPressed() { guard let message = self.item?.0.message else { @@ -310,9 +314,7 @@ private final class VisualMediaItemNode: ASDisplayNode { self.accessibilityLabel = presentationData.strings.VoiceOver_Chat_Video } self.accessibilityValue = item.message.text.isEmpty ? nil : item.message.text - self.accessibilityCustomActions = [ - UIAccessibilityCustomAction(name: presentationData.strings.VoiceOver_MessageContextOpenMessageMenu, target: self, selector: #selector(self.accessibilityOpenContextMenu(_:))) - ] + self.updateAccessibilityActions(strings: presentationData.strings) self.updateHiddenMedia() } @@ -354,6 +356,8 @@ private final class VisualMediaItemNode: ASDisplayNode { func updateSelectionState(animated: Bool) { if let (item, _, _, _) = self.item, let theme = self.theme { self.containerNode.isGestureEnabled = self.interaction.selectedMessageIds == nil + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + self.updateAccessibilityActions(strings: presentationData.strings) if let selectedIds = self.interaction.selectedMessageIds { let selected = selectedIds.contains(item.message.id) @@ -407,14 +411,24 @@ private final class VisualMediaItemNode: ASDisplayNode { } if let selectedMessageIds = self.interaction.selectedMessageIds { self.interaction.toggleSelection(item.message.id, !selectedMessageIds.contains(item.message.id)) + return true } else { - self.interaction.openMessage(item.message) + return self.activateMedia() + } + } + + private func updateAccessibilityActions(strings: PresentationStrings) { + if self.interaction.selectedMessageIds == nil { + self.accessibilityCustomActions = [ + UIAccessibilityCustomAction(name: strings.VoiceOver_MessageContextOpenMessageMenu, target: self, selector: #selector(self.accessibilityOpenContextMenu(_:))) + ] + } else { + self.accessibilityCustomActions = nil } - return true } @objc private func accessibilityOpenContextMenu(_ action: UIAccessibilityCustomAction) -> Bool { - guard let item = self.item?.0 else { + guard self.interaction.selectedMessageIds == nil, let item = self.item?.0 else { return false } self.interaction.openMessageContextActions(item.message, self.containerNode, self.containerNode.bounds, nil) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift index ce181a144b8..48d60356684 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift @@ -3798,7 +3798,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, - inVoiceOver: false + inVoiceOver: UIAccessibility.isVoiceOverRunning ), navigationBarHeight: 0.0, topPadding: mapOverscrollInset + self.additionalNavigationHeight, From d9f29ddeb4d8f1b3ead18a0f5fcae8360e39217b Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:38:41 +0500 Subject: [PATCH 10/11] Improve VoiceOver accessibility in gifts and sharing Improve VoiceOver focus containment across Share Extension transitions. Add accessible labels, traits, loading states, topic selection, link copying, and account switching. Improve Gifts pane controls, empty states, collection transitions, and removal animations. --- .../Sources/ShareControllerNode.swift | 54 +++++-------------- .../Sources/ShareInputFieldNode.swift | 9 +++- .../Sources/ShareLoadingContainerNode.swift | 28 +++++++++- .../Sources/SharePeersContainerNode.swift | 11 ++++ .../Sources/ShareSearchContainerNode.swift | 2 + .../Sources/ShareTopicGridItem.swift | 18 +++++++ .../Sources/ShareTopicsContainerNode.swift | 4 ++ .../Sources/GiftsListView.swift | 25 +++++++++ .../Sources/PeerInfoGiftsPaneNode.swift | 20 +++++++ 9 files changed, 128 insertions(+), 43 deletions(-) diff --git a/submodules/ShareController/Sources/ShareControllerNode.swift b/submodules/ShareController/Sources/ShareControllerNode.swift index 6cac8695294..7d69d239609 100644 --- a/submodules/ShareController/Sources/ShareControllerNode.swift +++ b/submodules/ShareController/Sources/ShareControllerNode.swift @@ -972,6 +972,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate func setActionNodesHidden(_ hidden: Bool, inputField: Bool = false, actions: Bool = false, animated: Bool = true) { func updateActionNodesAlpha(_ nodes: [ASDisplayNode], alpha: CGFloat) { for node in nodes { + node.accessibilityElementsHidden = alpha.isZero if !node.alpha.isEqual(to: alpha) { let previousAlpha = node.alpha node.alpha = alpha @@ -1007,6 +1008,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate if let previous = previous { previous.setDidBeginDragging(nil) previous.setContentOffsetUpdated(nil) + previous.accessibilityElementsHidden = true if animated { transition = .animated(duration: 0.4, curve: .spring) self.previousContentNode = previous @@ -1034,6 +1036,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate if let (layout, navigationBarHeight, bottomGridInset) = self.containerLayout { if let contentNode = contentNode, let previous = previous { contentNode.frame = previous.frame + contentNode.accessibilityElementsHidden = false contentNode.updateLayout(size: previous.bounds.size, isLandscape: layout.size.width > layout.size.height, bottomInset: bottomGridInset, transition: .immediate) contentNode.setDidBeginDragging({ [weak self] in @@ -1067,6 +1070,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate } } else { if let contentNode = self.contentNode { + contentNode.accessibilityElementsHidden = false contentNode.setDidBeginDragging({ [weak self] in self?.contentNodeDidBeginDragging() }) @@ -1079,6 +1083,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) } } else if let contentNode = contentNode { + contentNode.accessibilityElementsHidden = false contentNode.setContentOffsetUpdated({ [weak self] contentOffset, transition in self?.contentNodeOffsetUpdated(contentOffset, transition: transition) }) @@ -1202,7 +1207,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, - inVoiceOver: false + inVoiceOver: layout.inVoiceOver ) controller.presentationContext.containerLayoutUpdated(subLayout, transition: transition) } @@ -1412,19 +1417,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate } self.inputFieldNode.deactivateInput() - let transition: ContainedViewLayoutTransition - if peerId == nil { - transition = .animated(duration: 0.12, curve: .easeInOut) - } else { - transition = .immediate - } - transition.updateAlpha(node: self.actionButtonNode, alpha: 0.0) - transition.updateAlpha(node: self.inputFieldNode, alpha: 0.0) - transition.updateAlpha(node: self.actionSeparatorNode, alpha: 0.0) - transition.updateAlpha(node: self.actionsBackgroundNode, alpha: 0.0) - if let startAtTimestampNode = self.startAtTimestampNode { - transition.updateAlpha(node: startAtTimestampNode, alpha: 0.0) - } + self.setActionNodesHidden(true, inputField: true, actions: true, animated: peerId == nil) let peerIds: [PeerId] var topicIds: [PeerId: Int64] = [:] @@ -1502,7 +1495,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate if long { strongSelf.transitionToContentNode(ShareProlongedLoadingContainerNode(theme: strongSelf.presentationData.theme, strings: strongSelf.presentationData.strings, forceNativeAppearance: true, environment: strongSelf.environment), fastOut: true) } else { - strongSelf.transitionToContentNode(ShareLoadingContainerNode(theme: strongSelf.presentationData.theme, forceNativeAppearance: true), fastOut: true) + strongSelf.transitionToContentNode(ShareLoadingContainerNode(theme: strongSelf.presentationData.theme, strings: strongSelf.presentationData.strings, forceNativeAppearance: true), fastOut: true) } } @@ -1717,15 +1710,8 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate case .preparing: if loadingTimestamp == nil { strongSelf.inputFieldNode.deactivateInput() - let transition = ContainedViewLayoutTransition.animated(duration: 0.12, curve: .easeInOut) - transition.updateAlpha(node: strongSelf.actionButtonNode, alpha: 0.0) - transition.updateAlpha(node: strongSelf.inputFieldNode, alpha: 0.0) - transition.updateAlpha(node: strongSelf.actionSeparatorNode, alpha: 0.0) - transition.updateAlpha(node: strongSelf.actionsBackgroundNode, alpha: 0.0) - if let startAtTimestampNode = strongSelf.startAtTimestampNode { - transition.updateAlpha(node: startAtTimestampNode, alpha: 0.0) - } - strongSelf.transitionToContentNode(ShareLoadingContainerNode(theme: strongSelf.presentationData.theme, forceNativeAppearance: true), fastOut: true) + strongSelf.setActionNodesHidden(true, inputField: true, actions: true) + strongSelf.transitionToContentNode(ShareLoadingContainerNode(theme: strongSelf.presentationData.theme, strings: strongSelf.presentationData.strings, forceNativeAppearance: true), fastOut: true) loadingTimestamp = CACurrentMediaTime() if reportReady { strongSelf.ready.set(.single(true)) @@ -1879,14 +1865,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate func transitionToProgress(signal: Signal) { self.inputFieldNode.deactivateInput() - let transition = ContainedViewLayoutTransition.animated(duration: 0.12, curve: .easeInOut) - transition.updateAlpha(node: self.actionButtonNode, alpha: 0.0) - transition.updateAlpha(node: self.inputFieldNode, alpha: 0.0) - transition.updateAlpha(node: self.actionSeparatorNode, alpha: 0.0) - transition.updateAlpha(node: self.actionsBackgroundNode, alpha: 0.0) - if let startAtTimestampNode = self.startAtTimestampNode { - transition.updateAlpha(node: startAtTimestampNode, alpha: 0.0) - } + self.setActionNodesHidden(true, inputField: true, actions: true) self.transitionToContentNode(ShareProlongedLoadingContainerNode(theme: self.presentationData.theme, strings: self.presentationData.strings, forceNativeAppearance: true, environment: self.environment), fastOut: true) let timestamp = CACurrentMediaTime() @@ -1920,16 +1899,9 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate completion() })) } else { - let transition = ContainedViewLayoutTransition.animated(duration: 0.12, curve: .easeInOut) - transition.updateAlpha(node: self.actionButtonNode, alpha: 0.0) - transition.updateAlpha(node: self.inputFieldNode, alpha: 0.0) - transition.updateAlpha(node: self.actionSeparatorNode, alpha: 0.0) - transition.updateAlpha(node: self.actionsBackgroundNode, alpha: 0.0) - if let startAtTimestampNode = self.startAtTimestampNode { - transition.updateAlpha(node: startAtTimestampNode, alpha: 0.0) - } + self.setActionNodesHidden(true, inputField: true, actions: true) - self.transitionToContentNode(ShareLoadingContainerNode(theme: self.presentationData.theme, forceNativeAppearance: true), fastOut: true) + self.transitionToContentNode(ShareLoadingContainerNode(theme: self.presentationData.theme, strings: self.presentationData.strings, forceNativeAppearance: true), fastOut: true) let timestamp = CACurrentMediaTime() var wasDone = false diff --git a/submodules/ShareController/Sources/ShareInputFieldNode.swift b/submodules/ShareController/Sources/ShareInputFieldNode.swift index cab97511271..826e9fb3ab7 100644 --- a/submodules/ShareController/Sources/ShareInputFieldNode.swift +++ b/submodules/ShareController/Sources/ShareInputFieldNode.swift @@ -135,6 +135,9 @@ private final class ShareInputCopyComponent: Component { textView.mask = self.textMask } textView.frame = textFrame + textView.isAccessibilityElement = true + textView.accessibilityLabel = component.text + textView.accessibilityTraits = [.staticText] } let buttonSize = self.button.update( @@ -159,6 +162,9 @@ private final class ShareInputCopyComponent: Component { self.addSubview(buttonView) } buttonView.frame = buttonFrame + buttonView.isAccessibilityElement = true + buttonView.accessibilityLabel = component.strings.Conversation_LinkDialogCopy + buttonView.accessibilityTraits = [.button] } if self.textMask.image == nil { @@ -224,6 +230,7 @@ public final class ShareInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegat public var placeholder: String = "" { didSet { self.placeholderNode.attributedText = NSAttributedString(string: self.placeholder, font: Font.regular(17.0), textColor: self.theme.placeholderColor) + self.textInputNode.textView.accessibilityLabel = self.placeholder } } @@ -245,7 +252,7 @@ public final class ShareInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegat self.textInputNode.textContainerInset = UIEdgeInsets(top: self.inputInsets.top, left: 0.0, bottom: self.inputInsets.bottom, right: 0.0) self.textInputNode.keyboardAppearance = theme.keyboard.keyboardAppearance self.textInputNode.tintColor = theme.accentColor - self.textInputNode.textView.accessibilityHint = placeholder + self.textInputNode.textView.accessibilityLabel = placeholder self.placeholderNode = ASTextNode() self.placeholderNode.isUserInteractionEnabled = false diff --git a/submodules/ShareController/Sources/ShareLoadingContainerNode.swift b/submodules/ShareController/Sources/ShareLoadingContainerNode.swift index 06ef0c4c471..f1056cf063a 100644 --- a/submodules/ShareController/Sources/ShareLoadingContainerNode.swift +++ b/submodules/ShareController/Sources/ShareLoadingContainerNode.swift @@ -47,6 +47,7 @@ public final class ShareLoadingContainerNode: ASDisplayNode, ShareContentContain private var contentOffsetUpdated: ((CGFloat, ContainedViewLayoutTransition) -> Void)? private var theme: PresentationTheme + private let strings: PresentationStrings private let activityIndicator: ActivityIndicator private let statusNode: RadialStatusNode private let doneStatusNode: RadialStatusNode @@ -67,22 +68,42 @@ public final class ShareLoadingContainerNode: ASDisplayNode, ShareContentContain self.statusNode.transitionToState(.progress(color: self.theme.actionSheet.controlAccentColor, lineWidth: 2.0, value: 1.0, cancelEnabled: false, animateRotation: true), completion: {}) self.doneStatusNode.transitionToState(.check(self.theme.actionSheet.controlAccentColor), completion: {}) } + self.updateAccessibilityLabel() } } - public init(theme: PresentationTheme, forceNativeAppearance: Bool) { + public init(theme: PresentationTheme, strings: PresentationStrings, forceNativeAppearance: Bool) { self.theme = theme + self.strings = strings self.activityIndicator = ActivityIndicator(type: .custom(theme.actionSheet.controlAccentColor, !forceNativeAppearance ? 22.0 : 50.0, 2.0, forceNativeAppearance)) self.statusNode = RadialStatusNode(backgroundNodeColor: .clear) self.doneStatusNode = RadialStatusNode(backgroundNodeColor: .clear) super.init() + + self.isAccessibilityElement = true + self.accessibilityTraits = [.staticText, .updatesFrequently] + self.updateAccessibilityLabel() + self.activityIndicator.isAccessibilityElement = false + self.statusNode.isAccessibilityElement = false + self.doneStatusNode.isAccessibilityElement = false self.addSubnode(self.activityIndicator) self.addSubnode(self.statusNode) self.addSubnode(self.doneStatusNode) self.doneStatusNode.transitionToState(.progress(color: self.theme.actionSheet.controlAccentColor, lineWidth: 2.0, value: 0.0, cancelEnabled: false, animateRotation: true), completion: {}) } + + private func updateAccessibilityLabel() { + switch self.state { + case .preparing: + self.accessibilityLabel = self.strings.Channel_NotificationLoading + case let .progress(value): + self.accessibilityLabel = self.strings.Share_UploadProgress(Int(value * 100.0)).string + case .done: + self.accessibilityLabel = self.strings.Share_UploadDone + } + } public func activate() { } @@ -249,6 +270,8 @@ public final class ShareProlongedLoadingContainerNode: ASDisplayNode, ShareConte self.progressTextNode = ImmediateTextNode() self.progressTextNode.textAlignment = .center + self.progressTextNode.isAccessibilityElement = true + self.progressTextNode.accessibilityTraits = [.staticText, .updatesFrequently] self.progressBackgroundNode = ASDisplayNode() self.progressBackgroundNode.backgroundColor = theme.actionSheet.controlAccentColor.withMultipliedAlpha(0.2) @@ -350,6 +373,7 @@ public final class ShareProlongedLoadingContainerNode: ASDisplayNode, ShareConte } self.progressTextNode.attributedText = NSAttributedString(string: progressText, font: Font.with(size: 17.0, design: .regular, weight: .semibold, traits: [.monospacedNumbers]), textColor: self.theme.actionSheet.primaryTextColor) + self.progressTextNode.accessibilityLabel = progressText let progressTextSize = self.progressTextNode.updateLayout(size) let progressTextFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - progressTextSize.width) / 2.0), y: progressFrame.minY - spacing - 9.0 - progressTextSize.height), size: progressTextSize) self.progressTextNode.frame = progressTextFrame @@ -359,9 +383,11 @@ public final class ShareProlongedLoadingContainerNode: ASDisplayNode, ShareConte let animationFrame = CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) / 2.0), y: (progressTextFrame.minY - imageSize.height - 20.0)), size: imageSize) self.animationNode.frame = animationFrame + self.animationNode.isAccessibilityElement = false self.animationNode.updateLayout(size: imageSize) self.doneAnimationNode.frame = animationFrame + self.doneAnimationNode.isAccessibilityElement = false self.doneAnimationNode.updateLayout(size: imageSize) self.contentOffsetUpdated?(-size.height + nodeHeight * 0.5, transition) diff --git a/submodules/ShareController/Sources/SharePeersContainerNode.swift b/submodules/ShareController/Sources/SharePeersContainerNode.swift index 1bc44a39f92..1a41fd845e5 100644 --- a/submodules/ShareController/Sources/SharePeersContainerNode.swift +++ b/submodules/ShareController/Sources/SharePeersContainerNode.swift @@ -236,15 +236,24 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { emptyColor: nil, synchronousLoad: false ) + self.contentTitleAccountNode.isAccessibilityElement = true + self.contentTitleAccountNode.accessibilityLabel = strings.Shortcut_SwitchAccount + self.contentTitleAccountNode.accessibilityValue = info.peer.compactDisplayTitle + self.contentTitleAccountNode.accessibilityTraits = [.button] } else { self.contentTitleAccountNode.isHidden = true + self.contentTitleAccountNode.isAccessibilityElement = false } self.searchButtonNode = HighlightableButtonNode() self.searchButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Share/SearchIcon"), color: self.theme.actionSheet.controlAccentColor), for: []) + self.searchButtonNode.accessibilityLabel = strings.Common_Search + self.searchButtonNode.accessibilityTraits = [.button] self.shareButtonNode = HighlightableButtonNode() self.shareButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Share/ShareIcon"), color: self.theme.actionSheet.controlAccentColor), for: []) + self.shareButtonNode.accessibilityLabel = strings.ShareMenu_ShareTo + self.shareButtonNode.accessibilityTraits = [.button] self.shareReferenceNode = ContextReferenceContentNode() self.shareContainerNode = ContextControllerSourceNode() @@ -272,6 +281,8 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { } super.init() + + self.contentTitleNode.accessibilityTraits = [.header] self.addSubnode(self.contentGridNode) self.addSubnode(self.headerNode) diff --git a/submodules/ShareController/Sources/ShareSearchContainerNode.swift b/submodules/ShareController/Sources/ShareSearchContainerNode.swift index a53d67687ac..29220c1508e 100644 --- a/submodules/ShareController/Sources/ShareSearchContainerNode.swift +++ b/submodules/ShareController/Sources/ShareSearchContainerNode.swift @@ -246,6 +246,8 @@ final class ShareSearchContainerNode: ASDisplayNode, ShareContentContainerNode { self.cancelButtonNode = HighlightableButtonNode() self.cancelButtonNode.setTitle(strings.Common_Cancel, with: cancelFont, with: theme.actionSheet.controlAccentColor, for: []) self.cancelButtonNode.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0) + self.cancelButtonNode.accessibilityLabel = strings.Common_Cancel + self.cancelButtonNode.accessibilityTraits = [.button] self.contentSeparatorNode = ASDisplayNode() self.contentSeparatorNode.isLayerBacked = true diff --git a/submodules/ShareController/Sources/ShareTopicGridItem.swift b/submodules/ShareController/Sources/ShareTopicGridItem.swift index 015d0b76488..e27f92c389b 100644 --- a/submodules/ShareController/Sources/ShareTopicGridItem.swift +++ b/submodules/ShareController/Sources/ShareTopicGridItem.swift @@ -72,6 +72,10 @@ final class ShareTopicGridItemNode: GridItemNode { self.textNode.textAlignment = .center super.init() + + self.isAccessibilityElement = false + self.accessibilityTraits = [.button] + self.textNode.isAccessibilityElement = false self.addSubnode(self.textNode) } @@ -91,6 +95,14 @@ final class ShareTopicGridItemNode: GridItemNode { } } } + + override func accessibilityActivate() -> Bool { + guard self.currentItem?.peer != nil else { + return false + } + self.tapped() + return true + } override func updateAbsoluteRect(_ absoluteRect: CGRect, within containerSize: CGSize) { let rect = absoluteRect @@ -107,8 +119,11 @@ final class ShareTopicGridItemNode: GridItemNode { return } self.currentItem = item + self.isAccessibilityElement = item.peer != nil + self.accessibilityLabel = nil if let threadInfo = item.threadInfo { + self.accessibilityLabel = threadInfo.info.title self.textNode.attributedText = NSAttributedString(string: threadInfo.info.title, font: Font.regular(11.0), textColor: item.theme.actionSheet.primaryTextColor) let iconContent: EmojiStatusComponent.Content @@ -138,9 +153,11 @@ final class ShareTopicGridItemNode: GridItemNode { if iconComponentView.superview == nil { self.view.addSubview(iconComponentView) } + iconComponentView.accessibilityElementsHidden = true iconComponentView.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - iconSize.width) / 2.0), y: 7.0), size: iconSize) } } else if let peer = item.peer, let mainPeer = peer.chatMainPeer { + self.accessibilityLabel = mainPeer.compactDisplayTitle self.textNode.attributedText = NSAttributedString(string: mainPeer.compactDisplayTitle, font: Font.regular(11.0), textColor: item.theme.actionSheet.primaryTextColor) let avatarNode: AvatarNode @@ -148,6 +165,7 @@ final class ShareTopicGridItemNode: GridItemNode { avatarNode = current } else { avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 12.0)) + avatarNode.isAccessibilityElement = false self.avatarNode = avatarNode self.addSubnode(avatarNode) } diff --git a/submodules/ShareController/Sources/ShareTopicsContainerNode.swift b/submodules/ShareController/Sources/ShareTopicsContainerNode.swift index 18d5b6e4a23..0989a2c9db7 100644 --- a/submodules/ShareController/Sources/ShareTopicsContainerNode.swift +++ b/submodules/ShareController/Sources/ShareTopicsContainerNode.swift @@ -95,6 +95,8 @@ private class CancelButtonNode: ASDisplayNode { self.strings = strings self.buttonNode = HighlightTrackingButtonNode() + self.buttonNode.accessibilityLabel = strings.Common_Back + self.buttonNode.accessibilityTraits = [.button] self.arrowNode = ASImageNode() self.arrowNode.displaysAsynchronously = false @@ -237,6 +239,8 @@ final class ShareTopicsContainerNode: ASDisplayNode, ShareContentContainerNode { self.backNode = CancelButtonNode(theme: theme, strings: strings) super.init() + + self.contentTitleNode.accessibilityTraits = [.header] self.addSubnode(self.contentGridNode) self.addSubnode(self.headerNode) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift index e1c98a1ac32..24f2630ba91 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift @@ -747,6 +747,7 @@ final class GiftsListView: UIView { if !validIds.contains(id) { removeIds.append(id) if let itemView = item.1.view { + itemView.accessibilityElementsHidden = true if !transition.animation.isImmediate { itemView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.25, removeOnCompletion: false) itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in @@ -859,6 +860,9 @@ final class GiftsListView: UIView { } view.bounds = CGRect(origin: .zero, size: emptyResultsTitleFrame.size) panelTransition.setPosition(view: view, position: emptyResultsTitleFrame.center) + view.isAccessibilityElement = true + view.accessibilityLabel = presentationData.strings.PeerInfo_Gifts_EmptyCollection_Title + view.accessibilityTraits = [.header] } if let view = self.emptyResultsText.view { if view.superview == nil { @@ -868,6 +872,9 @@ final class GiftsListView: UIView { } view.bounds = CGRect(origin: .zero, size: emptyResultsTextFrame.size) panelTransition.setPosition(view: view, position: emptyResultsTextFrame.center) + view.isAccessibilityElement = true + view.accessibilityLabel = presentationData.strings.PeerInfo_Gifts_EmptyCollection_Text + view.accessibilityTraits = [.staticText] } if let view = self.emptyResultsAction.view { if view.superview == nil { @@ -877,6 +884,12 @@ final class GiftsListView: UIView { } view.bounds = CGRect(origin: .zero, size: emptyResultsActionFrame.size) panelTransition.setPosition(view: view, position: emptyResultsActionFrame.center) + view.isAccessibilityElement = true + view.accessibilityLabel = presentationData.strings.PeerInfo_Gifts_EmptyCollection_Action + view.accessibilityTraits = [.button] + for subview in view.subviews { + subview.accessibilityElementsHidden = true + } } } else if self.filteredResultsAreEmpty { let sideInset: CGFloat = 44.0 @@ -951,6 +964,7 @@ final class GiftsListView: UIView { self.emptyResultsClippingView.addSubview(view) view.playOnce() } + view.accessibilityElementsHidden = true view.bounds = CGRect(origin: .zero, size: emptyResultsAnimationFrame.size) panelTransition.setPosition(view: view, position: emptyResultsAnimationFrame.center) } @@ -962,6 +976,9 @@ final class GiftsListView: UIView { } view.bounds = CGRect(origin: .zero, size: emptyResultsTitleFrame.size) panelTransition.setPosition(view: view, position: emptyResultsTitleFrame.center) + view.isAccessibilityElement = true + view.accessibilityLabel = presentationData.strings.PeerInfo_Gifts_NoResults + view.accessibilityTraits = [.header] } if let view = self.emptyResultsAction.view { if view.superview == nil { @@ -971,8 +988,15 @@ final class GiftsListView: UIView { } view.bounds = CGRect(origin: .zero, size: emptyResultsActionFrame.size) panelTransition.setPosition(view: view, position: emptyResultsActionFrame.center) + view.isAccessibilityElement = true + view.accessibilityLabel = presentationData.strings.PeerInfo_Gifts_NoResults_ViewAll + view.accessibilityTraits = [.button] + for subview in view.subviews { + subview.accessibilityElementsHidden = true + } } } else { + self.emptyResultsClippingView.accessibilityElementsHidden = true if let view = self.emptyResultsAnimation.view { fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in view.removeFromSuperview() @@ -997,6 +1021,7 @@ final class GiftsListView: UIView { } fadeTransition.setAlpha(view: self.emptyResultsClippingView, alpha: visibleHeight < 300.0 ? 0.0 : 1.0) + self.emptyResultsClippingView.accessibilityElementsHidden = (!self.resultsAreEmpty && !self.filteredResultsAreEmpty) || self.emptyResultsClippingView.isHidden || visibleHeight < 300.0 if self.peerId == self.context.account.peerId, !self.canSelect && !self.filteredResultsAreEmpty && self.profileGifts.collectionId == nil && self.emptyResultsClippingView.isHidden { let footerText: ComponentView diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift index ee9ea2d9fc6..d893925f24a 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift @@ -470,6 +470,8 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } self.giftsListView.parentController = self.parentController self.giftsListView.frame = previousGiftsListView.frame + previousGiftsListView.accessibilityElementsHidden = true + self.giftsListView.accessibilityElementsHidden = false self.scrollNode.view.insertSubview(self.giftsListView, aboveSubview: previousGiftsListView) @@ -709,6 +711,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr if let tabSelectorView = self.tabSelector.view { if tabSelectorView.superview == nil { tabSelectorView.alpha = 1.0 + tabSelectorView.accessibilityElementsHidden = false self.scrollNode.view.insertSubview(tabSelectorView, at: 0) if !transition.animation.isImmediate { @@ -720,6 +723,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr topInset += tabSelectorSize.height + 15.0 } } else if let tabSelectorView = self.tabSelector.view { + tabSelectorView.accessibilityElementsHidden = true tabSelectorView.alpha = 0.0 tabSelectorView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, completion: { _ in tabSelectorView.removeFromSuperview() @@ -834,6 +838,12 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr panelContentContainer.addSubview(panelButtonView) } panelButtonView.frame = CGRect(origin: CGPoint(x: buttonInsets.left, y: 8.0), size: panelButtonSize) + panelButtonView.isAccessibilityElement = true + panelButtonView.accessibilityLabel = buttonTitle + panelButtonView.accessibilityTraits = [.button] + for subview in panelButtonView.subviews { + subview.accessibilityElementsHidden = true + } } panelTransition.setFrame(view: panelContentContainer, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height - bottomPanelHeight), size: CGSize(width: size.width, height: bottomPanelHeight))) @@ -904,6 +914,15 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr panelContentContainer.addSubview(panelCheckView) } panelCheckView.frame = CGRect(origin: CGPoint(x: floor((size.width - panelCheckSize.width) / 2.0), y: 16.0 + 16.0), size: panelCheckSize) + panelCheckView.isAccessibilityElement = true + panelCheckView.accessibilityLabel = presentationData.strings.PeerInfo_Gifts_ChannelNotify + panelCheckView.accessibilityTraits = [.button] + if self.profileGifts.currentState?.notificationsEnabled == true { + panelCheckView.accessibilityTraits.insert(.selected) + } + for subview in panelCheckView.subviews { + subview.accessibilityElementsHidden = true + } } if let panelButtonView = panelButton.view { panelButtonView.isHidden = true @@ -915,6 +934,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr panelEdgeEffectView.update(content: presentationData.theme.list.blocksBackgroundColor, blur: false, rect: edgeEffectFrame, edge: .bottom, edgeSize: 40.0, transition: panelTransition) ComponentTransition.spring(duration: 0.4).setSublayerTransform(view: panelContentContainer, transform: CATransform3DMakeTranslation(0.0, bottomPanelHeight * (1.0 - panelVisibility), 0.0)) + panelContentContainer.accessibilityElementsHidden = panelVisibility == 0.0 contentHeight += bottomPanelHeight bottomScrollInset = bottomPanelHeight - 40.0 From 186418800fdfb9461e5fdb354316e39f37ec5c14 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:48:48 +0500 Subject: [PATCH 11/11] Improve VoiceOver accessibility in selection flows Add accessible navigation labels to gift, contact, chat, and search selection screens. Hide the offscreen Add Gifts action panel when no gifts are selected. Keep VoiceOver scroll status localized when presentation data changes. --- .../Sources/AddGiftsScreen.swift | 9 ++++++++- .../Sources/ChatSearchResultsContollerNode.swift | 10 +++++++--- .../Sources/ChatSearchResultsController.swift | 5 ++++- .../Sources/ContactMultiselectionController.swift | 10 ++++++++-- .../Sources/ContactMultiselectionControllerNode.swift | 5 +++++ 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/AddGiftsScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/AddGiftsScreen.swift index fe41b91c02b..404ac155b22 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/AddGiftsScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/AddGiftsScreen.swift @@ -184,6 +184,7 @@ final class AddGiftsScreenComponent: Component { if buttonPanelView.superview == nil { self.addSubview(buttonPanelView) } + buttonPanelView.accessibilityElementsHidden = giftsListView.selectedItems.isEmpty transition.setFrame(view: buttonPanelView, frame: CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - bottomPanelSize.height + bottomPanelOffset), size: bottomPanelSize)) } @@ -266,7 +267,9 @@ public final class AddGiftsScreen: ViewControllerComponentContainer { } self.filterButton.addTarget(self, action: #selector(self.filterPressed), forControlEvents: .touchUpInside) - self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(self.cancelPressed)) + let closeButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(self.cancelPressed)) + closeButtonItem.accessibilityLabel = presentationData.strings.Common_Close + self.navigationItem.leftBarButtonItem = closeButtonItem self.navigationItem.rightBarButtonItem = UIBarButtonItem(customDisplayNode: self.filterButton) } @@ -426,6 +429,10 @@ private final class FilterHeaderButton: HighlightableButtonNode { super.init() + self.isAccessibilityElement = true + self.accessibilityLabel = presentationData.strings.Common_More + self.accessibilityTraits = [.button] + self.containerNode.addSubnode(self.referenceNode) self.addSubnode(self.containerNode) diff --git a/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift b/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift index b29ac66d01c..2d23d0a97ba 100644 --- a/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift +++ b/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift @@ -200,11 +200,15 @@ class ChatSearchResultsControllerNode: ViewControllerTracingNode, ASScrollViewDe self.listNode = ListViewImpl() self.listNode.verticalScrollIndicatorColor = self.presentationData.theme.list.scrollIndicatorColor - self.listNode.accessibilityPageScrolledString = { row, count in - return presentationData.strings.VoiceOver_ScrollStatus(row, count).string - } super.init() + + self.listNode.accessibilityPageScrolledString = { [weak self] row, count in + guard let self else { + return "" + } + return self.presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } self.backgroundColor = self.presentationData.theme.chatList.backgroundColor self.isOpaque = false diff --git a/submodules/TelegramUI/Sources/ChatSearchResultsController.swift b/submodules/TelegramUI/Sources/ChatSearchResultsController.swift index 82bb22f0c1c..a30815f552f 100644 --- a/submodules/TelegramUI/Sources/ChatSearchResultsController.swift +++ b/submodules/TelegramUI/Sources/ChatSearchResultsController.swift @@ -43,6 +43,7 @@ final class ChatSearchResultsController: ViewController { if let strongSelf = self { strongSelf.presentationData = presentationData strongSelf.navigationBar?.updatePresentationData(NavigationBarPresentationData(presentationTheme: presentationData.theme, presentationStrings: presentationData.strings), transition: .immediate) + strongSelf.navigationItem.rightBarButtonItem?.accessibilityLabel = presentationData.strings.Common_Done strongSelf.controllerNode.updatePresentationData(presentationData) } }) @@ -51,7 +52,9 @@ final class ChatSearchResultsController: ViewController { self.title = searchQuery self.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: UIView()) - self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "___done", style: .done, target: self, action: #selector(donePressed)) + let doneButtonItem = UIBarButtonItem(title: "___done", style: .done, target: self, action: #selector(donePressed)) + doneButtonItem.accessibilityLabel = self.presentationData.strings.Common_Done + self.navigationItem.rightBarButtonItem = doneButtonItem } required init(coder aDecoder: NSCoder) { diff --git a/submodules/TelegramUI/Sources/ContactMultiselectionController.swift b/submodules/TelegramUI/Sources/ContactMultiselectionController.swift index 908f39f5e6d..0b6ad80c33b 100644 --- a/submodules/TelegramUI/Sources/ContactMultiselectionController.swift +++ b/submodules/TelegramUI/Sources/ContactMultiselectionController.swift @@ -299,16 +299,22 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection self.titleView.title = CounterControllerTitle(title: self.params.title ?? self.presentationData.strings.PrivacyLastSeenSettings_EmpryUsersPlaceholder, counter: "") if self.rightNavigationButton == nil { let rightNavigationButton = UIBarButtonItem(title: "___done", style: .done, target: self, action: #selector(self.rightNavigationButtonPressed)) + rightNavigationButton.accessibilityLabel = self.presentationData.strings.Common_Done self.rightNavigationButton = rightNavigationButton - self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(cancelPressed)) + let closeButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(cancelPressed)) + closeButtonItem.accessibilityLabel = self.presentationData.strings.Common_Close + self.navigationItem.leftBarButtonItem = closeButtonItem self.navigationItem.rightBarButtonItem = self.rightNavigationButton } case let .chatSelection(chatSelection): self.titleView.title = CounterControllerTitle(title: self.params.title ?? chatSelection.title, counter: "") if self.rightNavigationButton == nil { let rightNavigationButton = UIBarButtonItem(title: "___done", style: .done, target: self, action: #selector(self.rightNavigationButtonPressed)) + rightNavigationButton.accessibilityLabel = self.presentationData.strings.Common_Done self.rightNavigationButton = rightNavigationButton - self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(cancelPressed)) + let closeButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(cancelPressed)) + closeButtonItem.accessibilityLabel = self.presentationData.strings.Common_Close + self.navigationItem.leftBarButtonItem = closeButtonItem self.navigationItem.rightBarButtonItem = self.rightNavigationButton } } diff --git a/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift b/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift index 881bfd5b67d..451938718af 100644 --- a/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift +++ b/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift @@ -449,6 +449,11 @@ final class ContactMultiselectionControllerNode: ASDisplayNode { func updatePresentationData(_ presentationData: PresentationData) { self.presentationData = presentationData self.backgroundColor = presentationData.theme.chatList.backgroundColor + if case let .chats(chatListNode) = self.contentNode { + chatListNode.accessibilityPageScrolledString = { row, count in + return presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } + } } func scrollToTop() {