From e1a8cd3b7fee91cfded3e034cd96275ed9f2dff2 Mon Sep 17 00:00:00 2001 From: Andy Bartholomew Date: Wed, 12 Aug 2026 13:25:14 -0700 Subject: [PATCH] Filter non-ItemView subviews out of accessibilityElements `CalendarScrollView.accessibilityElements` force-casts every subview to `[ItemView]` and `fatalError`s if the cast fails. That invariant holds only while HorizonCalendar is the sole contributor of subviews. On iOS 27 UIKit adds two `_UITouchPassthroughView`s to the scroll view, so the cast fails and the app dies: HorizonCalendar/CalendarScrollView.swift:55: Fatal error: Only `ItemView`s can be used as subviews of the scroll view. Filters to the item views instead of requiring that every subview is one. The views UIKit adds are private hit-testing views with no content, so the resulting element set matches what the force-cast produced on earlier OS versions. Only the accessibility path reads this property, so it stays hidden until something queries accessibility. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + HorizonCalendar.xcodeproj/project.pbxproj | 4 ++ Sources/Internal/CalendarScrollView.swift | 6 +- Tests/CalendarScrollViewTests.swift | 78 +++++++++++++++++++++++ 4 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 Tests/CalendarScrollViewTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index de852686..502f2e1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed a performance issue caused by the item models for day ranges and month backgrounds not being cached - Fixed an issue that could cause the calendar to appear blank after rotating the device - Fixed an issue with touch handling on iOS 26 +- Fixed a crash when accessibility is enabled on iOS 27, caused by assuming that every subview of the calendar's scroll view is an item view ### Changed - Rewrote accessibility code to avoid posting notifications, which causes poor Voice Over performance and odd focus bugs diff --git a/HorizonCalendar.xcodeproj/project.pbxproj b/HorizonCalendar.xcodeproj/project.pbxproj index 59b19f79..00d798c2 100644 --- a/HorizonCalendar.xcodeproj/project.pbxproj +++ b/HorizonCalendar.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 086333B02AB8D36900CC6125 /* CalendarContentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086333AE2AB8D34700CC6125 /* CalendarContentTests.swift */; }; + AB1200000000000000000002 /* CalendarScrollViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200000000000000000001 /* CalendarScrollViewTests.swift */; }; 9321957E26EEB44C0001C7E9 /* DayOfWeekView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9321957D26EEB44C0001C7E9 /* DayOfWeekView.swift */; }; 9321958026EEB6330001C7E9 /* Shape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9321957F26EEB6330001C7E9 /* Shape.swift */; }; 9321958226EEB6AB0001C7E9 /* DrawingConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9321958126EEB6AB0001C7E9 /* DrawingConfig.swift */; }; @@ -88,6 +89,7 @@ /* Begin PBXFileReference section */ 086333AE2AB8D34700CC6125 /* CalendarContentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarContentTests.swift; sourceTree = ""; }; + AB1200000000000000000001 /* CalendarScrollViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarScrollViewTests.swift; sourceTree = ""; }; 9321957D26EEB44C0001C7E9 /* DayOfWeekView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeekView.swift; sourceTree = ""; }; 9321957F26EEB6330001C7E9 /* Shape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shape.swift; sourceTree = ""; }; 9321958126EEB6AB0001C7E9 /* DrawingConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawingConfig.swift; sourceTree = ""; }; @@ -243,6 +245,7 @@ 939E694D2484B14400A8BCC7 /* VisibleItemsProviderTests.swift */, 9396F3D22483261B008AD306 /* Info.plist */, 086333AE2AB8D34700CC6125 /* CalendarContentTests.swift */, + AB1200000000000000000001 /* CalendarScrollViewTests.swift */, ); path = Tests; sourceTree = ""; @@ -487,6 +490,7 @@ 939E695C2484B22600A8BCC7 /* VisibleItemsProviderTests.swift in Sources */, 93A0062C24F206BE00F667A3 /* ItemViewReuseManagerTests.swift in Sources */, 086333B02AB8D36900CC6125 /* CalendarContentTests.swift in Sources */, + AB1200000000000000000002 /* CalendarScrollViewTests.swift in Sources */, 939E69592484B21700A8BCC7 /* LayoutItemTypeEnumeratorTests.swift in Sources */, 93FA64F2248D93EA00A8B7B1 /* MonthRowTests.swift in Sources */, 932E24142558DF6E001648D2 /* HorizontalMonthsLayoutOptionsTests.swift in Sources */, diff --git a/Sources/Internal/CalendarScrollView.swift b/Sources/Internal/CalendarScrollView.swift index f79213fa..827bc7b7 100644 --- a/Sources/Internal/CalendarScrollView.swift +++ b/Sources/Internal/CalendarScrollView.swift @@ -51,9 +51,9 @@ final class CalendarScrollView: UIScrollView { 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.") - } + // UIKit can add subviews of its own - on iOS 27 it adds `_UITouchPassthroughView`s - so we + // filter to the item views rather than requiring that every subview is one. + let itemViews = subviews.compactMap { $0 as? ItemView } cachedAccessibilityElements = cachedAccessibilityElements ?? itemViews .filter { switch $0.itemType { diff --git a/Tests/CalendarScrollViewTests.swift b/Tests/CalendarScrollViewTests.swift new file mode 100644 index 00000000..f54cbc40 --- /dev/null +++ b/Tests/CalendarScrollViewTests.swift @@ -0,0 +1,78 @@ +// Created by Andy Bartholomew on 8/12/26. +// Copyright © 2026 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 XCTest +@testable import HorizonCalendar + +// MARK: - CalendarScrollViewTests + +final class CalendarScrollViewTests: XCTestCase { + + // MARK: Internal + + func testAccessibilityElementsIgnoresSubviewsThatAreNotItemViews() { + let scrollView = CalendarScrollView() + let itemView = itemView(for: .monthHeader(month)) + scrollView.addSubview(itemView) + + // UIKit adds subviews of its own on some OS versions - `_UITouchPassthroughView`s on iOS 27, for + // example - which must not prevent the item views from being returned. + scrollView.addSubview(UIView()) + + XCTAssertEqual(scrollView.accessibilityElements?.count, 1) + XCTAssert(scrollView.accessibilityElements?.first as? ItemView === itemView) + } + + func testAccessibilityElementsWithNoItemViews() { + let scrollView = CalendarScrollView() + scrollView.addSubview(UIView()) + + XCTAssertEqual(scrollView.accessibilityElements?.count, 0) + } + + // MARK: Private + + private let month = Month(era: 1, year: 2026, month: 08, isInGregorianCalendar: true) + + private func itemView(for itemType: LayoutItem.ItemType) -> ItemView { + let itemView = ItemView(initialCalendarItemModel: MockCalendarItemModel()) + itemView.itemType = .layoutItemType(itemType) + return itemView + } + +} + +// MARK: - MockCalendarItemModel + +private struct MockCalendarItemModel: AnyCalendarItemModel { + + var _itemViewDifferentiator = _CalendarItemViewDifferentiator( + viewType: ObjectIdentifier(UIView.self), + invariantViewProperties: AnyHashable(0) + ) + + func _makeView() -> UIView { + UIView() + } + + func _setContent(onViewOfSameType _: UIView) { } + + func _isContentEqual(toContentOf _: AnyCalendarItemModel) -> Bool { + false + } + + mutating func _setSwiftUIWrapperViewContentIDIfNeeded(_: AnyHashable) { } + +}