276 lines
13 KiB
Swift
276 lines
13 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
|
|
|
|
//MARK: - AccessibilityOperation
|
|
public class AccessibilityOperation: MVMCoreOperation {
|
|
|
|
private let argument: Any?
|
|
private let notificationType: UIAccessibility.Notification
|
|
|
|
public init(notificationType: UIAccessibility.Notification, argument: Any?) {
|
|
self.notificationType = notificationType
|
|
self.argument = argument
|
|
}
|
|
|
|
/**
|
|
This method will post accessibility notification.
|
|
*/
|
|
public override func main() {
|
|
guard !checkAndHandleForCancellation() else { return }
|
|
guard UIAccessibility.isVoiceOverRunning else {
|
|
markAsFinished()
|
|
return
|
|
}
|
|
Task { @MainActor [weak self] in
|
|
guard let self = self, !self.isCancelled else {
|
|
self?.markAsFinished()
|
|
return
|
|
}
|
|
UIAccessibility.post(notification: self.notificationType, argument: self.argument)
|
|
self.markAsFinished()
|
|
}
|
|
}
|
|
}
|
|
|
|
//MARK: - AccessibilityHandler
|
|
/**
|
|
AccessibilityHandler will observe the page visibility of every view controller and post notification to the first interactive element on the screen.
|
|
If we have to shift/focus custom element on the screen on controller shown then we need to pass accessibilityId in the page response.
|
|
*/
|
|
open class AccessibilityHandler {
|
|
|
|
public static func shared() -> Self? {
|
|
guard let shared = CoreUIObject.sharedInstance()?.accessibilityHandler else { return nil }
|
|
return MVMCoreActionUtility.fatalClassCheck(object: shared)
|
|
}
|
|
//TODO: Revisit to avoid state properties to store in handler.
|
|
public var accessibilityId: String? ///This property is used to post accessibility to the UIElement mapped to this accessibilityId
|
|
public var previousAccessiblityElement: Any? ///This property is capture accessiblity element
|
|
public var anyCancellable: Set<AnyCancellable> = []
|
|
public weak var currentController: UIViewController? { MVMCoreUIUtility.getCurrentVisibleController() }
|
|
public var delegateObject: MVMCoreUIDelegateObject?
|
|
//TODO: Revisit to identify the page has top notification or not.
|
|
private var hasTopNotificationInPage: Bool { (currentController as? MVMCoreViewControllerProtocol)?.loadObject??.responseJSON?.optionalDictionaryForKey("TopNotification") != nil || (currentController as? MVMCoreViewControllerProtocol)?.loadObject??.responseInfoMap?.optionalStringForKey("messageStyle") != nil }
|
|
private let accessibilityOperationQueue: OperationQueue = {
|
|
let queue = OperationQueue()
|
|
queue.maxConcurrentOperationCount = 1
|
|
return queue
|
|
}()
|
|
|
|
lazy var rotorHandler = RotorHandler(accessibilityHandler: self)
|
|
|
|
/**
|
|
init method will register for focus changes
|
|
*/
|
|
public init() {
|
|
registerForFocusChanges()
|
|
}
|
|
|
|
/**
|
|
This method will capture current focused element
|
|
*/
|
|
open func capturePreviousFocusElement() {
|
|
previousAccessiblityElement = UIAccessibility.focusedElement(using: .notificationVoiceOver)
|
|
}
|
|
|
|
/**
|
|
This method will post accessibility notification to previous captured element
|
|
*/
|
|
open func postAccessibilityToPrevElement() {
|
|
post(notification: .layoutChanged, argument: previousAccessiblityElement)
|
|
previousAccessiblityElement = nil
|
|
}
|
|
|
|
/**
|
|
This method will check if voice over is running then will post notification to the mentioned argument
|
|
*/
|
|
public func post(notification type: UIAccessibility.Notification, argument: Any? = nil) {
|
|
guard UIAccessibility.isVoiceOverRunning else { return }
|
|
let AccessibilityOperation = AccessibilityOperation(notificationType: type, argument: argument)
|
|
accessibilityOperationQueue.addOperation(AccessibilityOperation)
|
|
}
|
|
|
|
/**
|
|
This method return first focused element from the screen.
|
|
If navigationBar is hidden then we are returning nil so that voice over will shift to the first interactive element.
|
|
*/
|
|
open func getFirstFocusedElementOnScreen() -> Any? {
|
|
((currentController as? PageProtocol)?.pageModel?.navigationBar?.hidden ?? false) ? nil : currentController?.navigationController?.navigationBar
|
|
}
|
|
|
|
/**
|
|
This method is used to decide if AccessibilityHandler can post screen change notification or specific classes will take care of posting Accessibility notification
|
|
*/
|
|
open func canPostAccessibilityNotification(for viewController: UIViewController) -> Bool { true }
|
|
|
|
/**
|
|
This method is used to identify the UIElement that is mapped to accessibilityId from server response.
|
|
*/
|
|
func getPreDefinedFocusedElementIfAny() -> UIView? {
|
|
guard let accessibilityId, let view = currentController?.view else { return nil }
|
|
return MVMCoreUIUtility.findViews(by: MoleculeViewProtocol.self, views: [view]).first {
|
|
$0.model?.id == accessibilityId
|
|
}
|
|
}
|
|
}
|
|
|
|
extension AccessibilityHandler {
|
|
|
|
/**
|
|
This method is used to notify rotor handler about page loaded and capturing the accessibilityId if any.
|
|
*/
|
|
public func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) {
|
|
previousAccessiblityElement = nil
|
|
rotorHandler.onPageNew(rootMolecules: rootMolecules, delegateObject)
|
|
guard let loadObject = (delegateObject?.loadDelegate as? MVMCoreViewControllerProtocol)?.loadObject else { return }
|
|
accessibilityId = loadObject?.pageJSON?.optionalStringForKey("accessibilityId")
|
|
self.delegateObject = delegateObject
|
|
}
|
|
|
|
/**
|
|
This method is used to capture accessibility views on the screen.
|
|
If the page has accessibilityId, then it will not post any accessibility notification because respective UI mapped element can be identified only on page shown.
|
|
If it has top notification then we are capturing the first focused element and will not post any accessibility notification.
|
|
If page doesn't have any top notification or accessibilityId then it will post notification to shift focus to first focused element on the screen.
|
|
*/
|
|
public func willShowPage(_ delegateObject: MVMCoreUIDelegateObject?) {
|
|
updateAccessibilityViews(delegateObject)
|
|
guard let controller = delegateObject?.moleculeDelegate as? UIViewController,
|
|
canPostAccessibilityNotification(for: controller),
|
|
accessibilityId == nil else { return }
|
|
if hasTopNotificationInPage {
|
|
previousAccessiblityElement = getFirstFocusedElementOnScreen()
|
|
} else {
|
|
post(notification: .layoutChanged, argument: getFirstFocusedElementOnScreen())
|
|
}
|
|
}
|
|
|
|
/**
|
|
This method is used to notify rotor handler about page visibility
|
|
Temp fix: - We are resetting the view accessibilityElements when focus shifted to first focused element on the screen not to have voice over struck in between view elements.
|
|
https://developer.apple.com/forums/thread/655359
|
|
https://developer.apple.com/forums/thread/675427
|
|
If the page has accessibilityId, i.e if server decides to manually shift focus to one of the UIElement then we are identifying the id mapped UIElement & shifting focus to that element.
|
|
If we have top notification as well in the page then we take that as priority and holding the server driven UIElement in previousAccessiblityElement and post accessibility notification.
|
|
https://oneconfluence.verizon.com/display/MFD/Accessibility+-+Focus+Retain
|
|
*/
|
|
public func onPageShown(_ delegateObject: MVMCoreUIDelegateObject?) {
|
|
defer {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
(delegateObject?.moleculeDelegate as? UIViewController)?.view.accessibilityElements = nil
|
|
}
|
|
}
|
|
rotorHandler.onPageShown(delegateObject)
|
|
guard let accessibilityElement = getPreDefinedFocusedElementIfAny() else { return }
|
|
accessibilityId = nil
|
|
if hasTopNotificationInPage {
|
|
previousAccessiblityElement = accessibilityElement
|
|
} else {
|
|
post(notification: .layoutChanged, argument: accessibilityElement)
|
|
}
|
|
}
|
|
|
|
/**
|
|
This method is used to set view elements as accessibilityElements due to the accessibility behaviour change when new controller is pushed on navigation stack from iOS13+
|
|
*/
|
|
private func updateAccessibilityViews(_ delegateObject: MVMCoreUIDelegateObject?) {
|
|
guard var currentController = delegateObject?.moleculeDelegate as? UIViewController,
|
|
currentController.navigationController != nil else { return }///If no navigationController, we can go with the default behaviour of Accessibility voiceover.
|
|
var accessibilityElements: [Any?] = [MVMCoreUISplitViewController.main()?.topAlertView, MVMCoreUISplitViewController.main()?.navigationController]
|
|
if let manager = ((delegateObject?.moleculeDelegate as? MVMCoreViewManagerViewControllerProtocol)?.manager as? MVMCoreViewManagerProtocol & UIViewController),
|
|
let managerAccessibilityElements = manager.getAccessibilityElements() {
|
|
accessibilityElements.append(contentsOf: managerAccessibilityElements)
|
|
accessibilityElements.append(contentsOf: manager.view.subviews)
|
|
currentController = manager
|
|
} else {
|
|
accessibilityElements.append(contentsOf: currentController.view.subviews)
|
|
}
|
|
accessibilityElements.append(MVMCoreUISplitViewController.main()?.tabBar)
|
|
currentController.view.accessibilityElements = accessibilityElements.compactMap { $0 }
|
|
}
|
|
}
|
|
|
|
// MARK: - AccessibilityHandler listeners
|
|
@objc extension AccessibilityHandler {
|
|
|
|
/**
|
|
This method listens for focus changes.
|
|
When focus is changed manually then we are cancelling existing operations.
|
|
*/
|
|
private func registerForFocusChanges() {
|
|
//Since focus shifted to other elements cancelling existing focus shift notifications if any
|
|
NotificationCenter.default.publisher(for: UIAccessibility.elementFocusedNotification)
|
|
.sink { [weak self] _ in
|
|
self?.accessibilityOperationQueue.cancelAllOperations()
|
|
}.store(in: &anyCancellable)
|
|
}
|
|
|
|
/**
|
|
This method listens for top notification changes.
|
|
When top notification is about to display it will capture previous focused element.
|
|
When top notification is displayed it will post notification to that notification view
|
|
When top notification is about to dismiss then it will post announcement that top alert is closed.
|
|
When top notification is dimissed then it will post notification back to previously captured element.
|
|
*/
|
|
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)
|
|
}.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?.postAccessibilityToPrevElement()
|
|
}.store(in: &anyCancellable)
|
|
}
|
|
}
|
|
|
|
// MARK: - AccessibilityHandlerBehaviour
|
|
/**
|
|
To notify AccessibilityHandler about the page visibility changes
|
|
*/
|
|
//TODO: Revisit why we need a behavior as a notifier.
|
|
open class AccessibilityHandlerBehavior: PageVisibilityBehavior, PageMoleculeTransformationBehavior {
|
|
|
|
public let accessibilityHandler: AccessibilityHandler?
|
|
|
|
public init(accessibilityHandler: AccessibilityHandler?) {
|
|
self.accessibilityHandler = accessibilityHandler
|
|
}
|
|
|
|
required public init(model: PageBehaviorModelProtocol, delegateObject: MVMCoreUIDelegateObject?) {
|
|
accessibilityHandler = AccessibilityHandler.shared() //Protocol Mandatory init method.
|
|
}
|
|
|
|
open func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) {
|
|
accessibilityHandler?.onPageNew(rootMolecules: rootMolecules, delegateObject)
|
|
}
|
|
|
|
open func willShowPage(_ delegateObject: MVMCoreUIDelegateObject?) {
|
|
accessibilityHandler?.willShowPage(delegateObject)
|
|
}
|
|
|
|
open func onPageShown(_ delegateObject: MVMCoreUIDelegateObject?) {
|
|
accessibilityHandler?.onPageShown(delegateObject)
|
|
}
|
|
}
|