diff --git a/MVMCoreUI/Atomic/Atoms/Views/CarouselIndicator/BarsCarouselIndicatorModel.swift b/MVMCoreUI/Atomic/Atoms/Views/CarouselIndicator/BarsCarouselIndicatorModel.swift index 1b5cae14..314d74c5 100644 --- a/MVMCoreUI/Atomic/Atoms/Views/CarouselIndicator/BarsCarouselIndicatorModel.swift +++ b/MVMCoreUI/Atomic/Atoms/Views/CarouselIndicator/BarsCarouselIndicatorModel.swift @@ -32,6 +32,10 @@ open class BarsCarouselIndicatorModel: CarouselIndicatorModel { // MARK: - Codec //-------------------------------------------------- + public override init() { + super.init() + } + public required init(from decoder: Decoder) throws { let typeContainer = try decoder.container(keyedBy: CodingKeys.self) diff --git a/MVMCoreUI/Atomic/Atoms/Views/CarouselIndicator/CarouselIndicatorModel.swift b/MVMCoreUI/Atomic/Atoms/Views/CarouselIndicator/CarouselIndicatorModel.swift index 8bf3fa19..1a9486f6 100644 --- a/MVMCoreUI/Atomic/Atoms/Views/CarouselIndicator/CarouselIndicatorModel.swift +++ b/MVMCoreUI/Atomic/Atoms/Views/CarouselIndicator/CarouselIndicatorModel.swift @@ -61,6 +61,7 @@ open class CarouselIndicatorModel: CarouselPagingModelProtocol, MoleculeModelPro //-------------------------------------------------- // MARK: - Codec //-------------------------------------------------- + public init() {} required public init(from decoder: Decoder) throws { let typeContainer = try decoder.container(keyedBy: CodingKeys.self) diff --git a/MVMCoreUI/Atomic/Organisms/Carousel.swift b/MVMCoreUI/Atomic/Organisms/Carousel.swift index 808c370e..00a05fc8 100644 --- a/MVMCoreUI/Atomic/Organisms/Carousel.swift +++ b/MVMCoreUI/Atomic/Organisms/Carousel.swift @@ -55,7 +55,7 @@ open class Carousel: View { /// The view that we use for paging public var pagingView: (UIView & CarouselPageControlProtocol)? - + /// If the carousel should loop after scrolling past the first and final cells. public var loop = false @@ -179,7 +179,7 @@ open class Carousel: View { numberOfPages = newMolecules.count molecules = newMolecules - if carouselModel?.loop ?? false && newMolecules.count > 1 { + if carouselModel?.loop ?? false && newMolecules.count > 1 && !UIAccessibility.isVoiceOverRunning { // Sets up the row data with buffer cells on each side (for illusion of endless scroll... also has one more buffer cell on each side in case we can peek that cell). loop = true @@ -277,31 +277,57 @@ open class Carousel: View { } } + func trackSwipeActionAnalyticsforIndex(_ index : Int){ + guard let itemModel = molecules?[index], + let viewControllerObject = delegateObject?.moleculeDelegate as? MVMCoreViewControllerProtocol else { return } + MVMCoreUILoggingHandler.shared()?.defaultLogAction(forController: viewControllerObject, actionInformation: itemModel.toJSON(), additionalData: nil) + } + + //-------------------------------------------------- + // MARK: - Accessibility + //-------------------------------------------------- + + /// Sets accessibility for the cell. Only the current cell is accessible. public func setAccessiblity(_ cell: UICollectionViewCell?, index: Int) { guard let cell = cell else { return } - if index == currentIndex { cell.accessibilityElementsHidden = false - var array = cell.accessibilityElements - if let pagingView = pagingView { - if let acc = pagingView.accessibilityElements { - array?.append(contentsOf: acc) - } else { - array?.append(pagingView) - } - } - - accessibilityElements = array + // set to nil to get fresh elements + accessibilityElements = nil } else { cell.accessibilityElementsHidden = true } } - func trackSwipeActionAnalyticsforIndex(_ index : Int){ - guard let itemModel = molecules?[index], - let viewControllerObject = delegateObject?.moleculeDelegate as? MVMCoreViewControllerProtocol else { return } - MVMCoreUILoggingHandler.shared()?.defaultLogAction(forController: viewControllerObject, actionInformation: itemModel.toJSON(), additionalData: nil) + /// Accessibility element that allows for adjustable carousel. + private lazy var carouselAccessibilityElement: CarouselAccessibilityElement = { + let accessibilityElement = CarouselAccessibilityElement(accessibilityContainer: self) + accessibilityElement.accessibilityFrameInContainerSpace = collectionView.frame + return accessibilityElement + }() + + private var _accessibilityElements: [Any]? + + /// Returns the accessibilityElements. If nil, will return current cell and carouselAccessibilityElement + override open var accessibilityElements: [Any]? { + set { + _accessibilityElements = newValue + } + + get { + // Only return custom accessibility if nil. + guard _accessibilityElements == nil else { + return _accessibilityElements + } + + if let currentCell = collectionView.cellForItem(at: IndexPath(row: currentIndex, section: 0)) { + _accessibilityElements = [currentCell, carouselAccessibilityElement] + } else { + _accessibilityElements = [carouselAccessibilityElement] + } + return _accessibilityElements + } } } @@ -347,7 +373,6 @@ extension Carousel: UIScrollViewDelegate { /// Go to the cell at the specified index. func goTo(_ index: Int, animated: Bool) { - showPeaking(false) setAccessiblity(collectionView.cellForItem(at: IndexPath(row: currentIndex, section: 0)), index: index) currentIndex = index @@ -378,7 +403,7 @@ extension Carousel: UIScrollViewDelegate { } } } - + open func scrollViewDidScroll(_ scrollView: UIScrollView) { // Adjust for looping @@ -395,12 +420,15 @@ extension Carousel: UIScrollViewDelegate { // Disable peaking when dragging. dragging = true + guard !UIAccessibility.isVoiceOverRunning else { return } + showPeaking(false) } public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { dragging = false + guard !UIAccessibility.isVoiceOverRunning else { return } // This is for setting up smooth custom paging. (Since UICollectionView only handles paging based on collection view size and not cell size). Math requires that we are using UICollectionViewFlowLayout. guard (model as? CarouselModel)?.paging == true, @@ -464,3 +492,106 @@ extension Carousel: UIScrollViewDelegate { trackSwipeActionAnalyticsforIndex(pageIndex) } } + +// Adapted from: https://developer.apple.com/documentation/uikit/accessibility_for_ios_and_tvos/delivering_an_exceptional_accessibility_experience +/// Ensures a good accessibility experience. Adds adjustable swiping for cards. +class CarouselAccessibilityElement: UIAccessibilityElement { + + /// This indicates to the user what exactly this element is supposed to be. + override var accessibilityLabel: String? { + get { + guard let containerView = accessibilityContainer as? Carousel, + let accessibilityLabel = containerView.accessibilityLabel else { return super.accessibilityLabel } + return accessibilityLabel + } + set { + super.accessibilityLabel = newValue + } + } + + override var accessibilityValue: String? { + get { + // Read which card we are on. + guard let containerView = accessibilityContainer as? Carousel, + let format = MVMCoreUIUtility.hardcodedString(withKey: "index_string_of_total"), + let indexString = MVMCoreUIUtility.getOrdinalString(forIndex: NSNumber(value: containerView.currentIndex + 1)) else { + return super.accessibilityValue + } + return String(format: format, indexString, containerView.numberOfPages) + } + + set { + super.accessibilityValue = newValue + } + } + + // This tells VoiceOver that our element will support the increment and decrement callbacks. + /// - Tag: accessibility_traits + override var accessibilityTraits: UIAccessibilityTraits { + get { + return .adjustable + } + set { + super.accessibilityTraits = newValue + } + } + + /** + A convenience for forward scrolling in both `accessibilityIncrement` and `accessibilityScroll`. + It returns a `Bool` because `accessibilityScroll` needs to know if the scroll was successful. + */ + func accessibilityScrollForward() -> Bool { + guard let containerView = accessibilityContainer as? Carousel else { return false } + + let newIndex = containerView.currentIndex + 1 + guard newIndex < containerView.numberOfPages else { return false } + + containerView.goTo(newIndex, animated: false) + + return true + } + + /** + A convenience for backward scrolling in both `accessibilityIncrement` and `accessibilityScroll`. + It returns a `Bool` because `accessibilityScroll` needs to know if the scroll was successful. + */ + func accessibilityScrollBackward() -> Bool { + guard let containerView = accessibilityContainer as? Carousel else { return false } + + let newIndex = containerView.currentIndex - 1 + guard newIndex >= 0 else { return false } + + containerView.goTo(newIndex, animated: false) + + return true + } + + /* + Overriding the following two methods allows the user to perform increment and decrement actions + (done by swiping up or down). + */ + /// - Tag: accessibility_increment_decrement + override func accessibilityIncrement() { + // This causes the picker to move forward one if the user swipes up. + _ = accessibilityScrollForward() + } + + override func accessibilityDecrement() { + // This causes the picker to move back one if the user swipes down. + _ = accessibilityScrollBackward() + } + + /* + This will cause the picker to move forward or backwards on when the user does a 3-finger swipe, + depending on the direction of the swipe. The return value indicates whether or not the scroll was successful, + so that VoiceOver can alert the user if it was not. + */ + override func accessibilityScroll(_ direction: UIAccessibilityScrollDirection) -> Bool { + if direction == .left { + return accessibilityScrollForward() + } else if direction == .right { + return accessibilityScrollBackward() + } + return false + } +} diff --git a/MVMCoreUI/Atomic/Organisms/CarouselModel.swift b/MVMCoreUI/Atomic/Organisms/CarouselModel.swift index 20915799..ca0a4a10 100644 --- a/MVMCoreUI/Atomic/Organisms/CarouselModel.swift +++ b/MVMCoreUI/Atomic/Organisms/CarouselModel.swift @@ -32,7 +32,8 @@ import UIKit public var useHorizontalMargins: Bool? public var leftPadding: CGFloat? public var rightPadding: CGFloat? - + public var accessibilityText: String? + public init(molecules: [MoleculeModelProtocol & CarouselItemModelProtocol]) { self.molecules = molecules } @@ -57,6 +58,7 @@ import UIKit case useHorizontalMargins case leftPadding case rightPadding + case accessibilityText } //-------------------------------------------------- @@ -83,6 +85,7 @@ import UIKit useHorizontalMargins = try typeContainer.decodeIfPresent(Bool.self, forKey: .useHorizontalMargins) leftPadding = try typeContainer.decodeIfPresent(CGFloat.self, forKey: .leftPadding) rightPadding = try typeContainer.decodeIfPresent(CGFloat.self, forKey: .rightPadding) + accessibilityText = try typeContainer.decodeIfPresent(String.self, forKey: .accessibilityText) } public func encode(to encoder: Encoder) throws { @@ -101,5 +104,6 @@ import UIKit try container.encodeIfPresent(useHorizontalMargins, forKey: .useHorizontalMargins) try container.encodeIfPresent(leftPadding, forKey: .leftPadding) try container.encodeIfPresent(rightPadding, forKey: .rightPadding) + try container.encodeIfPresent(accessibilityText, forKey: .accessibilityText) } }