// // 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, let models: [any Identifiable] = (delegate?.delegateObject?() as? MVMCoreUIDelegateObject)?.moleculeDelegate?.getRootMolecules().allMoleculesOfType() else { return nil } guard !models.isEmpty, let model = (models.filter { ($0.id as? String) == accessibilityId }).first else { return nil } return (delegate as? UIViewController)?.view?.getMoleculeViews { (subView: MoleculeViewProtocol) in guard let moleculeModel = (subView as? MoleculeViewModelProtocol)?.moleculeModel as? (any Identifiable), (moleculeModel.id as? String) == (model.id as? String) 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 = [] required public init(model: PageBehaviorModelProtocol, delegateObject: MVMCoreUIDelegateObject?) { } public func willShowPage(_ delegateObject: MVMCoreUIDelegateObject?) { 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) } } 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 } } }