diff --git a/MVMCoreUI.xcodeproj/project.pbxproj b/MVMCoreUI.xcodeproj/project.pbxproj index f0e4f4f6..06d4c8ef 100644 --- a/MVMCoreUI.xcodeproj/project.pbxproj +++ b/MVMCoreUI.xcodeproj/project.pbxproj @@ -168,6 +168,7 @@ 526A265E240D200500B0D828 /* ListTwoColumnCompareChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526A265D240D200500B0D828 /* ListTwoColumnCompareChanges.swift */; }; 52B201D224081CFB00D2011E /* ListLeftVariableRadioButtonAndPaymentMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B201D024081CFB00D2011E /* ListLeftVariableRadioButtonAndPaymentMethod.swift */; }; 52B201D324081CFB00D2011E /* ListLeftVariableRadioButtonAndPaymentMethodModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B201D124081CFB00D2011E /* ListLeftVariableRadioButtonAndPaymentMethodModel.swift */; }; + 7199C8162A4F3A64001568B7 /* AccessibilityHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199C8152A4F3A64001568B7 /* AccessibilityHandler.swift */; }; 8D070BB0241B56530099AC56 /* ListRightVariableTotalDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D070BAF241B56530099AC56 /* ListRightVariableTotalDataModel.swift */; }; 8D070BB2241B56AD0099AC56 /* ListRightVariableTotalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D070BB1241B56AD0099AC56 /* ListRightVariableTotalData.swift */; }; 8D084AD02410BF4800951227 /* ListOneColumnFullWidthTextBodyTextModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D084ACF2410BF4800951227 /* ListOneColumnFullWidthTextBodyTextModel.swift */; }; @@ -753,6 +754,7 @@ 526A265D240D200500B0D828 /* ListTwoColumnCompareChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTwoColumnCompareChanges.swift; sourceTree = ""; }; 52B201D024081CFB00D2011E /* ListLeftVariableRadioButtonAndPaymentMethod.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListLeftVariableRadioButtonAndPaymentMethod.swift; sourceTree = ""; }; 52B201D124081CFB00D2011E /* ListLeftVariableRadioButtonAndPaymentMethodModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListLeftVariableRadioButtonAndPaymentMethodModel.swift; sourceTree = ""; }; + 7199C8152A4F3A64001568B7 /* AccessibilityHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityHandler.swift; sourceTree = ""; }; 8D070BAF241B56530099AC56 /* ListRightVariableTotalDataModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRightVariableTotalDataModel.swift; sourceTree = ""; }; 8D070BB1241B56AD0099AC56 /* ListRightVariableTotalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRightVariableTotalData.swift; sourceTree = ""; }; 8D084ACF2410BF4800951227 /* ListOneColumnFullWidthTextBodyTextModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListOneColumnFullWidthTextBodyTextModel.swift; sourceTree = ""; }; @@ -1449,6 +1451,14 @@ path = OneColumn; sourceTree = ""; }; + 7199C8142A4F3A40001568B7 /* Accessibility */ = { + isa = PBXGroup; + children = ( + 7199C8152A4F3A64001568B7 /* AccessibilityHandler.swift */, + ); + path = Accessibility; + sourceTree = ""; + }; 8DD1E36C243B3CD900D8F2DF /* ThreeColumn */ = { isa = PBXGroup; children = ( @@ -1972,6 +1982,7 @@ D29DF0CE21E404D4003B2FB9 /* MVMCoreUI */ = { isa = PBXGroup; children = ( + 7199C8142A4F3A40001568B7 /* Accessibility */, 01F2C1FC27C81F9700DC3D36 /* Managers */, D2ED27D8254B0C1F00A1C293 /* Alerts */, 27F973512466071600CAB5C5 /* Behaviors */, @@ -2827,6 +2838,7 @@ 0A7ECC702441001C00C828E8 /* UIToolbar+Extension.swift in Sources */, D29DF26D21E6AA0B003B2FB9 /* FLAnimatedImageView.m in Sources */, AA3561AC24C9684400452EB1 /* ListRightVariableRightCaretAllTextAndLinksModel.swift in Sources */, + 7199C8162A4F3A64001568B7 /* AccessibilityHandler.swift in Sources */, D209234F244F77FD0044AD09 /* ThreeLayerCenterTemplate.swift in Sources */, 525019E52406852100EED91C /* ListFourColumnDataUsageDividerModel.swift in Sources */, 32D2609624C19E2100B56344 /* LockupsPlanSMLXL.swift in Sources */, diff --git a/MVMCoreUI/Accessibility/AccessibilityHandler.swift b/MVMCoreUI/Accessibility/AccessibilityHandler.swift new file mode 100644 index 00000000..3ee9c3f7 --- /dev/null +++ b/MVMCoreUI/Accessibility/AccessibilityHandler.swift @@ -0,0 +1,223 @@ +// +// AccessibilityHandler.swift +// MVMCoreUI +// +// Created by Bandaru, Krishna Kishore on 30/06/23. +// Copyright © 2023 Verizon Wireless. All rights reserved. +// + +import Foundation +import Combine + +public enum AccessibilityNotificationType { + + case controllerChanged, layoutChanged, screenChanged, announcement + + //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: + 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 + } + } +} + +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 let webPageNavigated = PassthroughSubject() + + private var accessibilityOperationQueue: OperationQueue = { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 1 + return queue + }() + private var anyCancellable: Set = [] + private weak var delegate: MVMCoreViewControllerProtocol? + private var accessibilityId: String? + private var operationType: UINavigationController.Operation? + private var previousAccessiblityElement: Any? + + public init() { + registerWithNotificationCenter() + registerForPageChanges() + registerForFocusChanges() +// registerForTopNotificationsChanges() + registerForWebpageNavigation() + } + + /// 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") + }.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()?.onNotificationShown + .sink { [weak self] (view, model) in + self?.post(notification: .layoutChanged, argument: view) + }.store(in: &anyCancellable) + NotificationHandler.shared()?.onNotificationDismissed + .sink { [weak self] (view, model) in + self?.post(notification: .announcement, argument: MVMCoreUIUtility.hardcodedString(withKey: "AccTopAlertClosed")) + self?.post(notification: .screenChanged, argument: self?.previousAccessiblityElement) + }.store(in: &anyCancellable) + print(anyCancellable) + } + + private func registerForWebpageNavigation() { + webPageNavigated.sink { [weak self] _ in + self?.post(notification: .controllerChanged, argument: self?.getFirstFocusedElementOnScreen()) + }.store(in: &anyCancellable) + } + + private func add(operation: Operation) { + accessibilityOperationQueue.addOperation(operation) + } + + private func cancelAllOperations() { + accessibilityOperationQueue.cancelAllOperations() + } + + public func post(notification type: AccessibilityNotificationType, argument: Any?) { + guard UIAccessibility.isVoiceOverRunning else { return } + let accessbilityOperation = AccessbilityOperation(notificationType: type, argument: argument) + add(operation: accessbilityOperation) + previousAccessiblityElement = UIAccessibility.focusedElement(using: .notificationVoiceOver) + } + + //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) { + delegate = viewController as? MVMCoreViewControllerProtocol + operationType = navigationController.viewControllers.contains(viewController) ? .pop : .push + } + + public func navigationController(_ navigationController: UINavigationController, displayedViewController viewController: UIViewController) { + //TODO: - For push operation we are posting accessibility notification to the top left item or the identified accessibility element from pageJSON. Need to check the logic for pop operation + guard UIAccessibility.isVoiceOverRunning, + operationType == .push, + canPostAccessbilityNotification(for: viewController) else { return } + let accessbilityElement = getAccessbilityFocusedElement() + post(notification: .controllerChanged, argument: accessbilityElement ?? getFirstFocusedElementOnScreen()) + } +} + +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 + print("subview: \(subView)") + guard let moleculeModel = (subView as? MoleculeViewModelProtocol)?.getMoleculeModel() as? (any AccessibilityElementProtocol), + moleculeModel.id == (accessibilityModel as? (any AccessibilityElementProtocol))?.id else { + return false + } + return true + }.first + } + + //To get first foucs element on the screen + private func getFirstFocusedElementOnScreen() -> Any? { + (delegate as? UIViewController)?.navigationItem.leftBarButtonItem ?? (delegate as? UIViewController)?.navigationItem.titleView ?? (delegate as? UIViewController)?.navigationController?.navigationBar + } +} + +extension UIView { + + private func getNestedSubviews() -> [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(filter: ((T) -> Bool)) -> [T] { + return getNestedSubviews().compactMap { + filter($0) ? $0 : nil + } + } +} diff --git a/MVMCoreUI/Atomic/Atoms/Selectors/Toggle.swift b/MVMCoreUI/Atomic/Atoms/Selectors/Toggle.swift index 85ad8e9c..359df799 100644 --- a/MVMCoreUI/Atomic/Atoms/Selectors/Toggle.swift +++ b/MVMCoreUI/Atomic/Atoms/Selectors/Toggle.swift @@ -372,7 +372,8 @@ public typealias ActionBlockConfirmation = () -> (Bool) public override func set(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?) { super.set(with: model, delegateObject, additionalData) self.delegateObject = delegateObject - + self.model = model + guard let model = model as? ToggleModel else { return } FormValidator.setupValidation(for: model, delegate: delegateObject?.formHolderDelegate) @@ -418,3 +419,8 @@ extension Toggle { public func horizontalAlignment() -> UIStackView.Alignment { .trailing } } + +extension Toggle: MoleculeViewModelProtocol { + + public func getMoleculeModel() -> MoleculeModelProtocol? { model } +} diff --git a/MVMCoreUI/Atomic/Atoms/Selectors/ToggleModel.swift b/MVMCoreUI/Atomic/Atoms/Selectors/ToggleModel.swift index e8d50851..7336bbad 100644 --- a/MVMCoreUI/Atomic/Atoms/Selectors/ToggleModel.swift +++ b/MVMCoreUI/Atomic/Atoms/Selectors/ToggleModel.swift @@ -7,7 +7,7 @@ // -public class ToggleModel: MoleculeModelProtocol, FormFieldProtocol { +public class ToggleModel: MoleculeModelProtocol, FormFieldProtocol, AccessibilityElementProtocol { //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- @@ -30,6 +30,7 @@ public class ToggleModel: MoleculeModelProtocol, FormFieldProtocol { public var fieldKey: String? public var groupName: String = FormValidator.defaultGroupName public var baseValue: AnyHashable? + public var id: String? //-------------------------------------------------- // MARK: - Keys @@ -52,6 +53,7 @@ public class ToggleModel: MoleculeModelProtocol, FormFieldProtocol { case offKnobTintColor case fieldKey case groupName + case id } //-------------------------------------------------- @@ -124,6 +126,7 @@ public class ToggleModel: MoleculeModelProtocol, FormFieldProtocol { } enabled = try typeContainer.decodeIfPresent(Bool.self, forKey: .enabled) ?? true readOnly = try typeContainer.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false + id = try typeContainer.decodeIfPresent(String.self, forKey: .id) } public func encode(to encoder: Encoder) throws { @@ -144,5 +147,6 @@ public class ToggleModel: MoleculeModelProtocol, FormFieldProtocol { try container.encodeIfPresent(fieldKey, forKey: .fieldKey) try container.encodeIfPresent(groupName, forKey: .groupName) try container.encode(readOnly, forKey: .readOnly) + try container.encodeIfPresent(id, forKey: .id) } } diff --git a/MVMCoreUI/Atomic/Molecules/LeftRightViews/ToggleMolecules/HeadlineBodyToggleModel.swift b/MVMCoreUI/Atomic/Molecules/LeftRightViews/ToggleMolecules/HeadlineBodyToggleModel.swift index 800c976b..b9b5f81c 100644 --- a/MVMCoreUI/Atomic/Molecules/LeftRightViews/ToggleMolecules/HeadlineBodyToggleModel.swift +++ b/MVMCoreUI/Atomic/Molecules/LeftRightViews/ToggleMolecules/HeadlineBodyToggleModel.swift @@ -9,12 +9,14 @@ import Foundation -open class HeadlineBodyToggleModel: MoleculeModelProtocol { +open class HeadlineBodyToggleModel: MoleculeModelProtocol, ParentMoleculeModelProtocol { public static var identifier: String = "headlineBodyToggle" public var moleculeName: String = HeadlineBodyToggleModel.identifier open var backgroundColor: Color? open var headlineBody: HeadlineBodyModel open var toggle: ToggleModel + + public var children: [MoleculeModelProtocol] { [headlineBody, toggle] } public init(_ headlineBody: HeadlineBodyModel, _ toggle: ToggleModel) { self.headlineBody = headlineBody diff --git a/MVMCoreUI/Atomic/Protocols/MoleculeViewProtocol.swift b/MVMCoreUI/Atomic/Protocols/MoleculeViewProtocol.swift index 624f99a6..47e0ca07 100644 --- a/MVMCoreUI/Atomic/Protocols/MoleculeViewProtocol.swift +++ b/MVMCoreUI/Atomic/Protocols/MoleculeViewProtocol.swift @@ -101,3 +101,8 @@ public extension ModelRegistry { } } } + +public protocol MoleculeViewModelProtocol: UIView { + + func getMoleculeModel() -> MoleculeModelProtocol? +} diff --git a/MVMCoreUI/BaseClasses/Protocols/AccessibilityProtocol.swift b/MVMCoreUI/BaseClasses/Protocols/AccessibilityProtocol.swift index 6c7ada50..d0501874 100644 --- a/MVMCoreUI/BaseClasses/Protocols/AccessibilityProtocol.swift +++ b/MVMCoreUI/BaseClasses/Protocols/AccessibilityProtocol.swift @@ -12,3 +12,8 @@ import Foundation /// Should return the argument to use for posting a layout change. func getAccessibilityLayoutChangedArgument() -> Any? } + +public protocol AccessibilityElementProtocol: Identifiable { + + var id: String? { get set } +} diff --git a/MVMCoreUI/OtherHandlers/CoreUIObject.swift b/MVMCoreUI/OtherHandlers/CoreUIObject.swift index b96e9b93..eaf59289 100644 --- a/MVMCoreUI/OtherHandlers/CoreUIObject.swift +++ b/MVMCoreUI/OtherHandlers/CoreUIObject.swift @@ -11,8 +11,13 @@ import MVMCore @objcMembers open class CoreUIObject: MVMCoreObject { public var alertHandler: AlertHandler? - public var topNotificationHandler: NotificationHandler? - + public var topNotificationHandler: NotificationHandler? { + didSet { + accessibilityHandler = AccessibilityHandler() + } + } + public var accessibilityHandler: AccessibilityHandler? + open override func defaultInitialSetup() { CoreUIModelMapping.registerObjects() loadHandler = MVMCoreLoadHandler()