From 38a9eae0d8259217bc2ef388c72e774c0d252ead Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Thu, 24 Aug 2023 10:33:36 -0700 Subject: [PATCH] Bk/animation offscreen items improvement (#260) * Improve animations that involve offscreen items * Add isAnimatedUpdatePass parameter to _layoutSubviews * Fix tests --- CHANGELOG.md | 1 + Sources/Internal/VisibleItemsProvider.swift | 3 +- Sources/Public/CalendarView.swift | 154 +++++++++++++------- Tests/VisibleItemsProviderTests.swift | 36 +++-- 4 files changed, 127 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af09edf..e4c7683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed an issue that could cause the calendar to programmatically scroll to a month or day to which it had previously scrolled - Fixed Storyboard support by removing the `fatalError` in `init?(coder: NSCoder)` - Fixed an issue that could cause the calendar to layout unnecessarily due to a trait collection change notification +- Fixed an issue that could cause off-screen items to appear or disappear instantly, rather than animating in or out during animated content changes ### Changed - Removed all deprecated code, simplifying the public API in preparation for a 2.0 release diff --git a/Sources/Internal/VisibleItemsProvider.swift b/Sources/Internal/VisibleItemsProvider.swift index 5a6c8d1..a17c85a 100644 --- a/Sources/Internal/VisibleItemsProvider.swift +++ b/Sources/Internal/VisibleItemsProvider.swift @@ -155,7 +155,8 @@ final class VisibleItemsProvider { func detailsForVisibleItems( surroundingPreviouslyVisibleLayoutItem previouslyVisibleLayoutItem: LayoutItem, - offset: CGPoint) + offset: CGPoint, + size: CGSize) -> VisibleItemsDetails { // Default the initial capacity to 100, which is approximately enough room for 3 months worth of diff --git a/Sources/Public/CalendarView.swift b/Sources/Public/CalendarView.swift index d96c387..5e569b9 100644 --- a/Sources/Public/CalendarView.swift +++ b/Sources/Public/CalendarView.swift @@ -198,60 +198,10 @@ public final class CalendarView: UIView { guard isReadyForLayout else { return } - scrollView.performWithoutNotifyingDelegate { - scrollMetricsMutator.setUpInitialMetricsIfNeeded() - scrollMetricsMutator.updateContentSizePerpendicularToScrollAxis(viewportSize: bounds.size) - } + _layoutSubviews(isAnimatedUpdatePass: isAnimatedUpdatePass) - let anchorLayoutItem: LayoutItem - if let scrollToItemContext = scrollToItemContext, !scrollToItemContext.animated { - anchorLayoutItem = self.anchorLayoutItem( - for: scrollToItemContext, - visibleItemsProvider: visibleItemsProvider) - // Clear the `scrollToItemContext` once we use it. This could happen over the course of - // several layout pass attempts since `isReadyForLayout` might be false initially. - self.scrollToItemContext = nil - } else if let previousAnchorLayoutItem = self.anchorLayoutItem { - anchorLayoutItem = previousAnchorLayoutItem - } else { - let initialScrollToItemContext = ScrollToItemContext( - targetItem: .month(content.monthRange.lowerBound), - scrollPosition: .firstFullyVisiblePosition, - animated: false) - anchorLayoutItem = self.anchorLayoutItem( - for: initialScrollToItemContext, - visibleItemsProvider: visibleItemsProvider) - } - - let currentVisibleItemsDetails = visibleItemsProvider.detailsForVisibleItems( - surroundingPreviouslyVisibleLayoutItem: anchorLayoutItem, - offset: scrollView.contentOffset) - self.anchorLayoutItem = currentVisibleItemsDetails.centermostLayoutItem - - updateVisibleViews( - withVisibleItems: currentVisibleItemsDetails.visibleItems, - previouslyVisibleItems: visibleItemsDetails?.visibleItems ?? []) - - visibleItemsDetails = currentVisibleItemsDetails - - let minimumScrollOffset = visibleItemsDetails?.contentStartBoundary.map { - ($0 - firstLayoutMarginValue).alignedToPixel(forScreenWithScale: scale) - } - let maximumScrollOffset = visibleItemsDetails?.contentEndBoundary.map { - ($0 + lastLayoutMarginValue).alignedToPixel(forScreenWithScale: scale) - } - scrollView.performWithoutNotifyingDelegate { - scrollMetricsMutator.updateScrollBoundaries( - minimumScrollOffset: minimumScrollOffset, - maximumScrollOffset: maximumScrollOffset) - } - - cachedAccessibilityElements = nil - if let element = focusedAccessibilityElement as? OffScreenCalendarItemAccessibilityElement { - UIAccessibility.post( - notification: .screenChanged, - argument: visibleViewsForVisibleItems[element.correspondingItem]) - } + // The layout / update pass has completed, and we can set this back to `false`. + isAnimatedUpdatePass = false } /// Sets the content of the `CalendarView`, causing it to re-render. @@ -261,6 +211,15 @@ public final class CalendarView: UIView { public func setContent(_ content: CalendarViewContent) { let oldContent = self.content + // Do a preparation layout pass with an extended bounds, if we're animating. This ensures that + // views don't pop in if they're animating in from outside the actual bounds. + isAnimatedUpdatePass = UIView.inheritedAnimationDuration > 0 && UIView.areAnimationsEnabled + if isAnimatedUpdatePass { + UIView.performWithoutAnimation { + _layoutSubviews(isAnimatedUpdatePass: isAnimatedUpdatePass) + } + } + _visibleItemsProvider = nil // We only need to clear the `scrollToItemContext` if the monthsLayout changed or the visible @@ -534,6 +493,8 @@ public final class CalendarView: UIView { private var visibleItemsDetails: VisibleItemsDetails? private var visibleViewsForVisibleItems = [VisibleItem: ItemView]() + private var isAnimatedUpdatePass = false + private var previousBounds = CGRect.zero private var previousLayoutMargins = UIEdgeInsets.zero @@ -714,6 +675,89 @@ public final class CalendarView: UIView { } } + // This exists so that we can force a layout ourselves in preparation for an animated update. + private func _layoutSubviews(isAnimatedUpdatePass: Bool) { + scrollView.performWithoutNotifyingDelegate { + scrollMetricsMutator.setUpInitialMetricsIfNeeded() + scrollMetricsMutator.updateContentSizePerpendicularToScrollAxis(viewportSize: bounds.size) + } + + let anchorLayoutItem: LayoutItem + if let scrollToItemContext = scrollToItemContext, !scrollToItemContext.animated { + anchorLayoutItem = self.anchorLayoutItem( + for: scrollToItemContext, + visibleItemsProvider: visibleItemsProvider) + // Clear the `scrollToItemContext` once we use it. This could happen over the course of + // several layout pass attempts since `isReadyForLayout` might be false initially. + self.scrollToItemContext = nil + } else if let previousAnchorLayoutItem = self.anchorLayoutItem { + anchorLayoutItem = previousAnchorLayoutItem + } else { + let initialScrollToItemContext = ScrollToItemContext( + targetItem: .month(content.monthRange.lowerBound), + scrollPosition: .firstFullyVisiblePosition, + animated: false) + anchorLayoutItem = self.anchorLayoutItem( + for: initialScrollToItemContext, + visibleItemsProvider: visibleItemsProvider) + } + + // Use an extended bounds (3x the viewport size) if we're in an animated update pass, reducing + // the likelihood of an item popping in / out. + let boundsMultiplier = CGFloat(3) + let offset: CGPoint + let size: CGSize + if isAnimatedUpdatePass { + switch content.monthsLayout { + case .vertical: + offset = CGPoint( + x: scrollView.contentOffset.x, + y: scrollView.contentOffset.y - bounds.height) + size = CGSize(width: bounds.size.width, height: bounds.size.height * boundsMultiplier) + + case .horizontal: + offset = CGPoint( + x: scrollView.contentOffset.x - bounds.width, + y: scrollView.contentOffset.y) + size = CGSize(width: bounds.size.width * boundsMultiplier, height: bounds.size.height) + } + } else { + offset = scrollView.contentOffset + size = bounds.size + } + + let currentVisibleItemsDetails = visibleItemsProvider.detailsForVisibleItems( + surroundingPreviouslyVisibleLayoutItem: anchorLayoutItem, + offset: offset, + size: size) + self.anchorLayoutItem = currentVisibleItemsDetails.centermostLayoutItem + + updateVisibleViews( + withVisibleItems: currentVisibleItemsDetails.visibleItems, + previouslyVisibleItems: visibleItemsDetails?.visibleItems ?? []) + + visibleItemsDetails = currentVisibleItemsDetails + + let minimumScrollOffset = visibleItemsDetails?.contentStartBoundary.map { + ($0 - firstLayoutMarginValue).alignedToPixel(forScreenWithScale: scale) + } + let maximumScrollOffset = visibleItemsDetails?.contentEndBoundary.map { + ($0 + lastLayoutMarginValue).alignedToPixel(forScreenWithScale: scale) + } + scrollView.performWithoutNotifyingDelegate { + scrollMetricsMutator.updateScrollBoundaries( + minimumScrollOffset: minimumScrollOffset, + maximumScrollOffset: maximumScrollOffset) + } + + cachedAccessibilityElements = nil + if let element = focusedAccessibilityElement as? OffScreenCalendarItemAccessibilityElement { + UIAccessibility.post( + notification: .screenChanged, + argument: visibleViewsForVisibleItems[element.correspondingItem]) + } + } + private func updateVisibleViews( withVisibleItems visibleItems: Set, previouslyVisibleItems: Set) @@ -1071,6 +1115,7 @@ extension CalendarView: WidthDependentIntrinsicContentHeightProviding { } else { calendarHeight = bounds.height } + let size = CGSize(width: calendarWidth, height: calendarHeight) let visibleItemsProvider = VisibleItemsProvider( calendar: calendar, @@ -1089,7 +1134,8 @@ extension CalendarView: WidthDependentIntrinsicContentHeightProviding { let visibleItemsDetails = visibleItemsProvider.detailsForVisibleItems( surroundingPreviouslyVisibleLayoutItem: anchorMonthHeaderLayoutItem, - offset: scrollView.contentOffset) + offset: scrollView.contentOffset, + size: size) return CGSize(width: UIView.noIntrinsicMetric, height: visibleItemsDetails.intrinsicHeight) } diff --git a/Tests/VisibleItemsProviderTests.swift b/Tests/VisibleItemsProviderTests.swift index 290a111..218cead 100644 --- a/Tests/VisibleItemsProviderTests.swift +++ b/Tests/VisibleItemsProviderTests.swift @@ -291,11 +291,13 @@ final class VisibleItemsProviderTests: XCTestCase { surroundingPreviouslyVisibleLayoutItem: LayoutItem( itemType: .monthHeader(Month(era: 1, year: 2020, month: 03, isInGregorianCalendar: true)), frame: CGRect(x: 0, y: 200, width: 320, height: 50)), - offset: CGPoint(x: 0, y: 150)) + offset: CGPoint(x: 0, y: 150), + size: verticalVisibleItemsProvider.size) .centermostLayoutItem let details = verticalShortDayAspectRatioVisibleItemsProvider.detailsForVisibleItems( surroundingPreviouslyVisibleLayoutItem: anchorLayoutItem, - offset: CGPoint(x: 0, y: 150)) + offset: CGPoint(x: 0, y: 150), + size: verticalShortDayAspectRatioVisibleItemsProvider.size) let expectedVisibleItemDescriptions: Set = [ "[itemType: .dayBackground(2020-02-16), frame: (5.0, 165.0, 35.5, 18.0)]", @@ -387,7 +389,8 @@ final class VisibleItemsProviderTests: XCTestCase { surroundingPreviouslyVisibleLayoutItem: LayoutItem( itemType: .monthHeader(Month(era: 1, year: 2020, month: 03, isInGregorianCalendar: true)), frame: CGRect(x: 0, y: 200, width: 320, height: 50)), - offset: CGPoint(x: 0, y: 150)) + offset: CGPoint(x: 0, y: 150), + size: verticalVisibleItemsProvider.size) let expectedVisibleItemDescriptions: Set = [ "[itemType: .dayBackground(2020-03-11), frame: (142.0, 391.5, 36.0, 35.5)]", @@ -471,7 +474,8 @@ final class VisibleItemsProviderTests: XCTestCase { surroundingPreviouslyVisibleLayoutItem: LayoutItem( itemType: .monthHeader(Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true)), frame: CGRect(x: 0, y: 450, width: 320, height: 40)), - offset: CGPoint(x: 0, y: 450)) + offset: CGPoint(x: 0, y: 450), + size: verticalPinnedDaysOfWeekVisibleItemsProvider.size) let expectedVisibleItemDescriptions: Set = [ "[itemType: .dayBackground(2020-06-11), frame: (188.0, 585.5, 35.5, 36.0)]", @@ -549,7 +553,8 @@ final class VisibleItemsProviderTests: XCTestCase { surroundingPreviouslyVisibleLayoutItem: LayoutItem( itemType: .monthHeader(Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true)), frame: CGRect(x: 0, y: 200, width: 320, height: 50)), - offset: CGPoint(x: 0, y: 150)) + offset: CGPoint(x: 0, y: 150), + size: verticalPartialMonthVisibleItemsProvider.size) let expectedVisibleItemDescriptions: Set = [ "[itemType: .daysOfWeekRowSeparator(2020-1), frame: (0.0, 314.5, 320.0, 1.0)]", @@ -599,7 +604,8 @@ final class VisibleItemsProviderTests: XCTestCase { surroundingPreviouslyVisibleLayoutItem: LayoutItem( itemType: .monthHeader(Month(era: 1, year: 2020, month: 05, isInGregorianCalendar: true)), frame: CGRect(x: 250, y: 0, width: 300, height: 50)), - offset: CGPoint(x: 100, y: 0)) + offset: CGPoint(x: 100, y: 0), + size: horizontalVisibleItemsProvider.size) let expectedVisibleItemDescriptions: Set = [ "[itemType: .dayBackground(2020-04-11), frame: (197.0, 185.5, 33.0, 33.0)]", @@ -683,7 +689,8 @@ final class VisibleItemsProviderTests: XCTestCase { surroundingPreviouslyVisibleLayoutItem: LayoutItem( itemType: .monthHeader(Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true)), frame: CGRect(x: 0, y: 3000, width: 320, height: 50)), - offset: CGPoint(x: 0, y: 150)) + offset: CGPoint(x: 0, y: 150), + size: verticalVisibleItemsProvider.size) let expectedVisibleItemDescriptions: Set = [ "[itemType: .dayBackground(2020-05-11), frame: (50.5, 220.5, 36.0, 36.0)]", @@ -763,7 +770,8 @@ final class VisibleItemsProviderTests: XCTestCase { surroundingPreviouslyVisibleLayoutItem: LayoutItem( itemType: .monthHeader(Month(era: 1, year: 2020, month: 2, isInGregorianCalendar: true)), frame: CGRect(x: 315, y: 0, width: 300, height: 50)), - offset: CGPoint(x: 295, y: 0)) + offset: CGPoint(x: 295, y: 0), + size: horizontalVisibleItemsProvider.size) let expectedVisibleItemDescriptions: Set = [ "[itemType: .dayBackground(2020-02-11), frame: (405.5, 238.5, 33.0, 33.0)]", @@ -831,7 +839,8 @@ final class VisibleItemsProviderTests: XCTestCase { surroundingPreviouslyVisibleLayoutItem: LayoutItem( itemType: .monthHeader(Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true)), frame: CGRect(x: 0, y: 0, width: 320, height: 50)), - offset: CGPoint(x: 0, y: -50)) + offset: CGPoint(x: 0, y: -50), + size: verticalVisibleItemsProvider.size) let expectedVisibleItemDescriptions: Set = [ "[itemType: .dayBackground(2020-01-11), frame: (279.5, 191.5, 35.5, 35.5)]", @@ -907,7 +916,8 @@ final class VisibleItemsProviderTests: XCTestCase { surroundingPreviouslyVisibleLayoutItem: LayoutItem( itemType: .monthHeader(Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true)), frame: CGRect(x: 0, y: 45, width: 320, height: 40)), - offset: CGPoint(x: 0, y: 50)) + offset: CGPoint(x: 0, y: 50), + size: verticalPinnedDaysOfWeekVisibleItemsProvider.size) let expectedVisibleItemDescriptions: Set = [ "[itemType: .dayBackground(2020-01-11), frame: (279.5, 180.5, 35.5, 36.0)]", @@ -986,7 +996,8 @@ final class VisibleItemsProviderTests: XCTestCase { surroundingPreviouslyVisibleLayoutItem: LayoutItem( itemType: .monthHeader(Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true)), frame: CGRect(x: 0, y: 690, width: 320, height: 50)), - offset: CGPoint(x: 0, y: 690)) + offset: CGPoint(x: 0, y: 690), + size: verticalPartialMonthVisibleItemsProvider.size) let expectedVisibleItemDescriptions: Set = [ "[itemType: .daysOfWeekRowSeparator(2020-12), frame: (0.0, 854.5, 320.0, 1.0)]", @@ -1021,7 +1032,8 @@ final class VisibleItemsProviderTests: XCTestCase { surroundingPreviouslyVisibleLayoutItem: LayoutItem( itemType: .monthHeader(Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true)), frame: CGRect(x: 1200, y: 0, width: 300, height: 50)), - offset: CGPoint(x: 1000, y: 0)) + offset: CGPoint(x: 1000, y: 0), + size: horizontalVisibleItemsProvider.size) let expectedVisibleItemDescriptions: Set = [ "[itemType: .dayBackground(2020-11-11), frame: (1018.5, 235.5, 33.0, 33.0)]",