// // AccessibilityHandler.swift // MVMCoreUI // // Created by Bandaru, Krishna Kishore on 30/06/23. // Copyright © 2023 Verizon Wireless. All rights reserved. // import Foundation import Combine import MVMCore import WebKit public class AccessbilityOperation: MVMCoreOperation { private let argument: Any? private let notificationType: UIAccessibility.Notification public init(notificationType: UIAccessibility.Notification, argument: Any?) { self.notificationType = notificationType self.argument = argument } public override func main() { guard UIAccessibility.isVoiceOverRunning, !checkAndHandleForCancellation() else { stop() return } Task { @MainActor [weak self] in guard let self = self, !self.isCancelled else { self?.stop() return } UIAccessibility.post(notification: self.notificationType, argument: self.argument) if self.notificationType == .announcement { NotificationCenter.default.addObserver(forName: UIAccessibility.announcementDidFinishNotification, object: nil, queue: .main) { _ in self.markAsFinished() } } else { self.markAsFinished() } } } public func stop() { guard isCancelled else { return } markAsFinished() } } open class AccessibilityHandler { enum RotorType: String, CaseIterable { case button = "Buttons" case header = "Header" var trait: UIAccessibilityTraits { switch self { case .button: return .button case .header: return .header } } func getUIAccessibilityCustomRotor(itemSearch: @escaping UIAccessibilityCustomRotor.Search) -> UIAccessibilityCustomRotor? { var accessibilityCustomRotor: UIAccessibilityCustomRotor? switch self { case .header: accessibilityCustomRotor = UIAccessibilityCustomRotor(systemType: .heading, itemSearch: itemSearch) default: accessibilityCustomRotor = UIAccessibilityCustomRotor(name: rawValue, itemSearch: itemSearch) } return accessibilityCustomRotor } } public static func shared() -> Self? { guard let shared = CoreUIObject.sharedInstance()?.accessibilityHandler else { return nil } return MVMCoreActionUtility.fatalClassCheck(object: shared) } public var accessibilityId: String? public var previousAccessiblityElement: Any? public var anyCancellable: Set = [] public weak var delegate: MVMCoreViewControllerProtocol? private var rotorIndexes: [RotorType: Int] = [:] private var hasTopNotificationInPage: Bool { NotificationHandler.shared()?.isNotificationShowing() ?? false } private let accessibilityOperationQueue: OperationQueue = { let queue = OperationQueue() queue.maxConcurrentOperationCount = 1 return queue }() public init() { registerForFocusChanges() } // MARK: - Accessibility Handler operation events open func capturePreviousFocusElement() { previousAccessiblityElement = UIAccessibility.focusedElement(using: .notificationVoiceOver) } open func postAccessbilityToPrevElement() { post(notification: .layoutChanged, argument: previousAccessiblityElement) previousAccessiblityElement = nil } public func post(notification type: UIAccessibility.Notification, argument: Any? = nil) { guard UIAccessibility.isVoiceOverRunning else { return } let accessbilityOperation = AccessbilityOperation(notificationType: type, argument: argument) accessibilityOperationQueue.addOperation(accessbilityOperation) } //To get first focus element on the screen open func getFirstFocusedElementOnScreen() -> Any? { (delegate as? UIViewController)?.navigationController?.navigationBar } //Subclass can decide to trigger Accessibility notification on screen change. open func canPostAccessbilityNotification(for viewController: UIViewController) -> Bool { true } func getPreDefinedFocusedElementIfAny() -> UIView? { guard let accessibilityId, let view = (delegate as? UIViewController)?.view else { return nil } return MVMCoreUIUtility.findViews(by: MoleculeViewProtocol.self, views: [view]).first { $0.model?.id == accessibilityId } } } extension AccessibilityHandler { public func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) { rotorIndexes = [:] previousAccessiblityElement = nil guard let loadObject = (delegateObject?.loadDelegate as? MVMCoreViewControllerProtocol)?.loadObject else { return } accessibilityId = loadObject?.pageJSON?.optionalStringForKey("accessibilityId") if let announcementText = loadObject?.pageJSON?.optionalStringForKey("announcementText") { post(notification: .announcement, argument: announcementText) } delegate = delegateObject?.moleculeDelegate as? MVMCoreViewControllerProtocol } //MARK: - PageVisibiltyBehaviour public func willShowPage(_ delegateObject: MVMCoreUIDelegateObject?) { updateAccessibilityViews(delegateObject) guard let controller = delegateObject?.moleculeDelegate as? UIViewController, canPostAccessbilityNotification(for: controller), accessibilityId == nil else { return } if hasTopNotificationInPage { previousAccessiblityElement = getFirstFocusedElementOnScreen() } else { post(notification: .layoutChanged, argument: getFirstFocusedElementOnScreen()) } } ///We need to shift focus to any element mentioned in server response i.e to retain focus of the element in new page, from where action is triggered. ///https://oneconfluence.verizon.com/display/MFD/Accessibility+-+Focus+Retain public func onPageShown(_ delegateObject: MVMCoreUIDelegateObject?) { identifyAndPrepareRotors(delegateObject) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { (delegateObject?.moleculeDelegate as? UIViewController)?.view.accessibilityElements = nil } guard let accessibilityElement = getPreDefinedFocusedElementIfAny() else { return } accessibilityId = nil if hasTopNotificationInPage { previousAccessiblityElement = accessibilityElement } else { post(notification: .layoutChanged, argument: accessibilityElement) } } //MARK: - Accessibility Methods private func updateAccessibilityViews(_ delegateObject: MVMCoreUIDelegateObject?) { var currentController = delegateObject?.moleculeDelegate as? UIViewController var accessibilityElements: [Any?] = [MVMCoreUISplitViewController.main()?.topAlertView, MVMCoreUISplitViewController.main()?.navigationController] if let manager = ((delegateObject?.moleculeDelegate as? MVMCoreViewManagerViewControllerProtocol)?.manager as? MVMCoreViewManagerProtocol & UIViewController), let managerAccessibilityElements = manager.getAccessibilityElements() { accessibilityElements.append(contentsOf: managerAccessibilityElements) accessibilityElements.append(contentsOf: manager.view.subviews) currentController = manager } else { accessibilityElements.append(contentsOf: currentController?.view.subviews ?? []) } accessibilityElements.append(MVMCoreUISplitViewController.main()?.tabBar) currentController?.view.accessibilityElements = accessibilityElements.compactMap { $0 } } //MARK: - Rotor Methods private func identifyAndPrepareRotors(_ delegateObject: MVMCoreUIDelegateObject?) { var rotorElements: [UIAccessibilityCustomRotor] = [] let currentViewController = ((delegateObject?.moleculeDelegate as? MVMCoreViewManagerViewControllerProtocol)?.manager as? SubNavManagerController) ?? (delegateObject?.moleculeDelegate as? ViewController) for type in RotorType.allCases { if let elements = getTraitMappedElements(currentViewController, type: type), let rotor = createRotor(elements, for: type) { rotorElements.append(rotor) } } currentViewController?.navigationController?.accessibilityCustomRotors = rotorElements } private func getTraitMappedElements(_ template: ViewController?, type: RotorType) -> [Any]? { var accessibilityElements: [Any?] = [] switch type { case .button: accessibilityElements.append(contentsOf: template?.navigationItem.leftBarButtonItems ?? []) accessibilityElements.append(contentsOf: template?.navigationItem.rightBarButtonItems ?? []) case .header: accessibilityElements.append(template?.navigationItem.titleView) } if let tabs = (template as? SubNavManagerController)?.tabs { accessibilityElements.append(contentsOf: tabs.subviews.filter { $0.accessibilityTraits.contains(type.trait) }) } if let rotorElements = getRotorElementsFrom(template: template, type: type) { accessibilityElements.append(contentsOf: rotorElements) } if let tabBarHidden = (template as? TabPageModelProtocol)?.tabBarHidden, !tabBarHidden { accessibilityElements.append(contentsOf: (MVMCoreUISplitViewController.main()?.tabBar?.subviews ?? []).filter { $0.accessibilityTraits.contains(type.trait) }) } return accessibilityElements.compactMap { $0 } } private func getRotorElementsFrom(template: ViewController?, type: RotorType) -> [Any]? { if let currentViewController = template as? MoleculeListTemplate, let templateModel = currentViewController.templateModel, let tableView = currentViewController.tableView { //List templates var rotorElements: [(model: MoleculeModelProtocol, indexPath: IndexPath)] = [] ///Identifying the trait mapped elements models var currentIndexPath: IndexPath? rotorElements = templateModel.reduceDepthFirstTraverse(options: .parentFirst, depth: 0, initialResult: rotorElements, nextPartialResult: { result, model, depth in if let listModel = model as? (ListItemModelProtocol & MoleculeModelProtocol), let indexPath = currentViewController.getIndexPath(for: listModel) { currentIndexPath = indexPath } var result = result if (model.accessibilityTraits?.contains(type.trait) ?? false), let currentIndexPath { result.append((model, currentIndexPath)) } return result }) let headerViewElements = MVMCoreUIUtility.findViews(by: MoleculeViewProtocol.self, views: [currentViewController.tableView.tableHeaderView].compactMap { $0 }).filter { $0.accessibilityTraits.contains(type.trait) } as [Any] let footerViewElements = MVMCoreUIUtility.findViews(by: MoleculeViewProtocol.self, views: [currentViewController.tableView.tableFooterView].compactMap { $0 }).filter { $0.accessibilityTraits.contains(type.trait) } as [Any] let otherInteractiveElements = MVMCoreUIUtility.findViews(by: MoleculeViewProtocol.self, views: [currentViewController.view].compactMap { $0 }, excludedViews: [tableView, tableView.tableFooterView, tableView.tableHeaderView].compactMap { $0 }).filter { $0.accessibilityTraits.contains(type.trait) } as [Any] return headerViewElements + otherInteractiveElements + (rotorElements as [Any]) + footerViewElements } else if let currentViewController = template as? MoleculeStackTemplate { //Stack templates return MVMCoreUIUtility.findViews(by: MoleculeViewProtocol.self, views: [currentViewController.view].compactMap { $0 }).filter { $0.accessibilityTraits.contains(type.trait) } as [Any] } return nil } private func createRotor(_ elements: [Any], for type: RotorType) -> UIAccessibilityCustomRotor? { guard !elements.isEmpty else { return nil } return type.getUIAccessibilityCustomRotor { [weak self] predicate in guard let self else { return UIAccessibilityCustomRotorItemResult() } var rotorIndex = self.rotorIndexes[type] ?? 0 if predicate.searchDirection == .next { rotorIndex += 1 if rotorIndex > elements.count { rotorIndex = 1 } } else { rotorIndex -= 1 if rotorIndex <= 0 { rotorIndex = elements.count } } var rotorElement = elements[rotorIndex - 1] if let tableView = (self.delegate as? MoleculeListTemplate)?.tableView, let element = rotorElement as? (model: MoleculeModelProtocol, indexPath: IndexPath) { //for List templates tableView.scrollToRow(at: element.indexPath, at: .middle, animated: false) rotorElement = MVMCoreUIUtility.findViews(by: MoleculeViewProtocol.self, views: [tableView.cellForRow(at: element.indexPath)].compactMap { $0 }).filter { $0.accessibilityTraits.contains(type.trait) && $0.model?.id == element.model.id }.first as Any } self.rotorIndexes[type] = rotorIndex post(notification: .layoutChanged, argument: rotorElement) return UIAccessibilityCustomRotorItemResult(targetElement: rotorElement as! NSObjectProtocol, targetRange: nil) } } } @objc extension AccessibilityHandler { // MARK: - Register with Accessibility Handler listeners private func registerForFocusChanges() { //Since focus shifted to other elements cancelling existing focus shift notifications if any NotificationCenter.default.publisher(for: UIAccessibility.elementFocusedNotification) .sink { [weak self] _ in self?.accessibilityOperationQueue.cancelAllOperations() }.store(in: &anyCancellable) } func registerForTopNotificationsChanges() { NotificationHandler.shared()?.onNotificationWillShow .sink { [weak self] (_, model) in if self?.previousAccessiblityElement == nil { self?.capturePreviousFocusElement() } }.store(in: &anyCancellable) NotificationHandler.shared()?.onNotificationShown .sink { [weak self] (view, model) in self?.post(notification: .layoutChanged, argument: view) }.store(in: &anyCancellable) NotificationHandler.shared()?.onNotificationWillDismiss .sink { [weak self] (view, model) in self?.post(notification: .announcement, argument: MVMCoreUIUtility.hardcodedString(withKey: "AccTopAlertClosed"), priority: .veryHigh) }.store(in: &anyCancellable) NotificationHandler.shared()?.onNotificationDismissed .sink { [weak self] (view, model) in self?.postAccessbilityToPrevElement() }.store(in: &anyCancellable) } } // MARK: - Accessibility Handler Behaviour ///Accessibility Handler Behaviour to detect page shown and post notification to first interactive element on screen or the pre-defined focused element. open class AccessibilityHandlerBehavior: PageVisibilityBehavior, PageMoleculeTransformationBehavior { public let accessibilityHandler: AccessibilityHandler? public init(accessibilityHandler: AccessibilityHandler?) { self.accessibilityHandler = accessibilityHandler } required public init(model: PageBehaviorModelProtocol, delegateObject: MVMCoreUIDelegateObject?) { accessibilityHandler = AccessibilityHandler.shared() //Protocol Mandatory init method. } //MARK: - PageMoleculeTransformationBehavior public func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) { accessibilityHandler?.onPageNew(rootMolecules: rootMolecules, delegateObject) } //MARK: - PageVisibiltyBehaviour open func willShowPage(_ delegateObject: MVMCoreUIDelegateObject?) { accessibilityHandler?.willShowPage(delegateObject) } open func onPageShown(_ delegateObject: MVMCoreUIDelegateObject?) { accessibilityHandler?.onPageShown(delegateObject) } }