Skip to content

Commit

Permalink
Bk/animation offscreen items improvement (#260)
Browse files Browse the repository at this point in the history
* Improve animations that involve offscreen items

* Add isAnimatedUpdatePass parameter to _layoutSubviews

* Fix tests
  • Loading branch information
bryankeller authored Aug 24, 2023
1 parent 64084e5 commit 38a9eae
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 67 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion Sources/Internal/VisibleItemsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
154 changes: 100 additions & 54 deletions Sources/Public/CalendarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<VisibleItem>,
previouslyVisibleItems: Set<VisibleItem>)
Expand Down Expand Up @@ -1071,6 +1115,7 @@ extension CalendarView: WidthDependentIntrinsicContentHeightProviding {
} else {
calendarHeight = bounds.height
}
let size = CGSize(width: calendarWidth, height: calendarHeight)

let visibleItemsProvider = VisibleItemsProvider(
calendar: calendar,
Expand All @@ -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)
}
Expand Down
36 changes: 24 additions & 12 deletions Tests/VisibleItemsProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = [
"[itemType: .dayBackground(2020-02-16), frame: (5.0, 165.0, 35.5, 18.0)]",
Expand Down Expand Up @@ -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<String> = [
"[itemType: .dayBackground(2020-03-11), frame: (142.0, 391.5, 36.0, 35.5)]",
Expand Down Expand Up @@ -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<String> = [
"[itemType: .dayBackground(2020-06-11), frame: (188.0, 585.5, 35.5, 36.0)]",
Expand Down Expand Up @@ -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<String> = [
"[itemType: .daysOfWeekRowSeparator(2020-1), frame: (0.0, 314.5, 320.0, 1.0)]",
Expand Down Expand Up @@ -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<String> = [
"[itemType: .dayBackground(2020-04-11), frame: (197.0, 185.5, 33.0, 33.0)]",
Expand Down Expand Up @@ -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<String> = [
"[itemType: .dayBackground(2020-05-11), frame: (50.5, 220.5, 36.0, 36.0)]",
Expand Down Expand Up @@ -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<String> = [
"[itemType: .dayBackground(2020-02-11), frame: (405.5, 238.5, 33.0, 33.0)]",
Expand Down Expand Up @@ -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<String> = [
"[itemType: .dayBackground(2020-01-11), frame: (279.5, 191.5, 35.5, 35.5)]",
Expand Down Expand Up @@ -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<String> = [
"[itemType: .dayBackground(2020-01-11), frame: (279.5, 180.5, 35.5, 36.0)]",
Expand Down Expand Up @@ -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<String> = [
"[itemType: .daysOfWeekRowSeparator(2020-12), frame: (0.0, 854.5, 320.0, 1.0)]",
Expand Down Expand Up @@ -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<String> = [
"[itemType: .dayBackground(2020-11-11), frame: (1018.5, 235.5, 33.0, 33.0)]",
Expand Down

0 comments on commit 38a9eae

Please sign in to comment.