251 lines
11 KiB
Swift
251 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 enum AccessibilityNotificationType: String, Codable {
|
|
|
|
case controllerChanged, layoutChanged, screenChanged, announcement, webPageChanged, webPageLoaded
|
|
|
|
//TODO: - Foucs is shifting to respective element only if we add delay only on new viewcontroller appear. Need to investigate futher.
|
|
//https://developer.apple.com/forums/thread/132699,
|
|
//https://developer.apple.com/forums/thread/655359
|
|
//By default from iOS 13+ focus is getting shifted to first interactive element inside viewcontroller not to the navigationitem left barbutton item so posting layoutChanged notification with delay to push to leftbarbutton item on new screen push
|
|
var delay: Double {
|
|
switch self {
|
|
case .controllerChanged, .webPageLoaded:
|
|
return 1.5
|
|
case .screenChanged, .layoutChanged:
|
|
return 0.0
|
|
default:
|
|
return 0.0
|
|
}
|
|
}
|
|
|
|
var accessibilityNotification: UIAccessibility.Notification {
|
|
switch self {
|
|
case .announcement:
|
|
return .announcement
|
|
case .screenChanged:
|
|
return .screenChanged
|
|
case .layoutChanged, .controllerChanged:
|
|
return .layoutChanged
|
|
case .webPageChanged, .webPageLoaded:
|
|
return .layoutChanged
|
|
}
|
|
}
|
|
}
|
|
|
|
public class AccessbilityOperation: MVMCoreOperation {
|
|
|
|
let argument: Any?
|
|
let notificationType: AccessibilityNotificationType
|
|
private var timerSource: DispatchSourceTimer?
|
|
|
|
public init(notificationType: AccessibilityNotificationType, argument: Any?) {
|
|
self.notificationType = notificationType
|
|
self.argument = argument
|
|
}
|
|
|
|
public override func main() {
|
|
Task { @MainActor in
|
|
guard UIAccessibility.isVoiceOverRunning, !checkAndHandleForCancellation() else {
|
|
stop()
|
|
return
|
|
}
|
|
timerSource = DispatchSource.makeTimerSource()
|
|
timerSource?.setEventHandler { [weak self] in
|
|
if !(self?.isCancelled ?? false), let notification = self?.notificationType.accessibilityNotification {
|
|
UIAccessibility.post(notification: notification, argument: self?.argument)
|
|
self?.markAsFinished()
|
|
} else {
|
|
self?.stop()
|
|
}
|
|
}
|
|
timerSource?.schedule(deadline: .now() + notificationType.delay)
|
|
timerSource?.activate()
|
|
}
|
|
}
|
|
|
|
public func stop() {
|
|
guard isCancelled else { return }
|
|
timerSource?.cancel()
|
|
markAsFinished()
|
|
}
|
|
}
|
|
|
|
open class AccessibilityHandler {
|
|
|
|
public static func shared() -> Self? {
|
|
guard let shared = CoreUIObject.sharedInstance()?.accessibilityHandler else { return nil }
|
|
return MVMCoreActionUtility.fatalClassCheck(object: shared)
|
|
}
|
|
public weak var delegate: MVMCoreViewControllerProtocol?
|
|
public var previousAccessiblityElement: Any?
|
|
|
|
private var accessibilityOperationQueue: OperationQueue = {
|
|
let queue = OperationQueue()
|
|
queue.maxConcurrentOperationCount = 1
|
|
return queue
|
|
}()
|
|
private var anyCancellable: Set<AnyCancellable> = []
|
|
private var accessibilityId: String?
|
|
private var announcementText: String?
|
|
private var hasTopNotitificationInPage: Bool = false
|
|
|
|
public init() {
|
|
registerWithNotificationCenter()
|
|
registerForPageChanges()
|
|
registerForFocusChanges()
|
|
registerForTopNotificationsChanges()
|
|
}
|
|
|
|
/// Registers with the notification center to know when json is updated.
|
|
private func registerWithNotificationCenter() {
|
|
NotificationCenter.default.publisher(for: NSNotification.Name(rawValue: NotificationResponseLoaded))
|
|
.sink { [weak self] notification in
|
|
self?.accessibilityId = (notification.userInfo?[String(describing: MVMCoreLoadObject.self)] as? MVMCoreLoadObject)?.pageJSON?.optionalStringForKey("accessibilityId")
|
|
self?.announcementText = (notification.userInfo?[String(describing: MVMCoreLoadObject.self)] as? MVMCoreLoadObject)?.pageJSON?.optionalStringForKey("announcementText")
|
|
}.store(in: &anyCancellable)
|
|
}
|
|
|
|
/// Registers to know when pages change.
|
|
private func registerForPageChanges() {
|
|
MVMCoreNavigationHandler.shared()?.addDelegate(self)
|
|
}
|
|
|
|
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] notification in
|
|
self?.cancelAllOperations()
|
|
}.store(in: &anyCancellable)
|
|
}
|
|
|
|
private func registerForTopNotificationsChanges() {
|
|
NotificationHandler.shared()?.onNotificationWillShow.sink { [weak self] (_, model) in
|
|
self?.hasTopNotitificationInPage = true
|
|
self?.capturePreviousFocusElement(for: model.molecule)
|
|
}.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(for: model.molecule)
|
|
}.store(in: &anyCancellable)
|
|
print(anyCancellable)
|
|
}
|
|
|
|
open func capturePreviousFocusElement(for model: MoleculeModelProtocol) {
|
|
previousAccessiblityElement = UIAccessibility.focusedElement(using: .notificationVoiceOver)
|
|
}
|
|
|
|
open func postAccessbilityToPrevElement(for model: MoleculeModelProtocol) {
|
|
post(notification: .layoutChanged, argument: previousAccessiblityElement)
|
|
}
|
|
|
|
private func add(operation: Operation) {
|
|
accessibilityOperationQueue.addOperation(operation)
|
|
}
|
|
|
|
private func cancelAllOperations() {
|
|
accessibilityOperationQueue.cancelAllOperations()
|
|
}
|
|
|
|
open func post(webpageChanged type: AccessibilityNotificationType, argument: Any? = nil) {
|
|
post(notification: type, argument: argument)
|
|
}
|
|
|
|
public func post(notification type: AccessibilityNotificationType, argument: Any? = nil) {
|
|
guard UIAccessibility.isVoiceOverRunning else { return }
|
|
let accessbilityOperation = AccessbilityOperation(notificationType: type, argument: argument)
|
|
add(operation: accessbilityOperation)
|
|
}
|
|
|
|
//To get first foucs element on the screen
|
|
open func getFirstFocusedElementOnScreen() -> Any? {
|
|
(delegate as? UIViewController)?.navigationItem.leftBarButtonItem ?? (delegate as? UIViewController)?.navigationItem.titleView ?? (delegate as? UIViewController)?.navigationController?.navigationBar
|
|
}
|
|
|
|
//Subclass can decide to trigger Accessibility notification on screen change.
|
|
open func canPostAccessbilityNotification(for viewController: UIViewController) -> Bool { true }
|
|
}
|
|
|
|
extension AccessibilityHandler: MVMCorePresentationDelegateProtocol {
|
|
|
|
public func navigationController(_ navigationController: UINavigationController, prepareDisplayFor viewController: UIViewController) {
|
|
previousAccessiblityElement = nil
|
|
delegate = viewController as? MVMCoreViewControllerProtocol
|
|
if let announcementText {
|
|
let accessbilityOperation = AccessbilityOperation(notificationType: .announcement, argument: announcementText)
|
|
add(operation: accessbilityOperation)
|
|
}
|
|
}
|
|
|
|
public func navigationController(_ navigationController: UINavigationController, displayedViewController viewController: UIViewController) {
|
|
guard UIAccessibility.isVoiceOverRunning,
|
|
canPostAccessbilityNotification(for: viewController) else { return }
|
|
//TODO: - For Tabbar change: adding 1.5 sec delay to shift focus to the top. for Temp fix added to check on childern count
|
|
/*var navigationOperationType: NavigationType = .push
|
|
if let presentationStyle = delegate?.loadObject??.pageJSON?.optionalStringForKey(KeyPresentationStyle) ?? delegate?.loadObject??.requestParameters?.actionMap?.optionalStringForKey(KeyPresentationStyle), presentationStyle == "root" {
|
|
navigationOperationType = .set
|
|
}*/
|
|
if hasTopNotitificationInPage {
|
|
previousAccessiblityElement = getFirstFocusedElementOnScreen()
|
|
} else {
|
|
let accessbilityElement = getAccessbilityFocusedElement()
|
|
post(notification: navigationController.children.count == 1 ? .controllerChanged : .layoutChanged, argument: accessbilityElement ?? getFirstFocusedElementOnScreen())
|
|
accessibilityId = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
extension AccessibilityHandler {
|
|
|
|
private func getAccessbilityFocusedElement() -> UIView? {
|
|
guard let accessibilityModels: [any AccessibilityElementProtocol] = (delegate?.delegateObject?() as? MVMCoreUIDelegateObject)?.moleculeDelegate?.getRootMolecules().allMoleculesOfType() else { return nil }
|
|
guard !accessibilityModels.isEmpty,
|
|
let accessibilityModel = (accessibilityModels.filter { $0.id == accessibilityId }).first as? MoleculeModelProtocol else {
|
|
return nil
|
|
}
|
|
return (delegate as? UIViewController)?.view?.getMoleculeViews { (subView: MoleculeViewProtocol) in
|
|
guard let moleculeModel = (subView as? MoleculeViewModelProtocol)?.getMoleculeModel() as? (any AccessibilityElementProtocol),
|
|
moleculeModel.id == (accessibilityModel as? (any AccessibilityElementProtocol))?.id else {
|
|
return false
|
|
}
|
|
return true
|
|
}.first
|
|
}
|
|
}
|
|
|
|
extension UIView {
|
|
|
|
private func getNestedSubviews<T>() -> [T] {
|
|
subviews.flatMap { subView -> [T] in
|
|
var result = subView.getNestedSubviews() as [T]
|
|
if let view = subView as? T { result.append(view) }
|
|
return result
|
|
}
|
|
}
|
|
|
|
func getMoleculeViews<T>(filter: ((T) -> Bool)) -> [T] {
|
|
return getNestedSubviews().compactMap {
|
|
filter($0) ? $0 : nil
|
|
}
|
|
}
|
|
}
|