mvm_core_ui/MVMCoreUI/Accessibility/AccessibilityHandler.swift
Keerthy df2fa3405b Updated delegateObject and delegate properties
removed weak ref for delegateObject. updated delegate as delegate.loadDelegate
2023-11-02 18:46:27 +05:30

274 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)
}
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 delegate: MVMCoreViewControllerProtocol? { delegateObject?.loadDelegate as? MVMCoreViewControllerProtocol }
public var delegateObject: MVMCoreUIDelegateObject?
private var hasTopNotificationInPage: Bool { delegate?.loadObject??.responseJSON?.optionalDictionaryForKey("TopNotification") != nil || delegate?.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? {
((delegate as? PageProtocol)?.pageModel?.navigationBar?.hidden ?? false) ? nil : (delegate as? UIViewController)?.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 = (delegate as? UIViewController)?.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)
}
}