321 lines
16 KiB
Swift
321 lines
16 KiB
Swift
//
|
|
// 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<AnyCancellable> = []
|
|
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<AnyCancellable> = []
|
|
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>() -> [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<T>(filter: ((T) -> Bool)) -> [T] {
|
|
return getNestedSubviews().compactMap {
|
|
filter($0) ? $0 : nil
|
|
}
|
|
}
|
|
}
|