Skip to content

Commit

Permalink
Simplify and improve accessibility implementation (#284)
Browse files Browse the repository at this point in the history
* Accessibility fixes

* Update example project

* Fix CI
  • Loading branch information
bryankeller authored Nov 28, 2023
1 parent 76b8bd8 commit 8d3f745
Show file tree
Hide file tree
Showing 13 changed files with 209 additions and 965 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed an issue that could cause off-screen items to appear or disappear instantly, rather than animating in or out during animated content changes
- Fixed an issue that caused a SwiftUI view being used as a calendar item to not receive calls to `onAppear`
- Fixed an accessibility issue that prevented scrolling callbacks from firing when scrolling via voiceover.
- Fixed an issue that caused Voice Over users to be unable to reliably navigate by heading

### Changed
- Removed all deprecated code, simplifying the public API in preparation for a 2.0 release
Expand All @@ -34,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed `ItemView` to determine user interaction capabilities from its content view's `hitTest` / `pointInside` functions
- Updated content-change animations so that the same scroll offset is maintained throughout the animation
- Changed the Swift version needed to use HorizonCalendar to 5.8
- Simplified accessibility (Voice Over) support so that it works consistently for calendars containing UIKit and SwiftUI views


## [v1.16.0](https://github.com/airbnb/HorizonCalendar/compare/v1.15.0...v1.16.0) - 2023-01-30

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ final class SwiftUIItemModelsDemoViewController: BaseDemoViewController {
Spacer()
}
.padding(.vertical)
.accessibilityAddTraits(.isHeader)
.calendarItemModel
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,21 @@ struct SwiftUIScreenDemo: View {

.monthHeaders { month in
let monthHeaderText = monthDateFormatter.string(from: calendar.date(from: month.components)!)
if case .vertical = monthsLayout {
HStack {
Group {
if case .vertical = monthsLayout {
HStack {
Text(monthHeaderText)
.font(.title2)
Spacer()
}
.padding()
} else {
Text(monthHeaderText)
.font(.title2)
Spacer()
.padding()
}
.padding()
} else {
Text(monthHeaderText)
.font(.title2)
.padding()
}
.accessibilityAddTraits(.isHeader)
}

.days { day in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ struct SwiftUIDayView: View {
.aspectRatio(1, contentMode: .fill)
Text("\(dayNumber)").foregroundColor(Color(UIColor.label))
}
.accessibilityAddTraits(.isButton)
}

}
Expand Down
8 changes: 4 additions & 4 deletions HorizonCalendar.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
939E692B24837E0300A8BCC7 /* Dictionary+MutatingValueForKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E691E24837E0200A8BCC7 /* Dictionary+MutatingValueForKey.swift */; };
939E692D24837E0300A8BCC7 /* LayoutItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E692024837E0300A8BCC7 /* LayoutItem.swift */; };
939E692E24837E0300A8BCC7 /* ScrollToItemContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E692124837E0300A8BCC7 /* ScrollToItemContext.swift */; };
939E692F24837E0300A8BCC7 /* OffScreenCalendarItemAccessibilityElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E692224837E0300A8BCC7 /* OffScreenCalendarItemAccessibilityElement.swift */; };
939E693024837E0300A8BCC7 /* FrameProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E692324837E0300A8BCC7 /* FrameProvider.swift */; };
939E693724837E8700A8BCC7 /* CalendarItemModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E693224837E8700A8BCC7 /* CalendarItemModel.swift */; };
939E693824837E8700A8BCC7 /* CalendarViewScrollPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939E693324837E8700A8BCC7 /* CalendarViewScrollPosition.swift */; };
Expand Down Expand Up @@ -71,6 +70,7 @@
FD6E1D1A2991C50100B480A6 /* CalendarViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6E1D192991C50100B480A6 /* CalendarViewRepresentable.swift */; };
FD8CF37A2988A06C00D0482D /* MonthGridBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8CF3792988A06C00D0482D /* MonthGridBackgroundView.swift */; };
FD9CDA90293B5E4D00A77188 /* SubviewInsertionIndexTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9CDA8F293B5E4D00A77188 /* SubviewInsertionIndexTracker.swift */; };
FDA33E8C2B16660D00DCB8EB /* CalendarScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA33E8B2B16660D00DCB8EB /* CalendarScrollView.swift */; };
FDB6D6BD298CB1A600A17668 /* Hasher+CGRect.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB6D6BC298CB1A600A17668 /* Hasher+CGRect.swift */; };
FDD8EE6E2984C08A00F6EC9D /* ColorViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD8EE6D2984C08A00F6EC9D /* ColorViewRepresentable.swift */; };
FDE2893C28F8A6D50020EBF1 /* SwiftUIWrapperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE2893B28F8A6D50020EBF1 /* SwiftUIWrapperView.swift */; };
Expand Down Expand Up @@ -115,7 +115,6 @@
939E691E24837E0200A8BCC7 /* Dictionary+MutatingValueForKey.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Dictionary+MutatingValueForKey.swift"; sourceTree = "<group>"; };
939E692024837E0300A8BCC7 /* LayoutItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LayoutItem.swift; sourceTree = "<group>"; };
939E692124837E0300A8BCC7 /* ScrollToItemContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollToItemContext.swift; sourceTree = "<group>"; };
939E692224837E0300A8BCC7 /* OffScreenCalendarItemAccessibilityElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OffScreenCalendarItemAccessibilityElement.swift; sourceTree = "<group>"; };
939E692324837E0300A8BCC7 /* FrameProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameProvider.swift; sourceTree = "<group>"; };
939E693224837E8700A8BCC7 /* CalendarItemModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalendarItemModel.swift; sourceTree = "<group>"; };
939E693324837E8700A8BCC7 /* CalendarViewScrollPosition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalendarViewScrollPosition.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -154,6 +153,7 @@
FD6E1D192991C50100B480A6 /* CalendarViewRepresentable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalendarViewRepresentable.swift; sourceTree = "<group>"; };
FD8CF3792988A06C00D0482D /* MonthGridBackgroundView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MonthGridBackgroundView.swift; sourceTree = "<group>"; };
FD9CDA8F293B5E4D00A77188 /* SubviewInsertionIndexTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubviewInsertionIndexTracker.swift; sourceTree = "<group>"; };
FDA33E8B2B16660D00DCB8EB /* CalendarScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarScrollView.swift; sourceTree = "<group>"; };
FDB6D6BC298CB1A600A17668 /* Hasher+CGRect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Hasher+CGRect.swift"; sourceTree = "<group>"; };
FDD8EE6D2984C08A00F6EC9D /* ColorViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorViewRepresentable.swift; sourceTree = "<group>"; };
FDE2893B28F8A6D50020EBF1 /* SwiftUIWrapperView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUIWrapperView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -277,6 +277,7 @@
9396F3E12483285D008AD306 /* Internal */ = {
isa = PBXGroup;
children = (
FDA33E8B2B16660D00DCB8EB /* CalendarScrollView.swift */,
939E69432484784D00A8BCC7 /* Calendar+Helpers.swift */,
FD33BADD2A7249D600C1DA27 /* CGFloat+MaxLayoutValue.swift */,
FDD8EE6D2984C08A00F6EC9D /* ColorViewRepresentable.swift */,
Expand All @@ -288,7 +289,6 @@
939E691D24837E0200A8BCC7 /* ItemViewReuseManager.swift */,
939E692024837E0300A8BCC7 /* LayoutItem.swift */,
939E691824837E0200A8BCC7 /* LayoutItemTypeEnumerator.swift */,
939E692224837E0300A8BCC7 /* OffScreenCalendarItemAccessibilityElement.swift */,
9391F15525C097DF001D14A2 /* PaginationHelpers.swift */,
93A361F3248332AE00E6544A /* ScreenPixelAlignment.swift */,
939E691924837E0200A8BCC7 /* ScrollMetricsMutator.swift */,
Expand Down Expand Up @@ -449,11 +449,11 @@
939E693A24837E8700A8BCC7 /* CalendarViewContent.swift in Sources */,
FD6E1D1A2991C50100B480A6 /* CalendarViewRepresentable.swift in Sources */,
FDD8EE6E2984C08A00F6EC9D /* ColorViewRepresentable.swift in Sources */,
939E692F24837E0300A8BCC7 /* OffScreenCalendarItemAccessibilityElement.swift in Sources */,
FDE2893C28F8A6D50020EBF1 /* SwiftUIWrapperView.swift in Sources */,
939E694624847BA300A8BCC7 /* DayOfWeekPosition.swift in Sources */,
FD6E1CFB298D94DC00B480A6 /* OverlayLayoutContext.swift in Sources */,
FD33BADE2A7249D600C1DA27 /* CGFloat+MaxLayoutValue.swift in Sources */,
FDA33E8C2B16660D00DCB8EB /* CalendarScrollView.swift in Sources */,
933992992736562D00C80380 /* DoubleLayoutPassHelpers.swift in Sources */,
939E694024846A8C00A8BCC7 /* Day.swift in Sources */,
939E692424837E0300A8BCC7 /* CalendarView.swift in Sources */,
Expand Down
81 changes: 81 additions & 0 deletions Sources/Internal/CalendarScrollView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Created by bryan_keller on 11/27/23.
// Copyright © 2023 Airbnb Inc. All rights reserved.

// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import UIKit

/// A scroll view with altered behavior to better fit the needs of `CalendarView`.
///
/// - Forces `contentInsetAdjustmentBehavior == .never`.
/// - The main thing this prevents is the situation where the view hierarchy is traversed to find a scroll view, and attempts are made to
/// change that scroll view's `contentInsetAdjustmentBehavior`.
/// - Customizes the accessibility elements of the scroll view
final class CalendarScrollView: UIScrollView {

// MARK: Lifecycle

init() {
super.init(frame: .zero)
contentInsetAdjustmentBehavior = .never
}

required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// MARK: Internal

var cachedAccessibilityElements: [Any]?

override var contentInsetAdjustmentBehavior: ContentInsetAdjustmentBehavior {
didSet {
super.contentInsetAdjustmentBehavior = .never
}
}

override var isAccessibilityElement: Bool {
get { false }
set { }
}

override var accessibilityElements: [Any]? {
get {
guard let itemViews = subviews as? [ItemView] else {
fatalError("Only `ItemView`s can be used as subviews of the scroll view.")
}
cachedAccessibilityElements = cachedAccessibilityElements ?? itemViews
.filter {
switch $0.itemType {
case .layoutItemType:
return true
default:
return false
}
}
.sorted {
guard
case .layoutItemType(let lhsItemType) = $0.itemType,
case .layoutItemType(let rhsItemType) = $1.itemType
else {
fatalError("Cannot sort views for Voice Over that aren't layout items.")
}
return lhsItemType < rhsItemType
}

return cachedAccessibilityElements
}
set { }
}

}
16 changes: 5 additions & 11 deletions Sources/Internal/ItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ final class ItemView: UIView {

var itemType: VisibleItem.ItemType?

override var isAccessibilityElement: Bool {
get { false }
set { }
}

var calendarItemModel: AnyCalendarItemModel {
didSet {
guard calendarItemModel._itemViewDifferentiator == oldValue._itemViewDifferentiator else {
Expand Down Expand Up @@ -105,14 +110,3 @@ final class ItemView: UIView {
}

}

// MARK: UIAccessibility

extension ItemView {

override var isAccessibilityElement: Bool {
get { false }
set { }
}

}
42 changes: 0 additions & 42 deletions Sources/Internal/OffScreenCalendarItemAccessibilityElement.swift

This file was deleted.

88 changes: 7 additions & 81 deletions Sources/Internal/VisibleItemsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ final class VisibleItemsProvider {
func detailsForVisibleItems(
surroundingPreviouslyVisibleLayoutItem previouslyVisibleLayoutItem: LayoutItem,
offset: CGPoint,
isAnimatedUpdatePass: Bool)
extendLayoutRegion: Bool)
-> VisibleItemsDetails
{
// Default the initial capacity to 100, which is approximately enough room for 3 months worth of
Expand All @@ -168,8 +168,8 @@ final class VisibleItemsProvider {
minimumCapacity: previousCalendarItemModelCache?.capacity ?? 100))

let bounds: CGRect
if isAnimatedUpdatePass {
bounds = boundsForAnimatedUpdatePass(atOffset: offset)
if extendLayoutRegion {
bounds = boundsForExtendedRegionUpdatePass(atOffset: offset)
} else {
bounds = CGRect(origin: offset, size: size)
}
Expand Down Expand Up @@ -293,81 +293,6 @@ final class VisibleItemsProvider {
maxMonthHeight: context.maxMonthHeight)
}

func visibleItemsForAccessibilityElements(
surroundingPreviouslyVisibleLayoutItem previouslyVisibleLayoutItem: LayoutItem,
visibleMonthRange: MonthRange)
-> [VisibleItem]
{
var visibleItems = [VisibleItem]()

// Look behind / ahead by 1 month to ensure that users can navigate by heading, even if an
// adjacent month header is off-screen.
let lowerBoundMonth = calendar.month(byAddingMonths: -1, to: visibleMonthRange.lowerBound)
let upperBoundMonth = calendar.month(byAddingMonths: 1, to: visibleMonthRange.upperBound)
let monthRange = lowerBoundMonth...upperBoundMonth

let handleItem: (LayoutItem, Bool, inout Bool) -> Void = { layoutItem, isLookingBackwards, shouldStop in
let month: Month
let calendarItemModel: AnyCalendarItemModel
switch layoutItem.itemType {
case .monthHeader(let _month):
month = _month
calendarItemModel = self.content.monthHeaderItemProvider(month)
case .day(let day):
month = day.month
calendarItemModel = self.content.dayItemProvider(day)
case .dayOfWeekInMonth:
return
}

guard monthRange.contains(month) else {
shouldStop = true
return
}

let item = VisibleItem(
calendarItemModel: calendarItemModel,
itemType: .layoutItemType(layoutItem.itemType),
frame: layoutItem.frame)
if isLookingBackwards {
visibleItems.insert(item, at: 0)
} else {
visibleItems.append(item)
}
}

var lastHandledLayoutItemEnumeratingBackwards = previouslyVisibleLayoutItem
var lastHandledLayoutItemEnumeratingForwards = previouslyVisibleLayoutItem
var context = VisibleItemsContext(
centermostLayoutItem: previouslyVisibleLayoutItem,
firstLayoutItem: previouslyVisibleLayoutItem)

layoutItemTypeEnumerator.enumerateItemTypes(
startingAt: previouslyVisibleLayoutItem.itemType,
itemTypeHandlerLookingBackwards: { itemType, shouldStop in
let layoutItem = self.layoutItem(
for: itemType,
lastHandledLayoutItem: lastHandledLayoutItemEnumeratingBackwards,
monthHeaderHeight: monthHeaderHeight(for: itemType.month, context: &context),
context: &context)
lastHandledLayoutItemEnumeratingBackwards = layoutItem

handleItem(layoutItem, true, &shouldStop)
},
itemTypeHandlerLookingForwards: { itemType, shouldStop in
let layoutItem = self.layoutItem(
for: itemType,
lastHandledLayoutItem: lastHandledLayoutItemEnumeratingForwards,
monthHeaderHeight: monthHeaderHeight(for: itemType.month, context: &context),
context: &context)
lastHandledLayoutItemEnumeratingForwards = layoutItem

handleItem(layoutItem, false, &shouldStop)
})

return visibleItems
}

// MARK: Private

private let layoutItemTypeEnumerator: LayoutItemTypeEnumerator
Expand Down Expand Up @@ -417,9 +342,10 @@ final class VisibleItemsProvider {
return itemOrigin < otherItemOrigin ? item : otherItem
}

private func boundsForAnimatedUpdatePass(atOffset offset: CGPoint) -> CGRect {
// Use a larger bounds (3x the viewport size) if we're in an animated update pass, reducing the
// likelihood of an item popping in / out.
private func boundsForExtendedRegionUpdatePass(atOffset offset: CGPoint) -> CGRect {
// Use a larger bounds (3x the viewport size) if we're in an animated update pass or voiceover
// is on, reducing the likelihood of an item popping in / out or an accessibility element being
// too far off screen to be focusable.
let boundsMultiplier = CGFloat(3)
switch content.monthsLayout {
case .vertical:
Expand Down
Loading

0 comments on commit 8d3f745

Please sign in to comment.