// // 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 private var timerSource: DispatchSourceTimer? public init(notificationType: UIAccessibility.Notification, argument: Any?) { self.notificationType = notificationType self.argument = argument } public override func main() { guard UIAccessibility.isVoiceOverRunning, !checkAndHandleForCancellation() else { stop() return } timerSource = DispatchSource.makeTimerSource() timerSource?.setEventHandler { Task { @MainActor [weak self] in guard let self = self, !self.isCancelled else { self?.stop() return } UIAccessibility.post(notification: self.notificationType, argument: self.argument) self.markAsFinished() } } timerSource?.schedule(deadline: .now()) timerSource?.activate() } public func stop() { guard isCancelled else { return } timerSource?.cancel() markAsFinished() } } open class AccessibilityHandler { public static func shared() -> Self? { guard let shared = CoreUIObject.sharedInstance()?.accessibilityHandler else { return nil } return MVMCoreActionUtility.fatalClassCheck(object: shared) } public var previousAccessiblityElement: Any? public var anyCancellable: Set = [] public weak var delegate: MVMCoreViewControllerProtocol? private var accessibilityOperationQueue: OperationQueue = { let queue = OperationQueue() queue.maxConcurrentOperationCount = 1 return queue }() private(set) var accessibilityId: String? private var announcementText: String? private(set) var hasTopNotitificationInPage: Bool = false public init() { registerWithResponseLoaded() registerForPageChanges() registerForFocusChanges() } // MARK: - Register with Accessibility Handler listeners /// Registers with the notification center to know when json is updated and to capture previous accessbility focused id & announcment text private func registerWithResponseLoaded() { NotificationCenter.default.publisher(for: NSNotification.Name(rawValue: NotificationResponseLoaded)) .sink { [weak self] notification in self?.accessibilityId = (notification.userInfo?[String(describing: MVMCoreLoadObject.self)] as? MVMCoreLoadObject)?.pageJSON?.optionalStringForKey("accessibilityId") self?.announcementText = (notification.userInfo?[String(describing: MVMCoreLoadObject.self)] as? MVMCoreLoadObject)?.pageJSON?.optionalStringForKey("announcementText") }.store(in: &anyCancellable) } private func registerForFocusChanges() { //Since foucs shifted to other elements cancelling existing focus shift notifications if any NotificationCenter.default.publisher(for: UIAccessibility.elementFocusedNotification) .sink { [weak self] _ in self?.cancelAllOperations() }.store(in: &anyCancellable) } func registerForTopNotificationsChanges() { NotificationHandler.shared()?.onNotificationWillShow .sink { [weak self] (_, model) in self?.hasTopNotitificationInPage = true 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")) }.store(in: &anyCancellable) NotificationHandler.shared()?.onNotificationDismissed .sink { [weak self] (view, model) in self?.postAccessbilityToPrevElement() }.store(in: &anyCancellable) } /// Registers to know when pages change. open func registerForPageChanges() { NavigationHandler.shared() .onNavigation .sink { [self] (event, operation) in switch event { case .willNavigate: willNavigate(operation) default: break } }.store(in: &anyCancellable) } private func willNavigate(_ operation: NavigationOperation) { previousAccessiblityElement = nil if let announcementText { post(notification: .announcement, argument: announcementText) } if let subNavManagerController = operation.toNavigationControllerViewControllers?.last as? SubNavManagerController { delegate = subNavManagerController.getCurrentViewController() as? MVMCoreViewControllerProtocol } else { delegate = operation.toNavigationControllerViewControllers?.last as? MVMCoreViewControllerProtocol } } // MARK: - Accessibility Handler operation events open func capturePreviousFocusElement() { previousAccessiblityElement = UIAccessibility.focusedElement(using: .notificationVoiceOver) } open func postAccessbilityToPrevElement() { post(notification: .layoutChanged, argument: previousAccessiblityElement) } private func add(operation: Operation) { accessibilityOperationQueue.addOperation(operation) } private func cancelAllOperations() { accessibilityOperationQueue.cancelAllOperations() } public func post(notification type: UIAccessibility.Notification, argument: Any? = nil) { guard UIAccessibility.isVoiceOverRunning else { return } let accessbilityOperation = AccessbilityOperation(notificationType: type, argument: argument) add(operation: accessbilityOperation) } //To get first focus element on the screen open func getFirstFocusedElementOnScreen() -> Any? { (delegate as? UIViewController)?.navigationItem.leftBarButtonItem ?? (delegate as? UIViewController)?.navigationItem.titleView ?? (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 else { return nil } var modelElement: MoleculeModelProtocol? ((delegate?.delegateObject?() as? MVMCoreUIDelegateObject)?.moleculeDelegate)?.getRootMolecules().depthFirstTraverse(options: .leafNodesOnly, depth: 0) { index, model, stop in if model.id == accessibilityId { modelElement = model stop = true } } return (delegate as? UIViewController)?.view?.getMoleculeViews { (subView: MoleculeViewProtocol, stop: inout Bool) in guard let modelElement, let moleculeModel = (subView as? MoleculeViewModelProtocol)?.moleculeModel, moleculeModel.id == modelElement.id else { return false } stop = true return true }.first } } // 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 { 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 var anyCancellable: Set = [] private var delegateObj: MVMCoreUIDelegateObject? private var rotorIndexes: [RotorType: Int] = [:] required public init(model: PageBehaviorModelProtocol, delegateObject: MVMCoreUIDelegateObject?) { } //MARK: - PageVisibiltyBehaviour open func willShowPage(_ delegateObject: MVMCoreUIDelegateObject?) { updateAccessibilityViews(delegateObject) guard let controller = delegateObject?.moleculeDelegate as? UIViewController, (AccessibilityHandler.shared()?.canPostAccessbilityNotification(for: controller) ?? true), AccessibilityHandler.shared()?.accessibilityId == nil else { return } if AccessibilityHandler.shared()?.hasTopNotitificationInPage ?? false { AccessibilityHandler.shared()?.previousAccessiblityElement = AccessibilityHandler.shared()?.getFirstFocusedElementOnScreen() } else { AccessibilityHandler.shared()?.post(notification: .layoutChanged, argument: AccessibilityHandler.shared()?.getFirstFocusedElementOnScreen()) } delegateObj = delegateObject } ///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 open func onPageShown(_ delegateObject: MVMCoreUIDelegateObject?) { updateAccessibilityViews(delegateObject) //To track FAB & HAB elements on UI identifyAndPrepareRotors() guard let accessibilityElement = AccessibilityHandler.shared()?.getPreDefinedFocusedElementIfAny() else { return } if AccessibilityHandler.shared()?.hasTopNotitificationInPage ?? false { AccessibilityHandler.shared()?.previousAccessiblityElement = accessibilityElement } else { AccessibilityHandler.shared()?.post(notification: .layoutChanged, argument: accessibilityElement) } } //MARK: - Accessibility Methods private func updateAccessibilityViews(_ delegateObject: MVMCoreUIDelegateObject?) { var accessibilityElements: [Any?] = [MVMCoreUISplitViewController.main()?.topAlertView] if let managerController = (delegateObject?.moleculeDelegate as? MVMCoreViewManagerViewControllerProtocol)?.manager as? SubNavManagerController { accessibilityElements.append(managerController.navigationController) accessibilityElements.append(managerController.tabs) accessibilityElements.append(contentsOf: managerController.view.subviews) accessibilityElements.append(MVMCoreUISplitViewController.main()?.tabBar) managerController.view.accessibilityElements = accessibilityElements.compactMap { $0 } } else if let controller = delegateObject?.moleculeDelegate as? UIViewController { accessibilityElements.append(controller.navigationController) accessibilityElements.append(contentsOf: controller.view.subviews.reversed()) accessibilityElements.append(MVMCoreUISplitViewController.main()?.tabBar) controller.view.accessibilityElements = accessibilityElements.compactMap { $0 } } } //MARK: - Rotor Methods private func identifyAndPrepareRotors() { var rotorElements: [UIAccessibilityCustomRotor] = [] let currentViewController = ((delegateObj?.moleculeDelegate as? MVMCoreViewManagerViewControllerProtocol)?.manager as? SubNavManagerController) ?? (delegateObj?.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)] = [] 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 = currentViewController.tableView.tableHeaderView?.getMoleculeViews { (subView: MoleculeViewProtocol, _) in subView.accessibilityTraits.contains(type.trait) } as? [Any] ?? [] let footerViewElements = currentViewController.tableView.tableFooterView?.getMoleculeViews { (subView: MoleculeViewProtocol, _) in subView.accessibilityTraits.contains(type.trait) } as? [Any] ?? [] let otherInteractiveElements = currentViewController.view?.getMoleculeViews(excludedViews: [tableView, tableView.tableFooterView, tableView.tableHeaderView] .compactMap { $0 }) { (subView: MoleculeViewProtocol, _) in subView.accessibilityTraits.contains(type.trait) } as? [Any] ?? [] return headerViewElements + otherInteractiveElements + (rotorElements as [Any]) + footerViewElements } else if let currentViewController = template as? MoleculeStackTemplate { //Stack templates return currentViewController.view?.getMoleculeViews { (subView: MoleculeViewProtocol, _) in subView.accessibilityTraits.contains(type.trait) } } 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.delegateObj?.moleculeListDelegate as? MoleculeListTemplate)?.tableView, let element = rotorElement as? (model: MoleculeModelProtocol, indexPath: IndexPath) { //for List templates tableView.scrollToRow(at: element.indexPath, at: .middle, animated: false) rotorElement = tableView.cellForRow(at: element.indexPath)?.getMoleculeViews { (subView: MoleculeViewProtocol, stop: inout Bool) in guard subView.accessibilityTraits.contains(type.trait), (subView as? MoleculeViewModelProtocol)?.moleculeModel?.id == element.model.id else { return false } stop = true return true }.first as Any } self.rotorIndexes[type] = rotorIndex AccessibilityHandler.shared()?.post(notification: .layoutChanged, argument: rotorElement) return UIAccessibilityCustomRotorItemResult(targetElement: rotorElement as! NSObjectProtocol, targetRange: nil) } } } // MARK: - Helpers extension UIView { private func getNestedSubviews(excludedViews: [UIView]? = nil) -> [T] { subviews.flatMap { subView -> [T] in guard !(excludedViews?.contains(subView) ?? false) else { return [] } var result = subView.getNestedSubviews() as [T] if let view = subView as? T { result.append(view) } return result } } func getMoleculeViews(excludedViews: [UIView]? = nil, filter: ((T, inout Bool) -> Bool)) -> [T] { var stop = false var results: [T] = [] for element: T in getNestedSubviews(excludedViews: excludedViews) { if filter(element, &stop) { results.append(element) } if stop { break } } return results } }