// // 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) in guard let modelElement, let moleculeModel = (subView as? MoleculeViewModelProtocol)?.moleculeModel, moleculeModel.id == modelElement.id else { return false } 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. struct AccessibilityHandlerBehaviorModel: PageBehaviorModelProtocol { var shouldAllowMultipleInstances = false static var identifier = "accessibilityHandlerBehaviorModel" } class AccessibilityHandlerBehavior: PageVisibilityBehavior { private var delegateObj: MVMCoreUIDelegateObject? private var anyCancellable: Set = [] private var accessibilityButtons: [Any]? private var currentRotorIndex: Int = 0 required public init(model: PageBehaviorModelProtocol, delegateObject: MVMCoreUIDelegateObject?) { } //MARK: - PageVisibiltyBehaviour public 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 func onPageShown(_ delegateObject: MVMCoreUIDelegateObject?) { updateAccessibilityViews(delegateObject) //To track FAB & HAB elements on UI 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: - Private Methods private func identifyAndPrepareForButtonRotor() { let currentViewController = ((delegateObj?.moleculeDelegate as? MVMCoreViewManagerViewControllerProtocol)?.manager as? SubNavManagerController) ?? (delegateObj?.moleculeDelegate as? UIViewController) var rotorElements: [(model: MoleculeModelProtocol, indexPath: IndexPath)] = [] var currentIndexPath: IndexPath? rotorElements = (currentViewController as? MoleculeListTemplate)?.templateModel?.reduceDepthFirstTraverse(options: .parentFirst, depth: 0, initialResult: rotorElements, nextPartialResult: { result, model, depth in if let listModel = model as? (ListItemModelProtocol & MoleculeModelProtocol), let indexPath = (currentViewController as? MoleculeListTemplate)?.getIndexPath(for: listModel) { currentIndexPath = indexPath } var result = result if (model.accessibilityTraits?.contains(.button) ?? false), let currentIndexPath { result.append((model, currentIndexPath)) } return result }) ?? [] var accessibilityButtons: [Any?]? = currentViewController?.navigationItem.leftBarButtonItems ?? [] accessibilityButtons?.append(contentsOf: currentViewController?.navigationItem.rightBarButtonItems ?? []) if let tabs = (currentViewController as? SubNavManagerController)?.tabs { accessibilityButtons?.append(contentsOf: tabs.subviews.filter { $0.accessibilityTraits.contains(.button) }) } accessibilityButtons?.append(contentsOf: rotorElements) if let tabBarHidden = (delegateObj?.moleculeDelegate as? TabPageModelProtocol)?.tabBarHidden, !tabBarHidden { accessibilityButtons?.append(contentsOf: (MVMCoreUISplitViewController.main()?.tabBar?.subviews ?? []).filter { $0.accessibilityTraits.contains(.button)}) } self.accessibilityButtons = accessibilityButtons?.compactMap { $0 } currentViewController?.navigationController?.accessibilityCustomRotors = [createRotorForButtons()].compactMap { $0 } } private func createRotorForButtons() -> UIAccessibilityCustomRotor? { guard let accessibilityButtons, !accessibilityButtons.isEmpty, let tableView = (delegateObj?.moleculeListDelegate as? MoleculeListTemplate)?.tableView else { return nil } return UIAccessibilityCustomRotor(name: "Buttons") { [weak self] predicate in guard let self, let accessibilityButtons = self.accessibilityButtons else { return UIAccessibilityCustomRotorItemResult() } if predicate.searchDirection == .next { self.currentRotorIndex += 1 if self.currentRotorIndex > accessibilityButtons.count { self.currentRotorIndex = 1 } } else { self.currentRotorIndex -= 1 if self.currentRotorIndex <= 0 { self.currentRotorIndex = accessibilityButtons.count } } var rotorElement = accessibilityButtons[self.currentRotorIndex - 1] if let element = rotorElement as? (model: MoleculeModelProtocol, indexPath: IndexPath) { tableView.scrollToRow(at: element.indexPath, at: .middle, animated: false) rotorElement = tableView.cellForRow(at: element.indexPath)?.getMoleculeViews { (subView: MoleculeViewProtocol) in subView.accessibilityTraits.contains(.button) }.filter { ($0 as? MoleculeViewModelProtocol)?.moleculeModel?.id == element.model.id } as Any } UIAccessibility.post(notification: .layoutChanged, argument: rotorElement) return UIAccessibilityCustomRotorItemResult(targetElement: rotorElement as! NSObjectProtocol, targetRange: nil) } } 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: - Helpers extension UIView { private func getNestedSubviews() -> [T] { subviews.flatMap { subView -> [T] in var result = subView.getNestedSubviews() as [T] if let view = subView as? T { result.append(view) } return result } } func getMoleculeViews(filter: ((T) -> Bool)) -> [T] { return getNestedSubviews().compactMap { filter($0) ? $0 : nil } } }