mvm_core_ui/MVMCoreUI/Accessibility/AccessibilityHandler.swift
Krishna Kishore Bandaru 88505e704c addressed review comments
2023-10-04 20:08:46 +05:30

351 lines
17 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
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)
self.markAsFinished()
}
}
public func stop() {
guard isCancelled else { return }
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
}()
public var accessibilityId: String?
public var hasTopNotificationInPage: Bool = false
public init() {
registerForPageChanges()
registerForFocusChanges()
}
// MARK: - Register with Accessibility Handler listeners
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
if self?.previousAccessiblityElement == nil {
self?.capturePreviousFocusElement()
}
}.store(in: &anyCancellable)
NotificationHandler.shared()?.onNotificationShown
.sink { [weak self] (view, model) in
self?.post(notification: .layoutChanged, argument: view)
self?.hasTopNotificationInPage = false
}.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 subNavManagerController = (operation.toNavigationControllerViewControllers?.last as? MVMCoreViewManagerViewControllerProtocol)?.manager 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)?.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 as? MoleculeViewModelProtocol)?.moleculeModel?.id == accessibilityId
}
}
}
// 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 {
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<AnyCancellable> = []
private var delegateObj: MVMCoreUIDelegateObject?
private var rotorIndexes: [RotorType: Int] = [:]
required public init(model: PageBehaviorModelProtocol, delegateObject: MVMCoreUIDelegateObject?) { }
public func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) {
guard let loadObject = (delegateObject?.loadDelegate as? MVMCoreViewControllerProtocol)?.loadObject else { return }
AccessibilityHandler.shared()?.accessibilityId = loadObject?.pageJSON?.optionalStringForKey("accessibilityId")
//TODO: - Need to revisit this logic
AccessibilityHandler.shared()?.hasTopNotificationInPage = loadObject?.responseJSON?.optionalDictionaryForKey("TopNotification") != nil || loadObject?.responseInfoMap?.optionalStringForKey("userMessage") != nil
if let announcementText = loadObject?.pageJSON?.optionalStringForKey("announcementText") {
AccessibilityHandler.shared()?.post(notification: .announcement, argument: announcementText)
}
}
//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()?.hasTopNotificationInPage ?? 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 }
AccessibilityHandler.shared()?.accessibilityId = nil
if AccessibilityHandler.shared()?.hasTopNotificationInPage ?? false {
AccessibilityHandler.shared()?.previousAccessiblityElement = accessibilityElement
} else {
AccessibilityHandler.shared()?.post(notification: .layoutChanged, argument: accessibilityElement)
}
}
//MARK: - Accessibility Methods
private func updateAccessibilityViews(_ delegateObject: MVMCoreUIDelegateObject?) {
//TODO: - Need to revisit this logic
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)] = [] ///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.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 = MVMCoreUIUtility.findViews(by: MoleculeViewProtocol.self, views: [tableView.cellForRow(at: element.indexPath)].compactMap { $0 }).filter { $0.accessibilityTraits.contains(type.trait) && ($0 as? MoleculeViewModelProtocol)?.moleculeModel?.id == element.model.id }.first as Any
}
self.rotorIndexes[type] = rotorIndex
AccessibilityHandler.shared()?.post(notification: .layoutChanged, argument: rotorElement)
return UIAccessibilityCustomRotorItemResult(targetElement: rotorElement as! NSObjectProtocol, targetRange: nil)
}
}
}