Merge remote-tracking branch 'origin/develop' into feature/molecule_replacement_behavior

This commit is contained in:
Hedden, Kyle Matthew 2023-12-05 14:39:29 -05:00
commit ac88b5d2b1
32 changed files with 781 additions and 133 deletions

View File

@ -1,66 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D29DF0CB21E404D4003B2FB9"
BuildableName = "MVMCoreUI.framework"
BlueprintName = "MVMCoreUI"
ReferencedContainer = "container:MVMCoreUI.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D29DF0CB21E404D4003B2FB9"
BuildableName = "MVMCoreUI.framework"
BlueprintName = "MVMCoreUI"
ReferencedContainer = "container:MVMCoreUI.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,275 @@
//
// 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.
*/
public override func main() {
guard !checkAndHandleForCancellation() else { return }
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)
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/focus 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)
}
//TODO: Revisit to avoid state properties to store in handler.
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 currentController: UIViewController? { MVMCoreUIUtility.getCurrentVisibleController() }
public var delegateObject: MVMCoreUIDelegateObject?
//TODO: Revisit to identify the page has top notification or not.
private var hasTopNotificationInPage: Bool { (currentController as? MVMCoreViewControllerProtocol)?.loadObject??.responseJSON?.optionalDictionaryForKey("TopNotification") != nil || (currentController as? MVMCoreViewControllerProtocol)?.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 focused 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.
If navigationBar is hidden then we are returning nil so that voice over will shift to the first interactive element.
*/
open func getFirstFocusedElementOnScreen() -> Any? {
((currentController as? PageProtocol)?.pageModel?.navigationBar?.hidden ?? false) ? nil : currentController?.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 = currentController?.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.
*/
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")
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 focus 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 focus changes.
When focus 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
*/
//TODO: Revisit why we need a behavior as a notifier.
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)
}
}

View File

@ -0,0 +1,357 @@
//
// RotorHandler.swift
// MVMCoreUI
//
// Created by Bandaru, Krishna Kishore on 13/10/23.
// Copyright © 2023 Verizon Wireless. All rights reserved.
//
import Foundation
import Combine
//MARK: - RotorType that our app supports
fileprivate enum RotorType: String, CaseIterable {
case button = "Buttons"
case header = "Header"
case link = "Link"
var trait: UIAccessibilityTraits {
switch self {
case .button:
return .button
case .header:
return .header
case .link:
return .link
}
}
///Filter block on model elements based on rotor type
var modelFilter: ((MoleculeModelProtocol) -> Bool) {
switch self {
default:
return { $0.accessibilityTraits?.contains(trait) ?? false }
}
}
///Filter block on model UIElements based on rotor type
var filter: ((UIView) -> Bool) {
switch self {
default:
return { $0.accessibilityTraits.contains(trait) && !$0.isHidden }
}
}
///Returns custom rotor of the specific type
func getUIAccessibilityCustomRotor(itemSearch: @escaping UIAccessibilityCustomRotor.Search) -> UIAccessibilityCustomRotor? {
var accessibilityCustomRotor: UIAccessibilityCustomRotor?
switch self {
case .header:
accessibilityCustomRotor = UIAccessibilityCustomRotor(systemType: .heading, itemSearch: itemSearch)
default:
accessibilityCustomRotor = UIAccessibilityCustomRotor(name: rawValue, itemSearch: itemSearch)
}
return accessibilityCustomRotor
}
}
//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)
}
public protocol RotorListTypeDelegateProtocol: RotorScrollDelegateProtocol {
func scrollToRow(at indexPath: IndexPath, animated: Bool) -> Void
func cellForRow(at indexPath: IndexPath) -> UIView?
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?///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
self.model = model
self.carouselItemModel = carouselItemModel
}
}
//MARK: - Rotor Handler
class RotorHandler {
private var rotorIndexes: [RotorType: Int] = [:]
private var rotorElements: [RotorType: [Any]] = [:]
private var anyCancellable: Set<AnyCancellable> = []
private weak var delegateObject: MVMCoreUIDelegateObject?
private weak var delegate: MVMCoreViewControllerProtocol? { delegateObject?.moleculeDelegate as? MVMCoreViewControllerProtocol }
private weak var accessibilityHandler: AccessibilityHandler?
init(accessibilityHandler: AccessibilityHandler?) {
self.accessibilityHandler = accessibilityHandler
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
guard UIAccessibility.isVoiceOverRunning, (self?.rotorElements.isEmpty ?? true) else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self?.identifyAndPrepareRotors(self?.delegateObject)
}
}.store(in: &anyCancellable)
}
//MARK: - Pagevisibility behaviour methods.
public func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) {
rotorIndexes = [:]
rotorElements = [:]
self.delegateObject = delegateObject
}
public func onPageShown(_ delegateObject: MVMCoreUIDelegateObject?) {
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 }
var customRotors: [UIAccessibilityCustomRotor] = []
for type in RotorType.allCases {
if let elements = getTraitMappedElements(currentViewController, type: type),
!elements.isEmpty,
let rotor = createRotor(for: type) {
rotorElements[type] = elements
customRotors.append(rotor)
}
}
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) {
accessibilityElements.append(contentsOf: getRotorElementsFrom(manager: manager, type: type) ?? [])
}
if let rotorElements = getRotorElementsFrom(template: template, type: type) {
accessibilityElements.append(contentsOf: rotorElements)
}
if let tabBarHidden = (template as? TabPageModelProtocol)?.tabBarHidden, !tabBarHidden {
accessibilityElements.append(contentsOf: (MVMCoreUISplitViewController.main()?.tabBar?.subviews ?? []).filter { type.filter($0) })
}
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
}
let rotorElements = getRotorElements(from: template, type: type)
let remainingRotorElements = MVMCoreUIUtility.findViews(by: UIView.self, views: [template.view], excludedViews: [template.topView, template.middleView, template.bottomView].compactMap { $0 }).filter { type.filter($0) } as [Any] //Other elements added to view if any.
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] = []
if let headerView = template?.topView {
rotorElements.append(contentsOf: MVMCoreUIUtility.findViews(by: UIView.self, views: [headerView].compactMap { $0 }).filter { type.filter($0) })
}
let molecules = templateModel.rootMolecules.filter { !($0.id == templateModel.header?.id || $0.id == templateModel.footer?.id) }
rotorElements.append(contentsOf: getRotorElements(from: molecules, template: template, type: type))
if let bottomView = template?.bottomView {
rotorElements.append(contentsOf: MVMCoreUIUtility.findViews(by: UIView.self, views: [bottomView].compactMap { $0 }).filter { type.filter($0) })
}
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?
var carouselDepth: Int?
let rotorElements: [RotorElement] = molecules.reduceDepthFirstTraverse(options: .parentFirst, depth: 0, initialResult: []) { result, model, depth in
if let listModel = model as? (ListItemModelProtocol & MoleculeModelProtocol),
let indexPath = (template as? RotorListTypeDelegateProtocol)?.getIndexPathFor(molecule: listModel) {
traitIndexPath = indexPath
}
if let _carouselDepth = carouselDepth, depth < _carouselDepth {
carouselDepth = nil
carouselItemModel = nil
}
if model is CarouselModel { carouselDepth = depth }
carouselItemModel = model is CarouselItemModelProtocol ? model : carouselItemModel
var result = result
if type.modelFilter(model) {
result.append(.init(indexPath: traitIndexPath, model: model, carouselItemModel: carouselItemModel))
}
return result
}
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
switch predicate.searchDirection {
case .next:
if rotorIndex + 1 < elements.count {
rotorIndex += 1
}
case .previous:
if rotorIndex > 0 {
rotorIndex -= 1
}
@unknown default:
rotorIndex = 0
}
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) {
var cellView: UIView?
if let indexPath = element.indexPath, let controller = self.delegate as? (any RotorListTypeDelegateProtocol) {
controller.scrollToRow(at: indexPath, animated: false)
cellView = controller.cellForRow(at: indexPath)
} else {
cellView = controller.view
}
if let cauroselItemModel = element.carouselItemModel {
guard let carousel = MVMCoreUIUtility.findViews(by: Carousel.self, views: [cellView].compactMap { $0 }).first,
let index = (carousel.molecules?.firstIndex { $0.id == cauroselItemModel.id }) else { return UIAccessibilityCustomRotorItemResult() }
let collectionIndexPath = IndexPath(item: index, section: 0)
carousel.collectionView.scrollToItem(at: collectionIndexPath, at: .left, animated: false)
let collectionViewCell = carousel.collectionView.cellForItem(at: collectionIndexPath)
rotorElement = MVMCoreUIUtility.findViews(by: MoleculeViewProtocol.self, views: [collectionViewCell].compactMap { $0 }).filter { type.filter($0) && $0.model?.id == element.model.id }.first as Any
} else {
rotorElement = MVMCoreUIUtility.findViews(by: MoleculeViewProtocol.self, views: [cellView].compactMap { $0 }).filter { type.filter($0) && $0.model?.id == element.model.id }.first as Any
if let viewElement = (rotorElement as? UIView) {
let convertedFrame = viewElement.convert(viewElement.frame, to: controller.view)
(self.delegate as? RotorScrollDelegateProtocol)?.scrollRectToVisible(convertedFrame, animated: false)
}
}
} else {
if let viewElement = (rotorElement as? UIView) {
let convertedFrame = viewElement.convert(viewElement.frame, to: (self.delegate as? UIViewController)?.view)
(self.delegate as? RotorScrollDelegateProtocol)?.scrollRectToVisible(convertedFrame, animated: false)
}
}
self.rotorIndexes[type] = rotorIndex
self.accessibilityHandler?.post(notification: .layoutChanged, argument: rotorElement)
return UIAccessibilityCustomRotorItemResult(targetElement: rotorElement as! NSObjectProtocol, targetRange: nil)
}
}
}
//MARK: - Protocol Extensions
public extension RotorViewElementsProtocol {
var topView: UIView? { nil }
var middleView: UIView? { nil }
var bottomView: UIView? { nil }
}
extension RotorScrollDelegateProtocol {
public func scrollRectToVisible(_ rect: CGRect, animated: Bool) { }
}
extension RotorListTypeDelegateProtocol {
public func scrollToRow(at indexPath: IndexPath, animated: Bool) { }
public func cellForRow(at indexPath: IndexPath) -> UIView? { nil }
public func getIndexPathFor(molecule: MoleculeModelProtocol) -> IndexPath? { nil }
}
extension ThreeLayerTableViewController: RotorListTypeDelegateProtocol {
public func scrollToRow(at indexPath: IndexPath, animated: Bool) {
tableView.scrollToRow(at: indexPath, at: .middle, animated: animated)
}
public func cellForRow(at indexPath: IndexPath) -> UIView? {
tableView.cellForRow(at: indexPath)
}
public func getIndexPathFor(molecule: MoleculeModelProtocol) -> IndexPath? {
guard let molecule = molecule as? (ListItemModelProtocol & MoleculeModelProtocol) else { return nil }
return (self as? MoleculeListTemplate)?.getIndexPath(for: molecule)
}
}
extension ThreeLayerViewController: RotorScrollDelegateProtocol {
public func scrollRectToVisible(_ rect: CGRect, animated: Bool) {
scrollView?.scrollRectToVisible(rect, animated: animated)
}
}
extension ThreeLayerCollectionViewController: RotorListTypeDelegateProtocol {
public func scrollToRow(at indexPath: IndexPath, animated: Bool) {
collectionView?.scrollToItem(at: indexPath, at: .centeredVertically, animated: animated)
}
public func cellForRow(at indexPath: IndexPath) -> UIView? {
collectionView?.cellForItem(at: indexPath)
}
public func getIndexPathFor(molecule: MoleculeModelProtocol) -> IndexPath? {
guard let molecule = molecule as? (CollectionItemModelProtocol & MoleculeModelProtocol) else { return nil }
return (self as? MoleculeCollectionListProtocol)?.getIndexPath(for: molecule)
}
}

View File

@ -372,7 +372,7 @@ 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
guard let model = model as? ToggleModel else { return }
FormValidator.setupValidation(for: model, delegate: delegateObject?.formHolderDelegate)

View File

@ -77,9 +77,10 @@ public class ToggleModel: MoleculeModelProtocol, FormFieldProtocol {
// MARK: - Initializer
//--------------------------------------------------
public init(_ state: Bool) {
public init(_ state: Bool, id: String = UUID().uuidString) {
self.selected = state
baseValue = state
self.id = id
}
//--------------------------------------------------

View File

@ -213,9 +213,7 @@ open class BarsIndicatorView: CarouselIndicator {
let accessibleIndex = MVMCoreUIUtility.getOrdinalString(forIndex: NSNumber(value: index + 1))
else { return }
let accessibilityValue = String(format: accessibleValueFormat, accessibleIndex, numberOfPages)
view.accessibilityLabel = accessibilityValue
view.accessibilityIdentifier = accessibilityValue
view.accessibilityLabel = String(format: accessibleValueFormat, accessibleIndex, numberOfPages)
}
public override func assessTouchOf(_ touchPoint_X: CGFloat) {

View File

@ -15,7 +15,7 @@ public enum CheckboxPosition: String, Codable {
case bottom
}
@objcMembers open class CheckboxLabelModel: MoleculeModelProtocol {
@objcMembers open class CheckboxLabelModel: MoleculeModelProtocol, ParentMoleculeModelProtocol {
open class var identifier: String { "checkboxLabel" }
public var moleculeName: String = CheckboxLabelModel.identifier
@DecodableDefault.UUIDString public var id: String
@ -25,6 +25,7 @@ public enum CheckboxPosition: String, Codable {
public var checkbox: CheckboxModel
public var label: LabelModel
public var children: [MoleculeModelProtocol] { [checkbox, label] }
//--------------------------------------------------
// MARK: - Initializer
//--------------------------------------------------

View File

@ -44,6 +44,7 @@ public typealias ActionBlock = () -> ()
public var shouldMaskWhileRecording: Bool = false
public var model: MoleculeModelProtocol?
//------------------------------------------------------
// MARK: - Multi-Action Text
//------------------------------------------------------
@ -408,6 +409,7 @@ public typealias ActionBlock = () -> ()
attributedText = attributedString
originalAttributedString = attributedText
}
self.model = labelModel
}
@objc public static func setUILabel(_ label: UILabel?, withJSON json: [AnyHashable: Any]?, delegate: DelegateObject?, additionalData: [AnyHashable: Any]?) {

View File

@ -13,8 +13,11 @@ import Foundation
//--------------------------------------------------
// MARK: - Properties
//--------------------------------------------------
var progressBarModel: ProgressBarModel?
public var model: MoleculeModelProtocol?
public var progressBarModel: ProgressBarModel? {
get { model as? ProgressBarModel }
set { model = newValue }
}
var thickness: CGFloat = 8.0 {
willSet(newValue) {

View File

@ -18,7 +18,13 @@ open class Tilelet: VDS.Tilelet, VDSMoleculeViewProtocol{
//--------------------------------------------------
// MARK: - Properties
//--------------------------------------------------
public var viewModel: TileletModel!
public var model: MoleculeModelProtocol?
public var viewModel: TileletModel! {
get { model as? TileletModel }
set { model = newValue }
}
public var delegateObject: MVMCoreUIDelegateObject?
public var additionalData: [AnyHashable: Any]?

View File

@ -9,7 +9,13 @@ import VDSColorTokens
@objcMembers open class TabBar: UITabBar, MoleculeViewProtocol, TabBarProtocol, UITabBarDelegate {
public var model: TabBarModel
public var model: MoleculeModelProtocol?
public var tabModel: TabBarModel {
get { model as! TabBarModel }
set { model = newValue }
}
public var delegateObject: MVMCoreUIDelegateObject?
public let line = Line()
@ -68,31 +74,35 @@ import VDSColorTokens
// MARK: - UITabBarDelegate
public func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
model.selectedTab = item.tag
let action = model.tabs[item.tag].action
tabModel.selectedTab = item.tag
let action = tabModel.tabs[item.tag].action
Task(priority: .userInitiated) {
try await Button.performButtonAction(with: action, button: item, delegateObject: delegateObject, additionalData: nil)
}
}
// MARK: - TabBarProtocol
@MainActor
public func highlightTab(at index: Int) {
MVMCoreDispatchUtility.performBlock(onMainThread: {
guard let newSelectedItem = self.items?[index] else { return }
self.model.selectedTab = index
self.selectedItem = newSelectedItem
})
guard let items = items, index >= 0, index < items.count else {
MVMCoreLoggingHandler.shared()?.addError(toLog: MVMCoreErrorObject(title: nil, messageToLog: "Invalid tab index \(index). \(items?.count ?? 0) tabs available .", code: 0, domain: ErrorDomainSystem, location: #function)!)
return
}
tabModel.selectedTab = index
selectedItem = items[index]
}
@MainActor
public func selectTab(at index: Int) {
MVMCoreDispatchUtility.performBlock(onMainThread: {
guard let newSelectedItem = self.items?[index] else { return }
self.selectedItem = newSelectedItem
self.tabBar(self, didSelect: newSelectedItem)
})
guard let items = items, index >= 0, index < items.count else {
MVMCoreLoggingHandler.shared()?.addError(toLog: MVMCoreErrorObject(title: nil, messageToLog: "Invalid tab index \(index). \(items?.count ?? 0) tabs available.", code: 0, domain: ErrorDomainSystem, location: #function)!)
return
}
selectedItem = items[index]
tabBar(self, didSelect: items[index])
}
public func currentTabIndex() -> Int { model.selectedTab }
public func currentTabIndex() -> Int { tabModel.selectedTab }
}
extension UITabBarItem: MFButtonProtocol { }

View File

@ -18,6 +18,7 @@
public var hideArrow: Bool?
public var line: LineModel?
public var style: ListItemStyle?
public var accessibilityText: String?
//--------------------------------------------------
// MARK: - Keys
@ -29,6 +30,7 @@
case hideArrow
case line
case style
case accessibilityText
}
//--------------------------------------------------
@ -102,6 +104,7 @@
hideArrow = try typeContainer.decodeIfPresent(Bool.self, forKey: .hideArrow)
line = try typeContainer.decodeIfPresent(LineModel.self, forKey: .line)
style = try typeContainer.decodeIfPresent(ListItemStyle.self, forKey: .style)
accessibilityText = try typeContainer.decodeIfPresent(String.self, forKey: .accessibilityText)
try super.init(from: decoder)
}
@ -113,5 +116,6 @@
try container.encodeIfPresent(hideArrow, forKey: .hideArrow)
try container.encodeIfPresent(line, forKey: .line)
try container.encodeIfPresent(style, forKey: .style)
try container.encodeIfPresent(accessibilityText, forKey: .accessibilityText)
}
}

View File

@ -9,13 +9,15 @@
import Foundation
open class HeadlineBodyToggleModel: MoleculeModelProtocol {
open class HeadlineBodyToggleModel: MoleculeModelProtocol, ParentMoleculeModelProtocol {
public static var identifier: String = "headlineBodyToggle"
public var moleculeName: String = HeadlineBodyToggleModel.identifier
@DecodableDefault.UUIDString public var id: String
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

View File

@ -88,15 +88,15 @@
var message = ""
if let eyebrowLabel = eyebrow.text {
if let eyebrowLabel = eyebrow.accessibilityLabel ?? eyebrow.text {
message += eyebrowLabel + ", "
}
if let headlineLabel = headline.text {
if let headlineLabel = headline.accessibilityLabel ?? headline.text {
message += headlineLabel + ", "
}
if let bodyLabel = body.text {
if let bodyLabel = body.accessibilityLabel ?? body.text {
message += bodyLabel
}

View File

@ -376,9 +376,7 @@ open class Carousel: View {
self.carouselAccessibilityElement = carouselAccessibilityElement
}
if let currentCell = collectionView.cellForItem(at: IndexPath(row: currentIndex, section: 0)), let pagingView = self.pagingView {
_accessibilityElements = [currentCell, carouselAccessibilityElement, pagingView]
} else if let currentCell = collectionView.cellForItem(at: IndexPath(row: currentIndex, section: 0)) {
if let currentCell = collectionView.cellForItem(at: IndexPath(row: currentIndex, section: 0)) {
_accessibilityElements = [currentCell, carouselAccessibilityElement]
} else {
_accessibilityElements = [carouselAccessibilityElement]

View File

@ -12,6 +12,9 @@ import Foundation
public protocol AccessibilityModelProtocol {
var accessibilityIdentifier: String? { get set }
var accessibilityTraits: UIAccessibilityTraits? { get set }
var accessibilityText: String? { get set }
var accessibilityValue: String? { get set }
}
public extension AccessibilityModelProtocol {
@ -20,4 +23,19 @@ public extension AccessibilityModelProtocol {
get { nil }
set { }
}
var accessibilityTraits: UIAccessibilityTraits? {
get { nil }
set { }
}
var accessibilityText: String? {
get { nil }
set { }
}
var accessibilityValue: String? {
get { nil }
set { }
}
}

View File

@ -37,3 +37,9 @@ public extension MVMCoreUIDelegateObject {
return (moleculeDelegate as? MoleculeListProtocol & NSObjectProtocol)
}
}
public protocol MoleculeCollectionListProtocol {
/// Asks the delegate for the index of molecule.
func getIndexPath(for molecule: CollectionItemModelProtocol & MoleculeModelProtocol) -> IndexPath?
}

View File

@ -12,6 +12,8 @@ import MVMCore.MVMCoreViewProtocol
public protocol MoleculeViewProtocol: UIView, ModelHandlerProtocol {
var model: MoleculeModelProtocol? { get set }
/// Initializes the view with the model
init(model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?)
@ -33,6 +35,11 @@ public protocol MoleculeViewProtocol: UIView, ModelHandlerProtocol {
extension MoleculeViewProtocol {
public var model: MoleculeModelProtocol? {
get { nil }
set { }
}
/// Calls set with model
public init(model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?) {
self.init(frame: .zero)

View File

@ -13,10 +13,10 @@ import Foundation
var delegateObject: MVMCoreUIDelegateObject? { get set }
/// Should visually select the given tab index.
@objc func highlightTab(at index: Int)
@MainActor func highlightTab(at index: Int)
/// Should select the tab index. As if the user selected it.
@objc func selectTab(at index: Int)
@MainActor func selectTab(at index: Int)
/// Returns the current tab
@objc func currentTabIndex() -> Int

View File

@ -202,3 +202,11 @@
return modules
}
}
extension CollectionTemplate: MoleculeCollectionListProtocol {
public func getIndexPath(for molecule: CollectionItemModelProtocol & MoleculeModelProtocol) -> IndexPath? {
guard let index = (moleculesInfo?.firstIndex { $0.molecule.id == molecule.id }) else { return nil }
return IndexPath(item: index, section: 0)
}
}

View File

@ -35,9 +35,4 @@ open class ModalMoleculeListTemplate: MoleculeListTemplate {
MVMCoreUIActionHandler.performActionUnstructured(with: closeAction, additionalData: nil, delegateObject: self.delegateObject())
})
}
open override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
accessibilityElements = [closeButton as Any, tableView as Any]
}
}

View File

@ -9,10 +9,14 @@
import Foundation
/// A view controller that has three main layers, a header, collection rows, and a footer. The header is added as a supplement header to the first section, and the footer is added as a supplement footer to the last section. This view controller allows for flexible space between the three layers to fit the screeen.
@objc open class ThreeLayerCollectionViewController: ProgrammaticCollectionViewController, UICollectionViewDelegateFlowLayout {
@objc open class ThreeLayerCollectionViewController: ProgrammaticCollectionViewController, UICollectionViewDelegateFlowLayout, RotorViewElementsProtocol {
private var topView: UIView?
private var bottomView: UIView?
public var topView: UIView?
public var middleView: UIView? {
set {}
get { collectionView }
}
public var bottomView: UIView?
private var headerView: ContainerCollectionReusableView?
private var footerView: ContainerCollectionReusableView?
private let headerID = "header"

View File

@ -8,15 +8,20 @@
import UIKit
open class ThreeLayerTableViewController: ProgrammaticTableViewController {
open class ThreeLayerTableViewController: ProgrammaticTableViewController, RotorViewElementsProtocol {
//--------------------------------------------------
// MARK: - Main Views
//--------------------------------------------------
private var topView: UIView?
private var bottomView: UIView?
private var headerView: UIView?
private var footerView: UIView?
public var topView: UIView?
public var middleView: UIView? {
get { tableView }
set { }
}
public var bottomView: UIView?
//--------------------------------------------------
// MARK: - Properties

View File

@ -9,12 +9,12 @@
import UIKit
open class ThreeLayerViewController: ProgrammaticScrollViewController {
open class ThreeLayerViewController: ProgrammaticScrollViewController, RotorViewElementsProtocol {
// The three main views
var topView: UIView?
var middleView: UIView?
var bottomView: UIView?
public var topView: UIView?
public var middleView: UIView?
public var bottomView: UIView?
var useMargins: Bool = false
// The top view can be put outside of the scrolling area.

View File

@ -83,6 +83,11 @@ import Combine
}
extension NavigationController: MVMCoreViewManagerProtocol {
public func getAccessibilityElements() -> [Any]? {
nil
}
public func getCurrentViewController() -> UIViewController? {
guard let topViewController = topViewController else { return nil }
return MVMCoreUIUtility.getViewControllerTraversingManagers(topViewController)

View File

@ -249,6 +249,11 @@ public extension MVMCoreUISplitViewController {
}
extension MVMCoreUISplitViewController: MVMCoreViewManagerProtocol {
public func getAccessibilityElements() -> [Any]? {
nil
}
public func getCurrentViewController() -> UIViewController? {
navigationController?.getCurrentViewController()
}

View File

@ -308,6 +308,10 @@ open class SubNavManagerController: ViewController, MVMCoreViewManagerProtocol,
}
}
@objc public func getAccessibilityElements() -> [Any]? {
[tabs]
}
open func newDataReceived(in viewController: UIViewController) {
manager?.newDataReceived?(in: viewController)
hideNavigationBarLine(true)

View File

@ -25,16 +25,6 @@ public class NotificationContainerView: UIView {
super.init(coder: coder)
setupView()
}
/// Posts a layout change with taking the arguments from the view following the AccessibilityProtocol.
private func updateAccessibilityForTopAlert(_ view: UIView) {
// Update accessibility with top alert
var accessibilityArgument: Any? = view
if let view = view as? AccessibilityProtocol {
accessibilityArgument = view.getAccessibilityLayoutChangedArgument()
}
UIAccessibility.post(notification: .layoutChanged, argument: accessibilityArgument)
}
}
extension NotificationContainerView: NotificationTransitionDelegateProtocol {
@ -56,7 +46,6 @@ extension NotificationContainerView: NotificationTransitionDelegateProtocol {
self.superview?.layoutIfNeeded()
} completion: { finished in
self.superview?.layoutIfNeeded()
self.updateAccessibilityForTopAlert(notification)
continuation.resume()
}
}
@ -64,14 +53,11 @@ extension NotificationContainerView: NotificationTransitionDelegateProtocol {
@MainActor
public func hide(notification: UIView) async {
// accessibility - below line added to notify VI user through voiceover user when the top alert is closed
UIAccessibility.post(notification: .screenChanged, argument: MVMCoreUIUtility.hardcodedString(withKey: "AccTopAlertClosed"))
await withCheckedContinuation { continuation in
UIView.animate(withDuration: 0.5) {
self.height.isActive = true
self.superview?.layoutIfNeeded()
} completion: { finished in
UIAccessibility.post(notification: .layoutChanged, argument: nil)
self.currentNotificationView?.removeFromSuperview()
self.currentNotificationView = nil
continuation.resume()

View File

@ -9,14 +9,19 @@
import UIKit
import MVMCore
@objcMembers
@objcMembers
public class CoreUIObject: NSObject {
private static var singleton = CoreUIObject()
public static func sharedInstance() -> CoreUIObject? { singleton }
private override init() {}
public var alertHandler: AlertHandler?
public var topNotificationHandler: NotificationHandler?
public var topNotificationHandler: NotificationHandler? {
didSet {
accessibilityHandler?.registerForTopNotificationsChanges()
}
}
public var accessibilityHandler: AccessibilityHandler?
public func defaultInitialSetup() {
MVMCoreObject.sharedInstance()?.defaultInitialSetup()
@ -26,5 +31,6 @@ public class CoreUIObject: NSObject {
MVMCoreObject.sharedInstance()?.viewControllerMapping = MVMCoreUIViewControllerMappingObject()
MVMCoreObject.sharedInstance()?.loggingDelegate = MVMCoreUILoggingHandler()
alertHandler = AlertHandler()
accessibilityHandler = AccessibilityHandler()
}
}

View File

@ -75,7 +75,9 @@
// MARK: Carousel
"MVMCoreUIPageControl_currentpage_index" = "page %@ of %d";
"MVMCoreUIPageControlslides_currentpage_index" = "slide %@ of %d";
"MVMCoreUIPageControlslides_currentpage_index" = "slide %@ of %d selected";
"MVMCoreUIPageControlslides_currentpage_index_accessibilityAnnouncement" = "slide %@ of %d";
"MVMCoreUIPageControlslides_totalslides" = "Carousel containing %d slides,";
// MARK: Styler

View File

@ -60,7 +60,10 @@
// Carousel
"MVMCoreUIPageControl_currentpage_index" = "página %@ de %d";
"MVMCoreUIPageControlslides_currentpage_index" = "diapositiva %@ of %d";
"MVMCoreUIPageControlslides_currentpage_index" = "diapositiva %@ of %d seleccionado";
"MVMCoreUIPageControlslides_currentpage_index_accessibilityAnnouncement" = "diapositiva %@ of %d";
"MVMCoreUIPageControlslides_totalslides" = "Carrusel contiene %d diapositivas,";
//Styler
"CountDownDay" = " día";
"CountDownHour" = " hora";

View File

@ -39,7 +39,7 @@ public extension MVMCoreUIUtility {
/// - type: The type you are looking for.
/// - views: The starting array of subviews.
/// - Returns: Will return an array of any view associated with the given type. Will return an empty array of none were found.
static func findViews<T>(by type: T.Type, views: [UIView]) -> [T] {
static func findViews<T>(by type: T.Type, views: [UIView], excludedViews: [UIView] = []) -> [T] {
guard !views.isEmpty else { return [] }
@ -47,6 +47,9 @@ public extension MVMCoreUIUtility {
var matching = [T]()
for view in views {
guard !excludedViews.contains(view) else {
continue
}
if view is T {
matching.append(view as! T)
}
@ -54,7 +57,7 @@ public extension MVMCoreUIUtility {
queue.append(contentsOf: view.subviews)
}
return findViews(by: type, views: queue) + matching
return findViews(by: type, views: queue, excludedViews: excludedViews) + matching
}
@MainActor