282 lines
13 KiB
Swift
282 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.
|
|
If we have announcement notification then operation will wait untill announcement is finished.
|
|
*/
|
|
public override func main() {
|
|
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)
|
|
if self.notificationType == .announcement {///Marking task as finished only if announcement did finished so that user will listen complete announcement.
|
|
NotificationCenter.default.addObserver(forName: UIAccessibility.announcementDidFinishNotification, object: nil, queue: .main) { _ in
|
|
self.markAsFinished()
|
|
}
|
|
} else {
|
|
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/foucs 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?.moleculeDelegate as? MVMCoreViewControllerProtocol }
|
|
public weak 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 foucsed 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.
|
|
*/
|
|
open func getFirstFocusedElementOnScreen() -> Any? {
|
|
(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.
|
|
Will announce text if the page is has announcementText in the response.
|
|
*/
|
|
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")
|
|
if let announcementText = loadObject?.pageJSON?.optionalStringForKey("announcementText") {
|
|
post(notification: .announcement, argument: announcementText)
|
|
}
|
|
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 foucs 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 foucs changes.
|
|
When foucs 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
|
|
*/
|
|
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)
|
|
}
|
|
}
|