235 lines
11 KiB
Swift
235 lines
11 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)
|
|
if self.notificationType == .announcement {
|
|
NotificationCenter.default.addObserver(forName: UIAccessibility.announcementDidFinishNotification, object: nil, queue: .main) { _ in
|
|
self.markAsFinished()
|
|
}
|
|
} else {
|
|
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 accessibilityId: String?
|
|
public var previousAccessiblityElement: Any?
|
|
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)
|
|
|
|
public init() {
|
|
registerForFocusChanges()
|
|
}
|
|
|
|
// MARK: - Accessibility Handler operation events
|
|
open func capturePreviousFocusElement() {
|
|
previousAccessiblityElement = UIAccessibility.focusedElement(using: .notificationVoiceOver)
|
|
}
|
|
|
|
open func postAccessbilityToPrevElement() {
|
|
post(notification: .layoutChanged, argument: previousAccessiblityElement)
|
|
previousAccessiblityElement = nil
|
|
}
|
|
|
|
public func post(notification type: UIAccessibility.Notification, argument: Any? = nil) {
|
|
guard UIAccessibility.isVoiceOverRunning else { return }
|
|
let accessbilityOperation = AccessbilityOperation(notificationType: type, argument: argument)
|
|
accessibilityOperationQueue.addOperation(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.model?.id == accessibilityId
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
When we push a new viewcontroller on to a Navigation stack from iOS 13+ Accessibility voiceover is going to the element inside of the viewcontroller. Not treating navigationController left/back bar button as first element. So alternatively we are setting accessibility elements in viewWillAppear untill viewDidAppear then we are resetting back So that there will not be accessibility order issue.
|
|
https://developer.apple.com/forums/thread/655359
|
|
https://developer.apple.com/forums/thread/675427
|
|
*/
|
|
extension AccessibilityHandler {
|
|
|
|
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
|
|
}
|
|
|
|
//MARK: - PageVisibiltyBehaviour
|
|
public func willShowPage(_ delegateObject: MVMCoreUIDelegateObject?) {
|
|
updateAccessibilityViews(delegateObject)
|
|
guard let controller = delegateObject?.moleculeDelegate as? UIViewController,
|
|
canPostAccessbilityNotification(for: controller),
|
|
accessibilityId == nil else { return }
|
|
if hasTopNotificationInPage {
|
|
previousAccessiblityElement = getFirstFocusedElementOnScreen()
|
|
} else {
|
|
post(notification: .layoutChanged, argument: getFirstFocusedElementOnScreen())
|
|
}
|
|
}
|
|
|
|
///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
|
|
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)
|
|
}
|
|
}
|
|
|
|
//MARK: - Accessibility Methods
|
|
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 }
|
|
}
|
|
}
|
|
|
|
@objc extension AccessibilityHandler {
|
|
|
|
// MARK: - Register with Accessibility Handler listeners
|
|
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)
|
|
}
|
|
|
|
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?.postAccessbilityToPrevElement()
|
|
}.store(in: &anyCancellable)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
|
|
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.
|
|
}
|
|
|
|
//MARK: - PageMoleculeTransformationBehavior
|
|
open func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) {
|
|
accessibilityHandler?.onPageNew(rootMolecules: rootMolecules, delegateObject)
|
|
}
|
|
|
|
//MARK: - PageVisibiltyBehaviour
|
|
open func willShowPage(_ delegateObject: MVMCoreUIDelegateObject?) {
|
|
accessibilityHandler?.willShowPage(delegateObject)
|
|
}
|
|
|
|
open func onPageShown(_ delegateObject: MVMCoreUIDelegateObject?) {
|
|
accessibilityHandler?.onPageShown(delegateObject)
|
|
}
|
|
}
|