diff --git a/MVMCoreUI/Accessibility/AccessibilityHandler.swift b/MVMCoreUI/Accessibility/AccessibilityHandler.swift index 32d6a0f2..29b727b6 100644 --- a/MVMCoreUI/Accessibility/AccessibilityHandler.swift +++ b/MVMCoreUI/Accessibility/AccessibilityHandler.swift @@ -11,6 +11,7 @@ import Combine import MVMCore import WebKit +//MARK: - AccessibilityOperation public class AccessibilityOperation: MVMCoreOperation { private let argument: Any? @@ -21,6 +22,10 @@ public class AccessibilityOperation: MVMCoreOperation { 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() @@ -32,7 +37,7 @@ public class AccessibilityOperation: MVMCoreOperation { return } UIAccessibility.post(notification: self.notificationType, argument: self.argument) - if self.notificationType == .announcement { + 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() } @@ -43,14 +48,19 @@ public class AccessibilityOperation: MVMCoreOperation { } } +//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? - public var previousAccessiblityElement: Any? + 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 = [] public weak var delegate: MVMCoreViewControllerProtocol? { delegateObject?.moleculeDelegate as? MVMCoreViewControllerProtocol } public weak var delegateObject: MVMCoreUIDelegateObject? @@ -63,34 +73,52 @@ open class AccessibilityHandler { lazy var rotorHandler = RotorHandler(accessibilityHandler: self) + /** + init method will register for focus changes + */ public init() { registerForFocusChanges() } - // MARK: - Accessibility Handler operation events + /** + This method will capture current foucsed element + */ open func capturePreviousFocusElement() { previousAccessiblityElement = UIAccessibility.focusedElement(using: .notificationVoiceOver) } - open func postAccessbilityToPrevElement() { + /** + 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) } - //To get first focus element on the screen + /** + This method return first focused element from 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 } + /** + 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 { @@ -99,13 +127,12 @@ open class AccessibilityHandler { } } -/** - 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 { + /** + 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) @@ -117,11 +144,16 @@ extension AccessibilityHandler { self.delegateObject = delegateObject } - //MARK: - PageVisibiltyBehaviour + /** + 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, - canPostAccessbilityNotification(for: controller), + canPostAccessibilityNotification(for: controller), accessibilityId == nil else { return } if hasTopNotificationInPage { previousAccessiblityElement = getFirstFocusedElementOnScreen() @@ -130,8 +162,15 @@ extension AccessibilityHandler { } } - ///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 + /** + 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) { @@ -148,7 +187,9 @@ extension AccessibilityHandler { } } - //MARK: - Accessibility Methods + /** + 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. @@ -166,9 +207,13 @@ extension AccessibilityHandler { } } +// MARK: - AccessibilityHandler listeners @objc extension AccessibilityHandler { - // MARK: - Register with Accessibility Handler listeners + /** + 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) @@ -177,6 +222,13 @@ extension AccessibilityHandler { }.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 @@ -194,13 +246,15 @@ extension AccessibilityHandler { }.store(in: &anyCancellable) NotificationHandler.shared()?.onNotificationDismissed .sink { [weak self] (view, model) in - self?.postAccessbilityToPrevElement() + self?.postAccessibilityToPrevElement() }.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. +// MARK: - AccessibilityHandlerBehaviour +/** + To notify AccessibilityHandler about the page visibility changes + */ open class AccessibilityHandlerBehavior: PageVisibilityBehavior, PageMoleculeTransformationBehavior { public let accessibilityHandler: AccessibilityHandler? @@ -213,12 +267,10 @@ open class AccessibilityHandlerBehavior: PageVisibilityBehavior, PageMoleculeTra 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) } diff --git a/MVMCoreUI/Accessibility/RotorHandler.swift b/MVMCoreUI/Accessibility/RotorHandler.swift index 3c3fdbce..916b4c8d 100644 --- a/MVMCoreUI/Accessibility/RotorHandler.swift +++ b/MVMCoreUI/Accessibility/RotorHandler.swift @@ -9,11 +9,13 @@ import Foundation import Combine +//MARK: - RotorType that our app supports fileprivate enum RotorType: String, CaseIterable { case button = "Buttons" case header = "Header" case checkbox = "Checkbox" + case link = "Link" var trait: UIAccessibilityTraits { switch self { @@ -21,11 +23,14 @@ fileprivate enum RotorType: String, CaseIterable { return .button case .header: return .header + case .link: + return .link default: return .none } } + ///Filter block on model elements based on rotor type var modelFilter: ((MoleculeModelProtocol) -> Bool) { switch self { case .checkbox: @@ -35,6 +40,7 @@ fileprivate enum RotorType: String, CaseIterable { } } + ///Filter block on model UIElements based on rotor type var filter: ((UIView) -> Bool) { switch self { case .checkbox: @@ -44,6 +50,7 @@ fileprivate enum RotorType: String, CaseIterable { } } + ///Returns custom rotor of the specific type func getUIAccessibilityCustomRotor(itemSearch: @escaping UIAccessibilityCustomRotor.Search) -> UIAccessibilityCustomRotor? { var accessibilityCustomRotor: UIAccessibilityCustomRotor? switch self { @@ -56,12 +63,14 @@ fileprivate enum RotorType: String, CaseIterable { } } +//MARK: - RotorViewElementsProtocol public protocol RotorViewElementsProtocol: MVMCoreViewControllerProtocol { var topView: UIView? { get set } var middleView: UIView? { get set } var bottomView: UIView? { get set } } +//MARK: - RotorProtocols public protocol RotorScrollDelegateProtocol: MVMCoreViewControllerProtocol { func scrollRectToVisible(_ rect: CGRect, animated: Bool) } @@ -72,11 +81,12 @@ public protocol RotorListTypeDelegateProtocol: RotorScrollDelegateProtocol { func getIndexPathFor(molecule: MoleculeModelProtocol) -> IndexPath? } +//MARK: - Rotor Element - RotorElement info when we are traversing on model elements in the page. struct RotorElement { let indexPath: IndexPath? let model: MoleculeModelProtocol - let carouselItemModel: MoleculeModelProtocol? + let carouselItemModel: MoleculeModelProtocol?///This element is the parent of model item if we have rotor element inside carousel. This is used to scroll to specific carousel item when rotor mode is enabled. init(indexPath: IndexPath? = nil, model: MoleculeModelProtocol, carouselItemModel: MoleculeModelProtocol? = nil) { self.indexPath = indexPath @@ -85,6 +95,7 @@ struct RotorElement { } } +//MARK: - Rotor Handler class RotorHandler { private var rotorIndexes: [RotorType: Int] = [:] @@ -99,6 +110,7 @@ class RotorHandler { registerForVoiceOverChanges() } + ///Preparing rotors when accessibility voiceover is turned after page is loaded. private func registerForVoiceOverChanges() { NotificationCenter.default.publisher(for: UIAccessibility.voiceOverStatusDidChangeNotification) .sink { [weak self] _ in @@ -109,6 +121,7 @@ class RotorHandler { }.store(in: &anyCancellable) } + //MARK: - Pagevisibility behaviour methods. public func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) { rotorIndexes = [:] rotorElements = [:] @@ -119,6 +132,11 @@ class RotorHandler { identifyAndPrepareRotors(delegateObject) } + //MARK: - Rotor methods + /** + This method prepares custom rotors that will be assigned to the current controller. + Rotor will be created only if the page contains that trait mapped element or the conditions met. + */ private func identifyAndPrepareRotors(_ delegateObject: MVMCoreUIDelegateObject?) { guard UIAccessibility.isVoiceOverRunning, let currentViewController = (delegateObject?.moleculeDelegate as? (MVMCoreViewControllerProtocol & UIViewController)) else { return } @@ -134,6 +152,9 @@ class RotorHandler { currentViewController.view.accessibilityCustomRotors = customRotors } + /** + This method prepares trait mapped elements of the current controller and from its manager if exists. + */ private func getTraitMappedElements(_ template: (MVMCoreViewControllerProtocol & UIViewController)?, type: RotorType) -> [Any]? { var accessibilityElements: [Any?] = [] if let manager = (template as? MVMCoreViewManagerViewControllerProtocol)?.manager as? (MVMCoreViewControllerProtocol & UIViewController) { @@ -148,11 +169,19 @@ class RotorHandler { return accessibilityElements.compactMap { $0 } } + /** + This method prepares trait mapped elements from manager + */ private func getRotorElementsFrom(manager: (MVMCoreViewControllerProtocol & UIViewController)?, type: RotorType) -> [Any]? { guard let elements = (manager as? MVMCoreViewManagerProtocol)?.getAccessibilityElements() as? [UIView] else { return nil } return MVMCoreUIUtility.findViews(by: MoleculeViewProtocol.self, views: elements).filter { type.filter($0) } as [Any] } + /** + This method prepares triat mapped elements from the current viewcontroller. + For BAU pages: This will traverse through the view hierarchy for trait mapped elements. + For Molecular pages: This will traverse through the models to identify the trait mapped model. Along with traversed models, again we are traversing on view hierarchy if any subViews are added to view to get trait mapped elements. + */ private func getRotorElementsFrom(template: (MVMCoreViewControllerProtocol & UIViewController)?, type: RotorType) -> [Any]? { guard let template = template as? (RotorViewElementsProtocol & MVMCoreViewControllerProtocol & ViewController) else { return MVMCoreUIUtility.findViews(by: UIView.self, views: [template?.view].compactMap { $0 }).filter { type.filter($0) } as [Any] //BAU pages @@ -162,6 +191,10 @@ class RotorHandler { return rotorElements + remainingRotorElements } + /** + This method is to get rotor elements form Molecular pages only. + We are filtering out header, molecules, footer from rootMolcules because rootMolcules may or maynot have the correct order of molecule models(header, body, footer). If we don't maintain order, Voiceover might first go footer then body elements then header.(Safety step) + */ private func getRotorElements(from template: (MVMCoreViewControllerProtocol & ViewController & RotorViewElementsProtocol)?, type: RotorType) -> [Any] { guard let templateModel = template?.model as? ThreeLayerModelBase else { return [] } var rotorElements: [Any] = [] @@ -176,6 +209,11 @@ class RotorHandler { return rotorElements } + /** + This method actually travers through the molecules and identify triat mapped model element along with indexPath of the element if its List/Collection templates. + Along with model, indexPath we are also capturing carouselItemModel because when we have Carousel inside the molecule, we need to scroll to carousel item if we have trait mapped rotor element inside the Carousel - (Multi Scroll behaviour) + Identiying the CarouselModel, CarouselItemModel by depth. + */ private func getRotorElements(from molecules: [MoleculeModelProtocol], template: (MVMCoreViewControllerProtocol & ViewController)?, type: RotorType) -> [Any] { var traitIndexPath: IndexPath? var carouselItemModel: MoleculeModelProtocol? @@ -200,20 +238,37 @@ class RotorHandler { return rotorElements as [Any] } + /** + This method creates a rotor based on the RotorType. + UIAccessibilityCustomRotor.Search Predicate block is used to return the current rotor UI element + If the rotor element is of type UIElement(subclasses of UIView) then it will post the accessibility notification directly to that UI element and scrolling that element to the visible area. + If rotor element is of type RotorElement then + 1. If we have indexPath in rotorElement then the template will scroll (list/collection) to that indexPath, and try to capture the cell view. + 2. After the cell view is captured, traversing the cell hierarchy which matches the trait & id of that view's model. + 3. After identifying the element, then will post the accessibility notification directly to that UI element + If we have carouselItemModel in RotorElement then + 1. If we have indexPath in rotorElement then the template will scroll (list/collection) to that indexPath, and try to capture the cell view. + 2. After cell is identified then we are identifying Carousel from the view hierarchy & scroll to the Carousel item which matches the carouselItemModel.id + 3. After carouselItemModel is scrolled then traversing the carouselCellItem hierarchy which matches the trait & id of that view's model + 4. After identifying the element, then will post the accessibility notification directly to that UI element + */ private func createRotor(for type: RotorType) -> UIAccessibilityCustomRotor? { return type.getUIAccessibilityCustomRotor { [weak self] predicate in guard let self, let elements = self.rotorElements[type] else { return UIAccessibilityCustomRotorItemResult() } var rotorIndex = self.rotorIndexes[type] ?? -1 - if predicate.searchDirection == .next { + switch predicate.searchDirection { + case .next: if rotorIndex + 1 < elements.count { rotorIndex += 1 } - } else { + case .previous: if rotorIndex > 0 { rotorIndex -= 1 } + @unknown default: + rotorIndex = 0 } - guard rotorIndex >= 0 else { return UIAccessibilityCustomRotorItemResult() } + guard rotorIndex >= 0, !elements.isEmpty else { return UIAccessibilityCustomRotorItemResult() } //Safety check to avoid crash. var rotorElement = elements[rotorIndex] if let element = rotorElement as? RotorElement, let controller = self.delegate as? (RotorViewElementsProtocol & ViewController) { @@ -251,6 +306,7 @@ class RotorHandler { } } +//MARK: - Protocol Extensions public extension RotorViewElementsProtocol { var topView: UIView? { nil } var middleView: UIView? { nil } diff --git a/MVMCoreUI/Atomic/Organisms/Carousel/Carousel.swift b/MVMCoreUI/Atomic/Organisms/Carousel/Carousel.swift index ea7d89b8..6b360120 100644 --- a/MVMCoreUI/Atomic/Organisms/Carousel/Carousel.swift +++ b/MVMCoreUI/Atomic/Organisms/Carousel/Carousel.swift @@ -384,7 +384,7 @@ open class Carousel: View { extension Carousel: UICollectionViewDelegateFlowLayout { open func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - let itemWidth = collectionView.bounds.width * itemWidthPercent + let itemWidth = (collectionView.bounds.width - collectionView.contentInset.left - collectionView.contentInset.right) * itemWidthPercent return CGSize(width: itemWidth, height: collectionView.bounds.height) }