From e4015733ee1a20221aa3f6f29ce2805143a7e10d Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Wed, 23 Aug 2023 14:17:10 -0700 Subject: [PATCH] Improve content change animations --- CHANGELOG.md | 3 +- Sources/Internal/FrameProvider.swift | 10 +++--- Sources/Internal/VisibleItemsProvider.swift | 38 +++++++++++++++++---- Sources/Public/CalendarView.swift | 9 ++++- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a5dcdd..af09edf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated the default `HorizontalMonthLayoutOptions` to use a `restingPosition` of `.atLeadingEdgeOfEachMonth`, which is probably what most people want - Updated more publicly-exposed types conform to `Hashable`, because why not - maybe API consumers want to stick things in a `Set` or `Dictionary`. - Allowed `CalendarItemViewRepresentable` to have no `Content` type if the conforming view does not depend on any variable data -- Changed `ItemView` to determine user interaction capabilities from its content view's `hitTest` / `pointInside` functions +- 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 ## [v1.16.0](https://github.com/airbnb/HorizonCalendar/compare/v1.15.0...v1.16.0) - 2023-01-30 diff --git a/Sources/Internal/FrameProvider.swift b/Sources/Internal/FrameProvider.swift index b2c80c1..7d5b4bd 100644 --- a/Sources/Internal/FrameProvider.swift +++ b/Sources/Internal/FrameProvider.swift @@ -387,9 +387,8 @@ final class FrameProvider { monthHeaderHeight: CGFloat) -> CGFloat { - let numberOfWeekRows = rowInMonth + 1 - return dayItemFrame.maxY - - heightOfDayContent(forNumberOfWeekRows: numberOfWeekRows) - + dayItemFrame.minY - + ((daySize.height + content.verticalDayMargin) * CGFloat(rowInMonth)) - heightOfDaysOfTheWeekRowInMonth() - content.monthDayInsets.top - monthHeaderHeight @@ -460,7 +459,10 @@ final class FrameProvider { // For example, the returned height value for 5 week rows will be 5x the `daySize.height`, plus 4x // the `content.verticalDayMargin`. private func heightOfDayContent(forNumberOfWeekRows numberOfWeekRows: Int) -> CGFloat { - (CGFloat(numberOfWeekRows) * daySize.height) + + guard numberOfWeekRows > 0 else { + fatalError("Cannot calculate the height of day content if `numberOfWeekRows` is <= 0.") + } + return (CGFloat(numberOfWeekRows) * daySize.height) + (CGFloat(numberOfWeekRows - 1) * content.verticalDayMargin) } diff --git a/Sources/Internal/VisibleItemsProvider.swift b/Sources/Internal/VisibleItemsProvider.swift index 7b53e92..5a6c8d1 100644 --- a/Sources/Internal/VisibleItemsProvider.swift +++ b/Sources/Internal/VisibleItemsProvider.swift @@ -69,8 +69,8 @@ final class VisibleItemsProvider { scrollPosition: CalendarViewScrollPosition) -> LayoutItem { - var context = VisibleItemsContext( - centermostLayoutItem: LayoutItem(itemType: .monthHeader(month), frame: .zero)) + let layoutItem = LayoutItem(itemType: .monthHeader(month), frame: .zero) + var context = VisibleItemsContext(centermostLayoutItem: layoutItem, firstLayoutItem: layoutItem) let monthHeaderHeight = monthHeaderHeight(for: month, context: &context) let monthOrigin: CGPoint @@ -111,8 +111,8 @@ final class VisibleItemsProvider { scrollPosition: CalendarViewScrollPosition) -> LayoutItem { - var context = VisibleItemsContext( - centermostLayoutItem: LayoutItem(itemType: .day(day), frame: .zero)) + let layoutItem = LayoutItem(itemType: .day(day), frame: .zero) + var context = VisibleItemsContext(centermostLayoutItem: layoutItem, firstLayoutItem: layoutItem) let month = day.month let monthHeaderHeight = monthHeaderHeight(for: month, context: &context) @@ -162,6 +162,7 @@ final class VisibleItemsProvider { // calendar item models. var context = VisibleItemsContext( centermostLayoutItem: previouslyVisibleLayoutItem, + firstLayoutItem: previouslyVisibleLayoutItem, calendarItemModelCache: .init( minimumCapacity: previousCalendarItemModelCache?.capacity ?? 100)) @@ -274,6 +275,7 @@ final class VisibleItemsProvider { return VisibleItemsDetails( visibleItems: context.visibleItems, centermostLayoutItem: context.centermostLayoutItem, + firstLayoutItem: context.firstLayoutItem, visibleDayRange: visibleDayRange, visibleMonthRange: visibleMonthRange, framesForVisibleMonths: context.framesForVisibleMonths, @@ -330,7 +332,9 @@ final class VisibleItemsProvider { var lastHandledLayoutItemEnumeratingBackwards = previouslyVisibleLayoutItem var lastHandledLayoutItemEnumeratingForwards = previouslyVisibleLayoutItem - var context = VisibleItemsContext(centermostLayoutItem: previouslyVisibleLayoutItem) + var context = VisibleItemsContext( + centermostLayoutItem: previouslyVisibleLayoutItem, + firstLayoutItem: previouslyVisibleLayoutItem) layoutItemTypeEnumerator.enumerateItemTypes( startingAt: previouslyVisibleLayoutItem.itemType, @@ -391,6 +395,22 @@ final class VisibleItemsProvider { return itemDistance < otherItemDistance ? item : otherItem } + // Returns the layout item closest to the top/leading edge of `bounds`. + private func firstLayoutItem(comparing item: LayoutItem, to otherItem: LayoutItem) -> LayoutItem { + let itemOrigin: CGFloat + let otherItemOrigin: CGFloat + switch content.monthsLayout { + case .vertical: + itemOrigin = item.frame.minY + otherItemOrigin = otherItem.frame.minY + case .horizontal: + itemOrigin = item.frame.minX + otherItemOrigin = otherItem.frame.minX + } + + return itemOrigin < otherItemOrigin ? item : otherItem + } + private func monthOrigin( forMonthContaining layoutItem: LayoutItem, monthHeaderHeight: CGFloat, @@ -813,10 +833,14 @@ final class VisibleItemsProvider { frame: layoutItem.frame) context.visibleItems.insert(visibleItem) - context.centermostLayoutItem = self.centermostLayoutItem( + context.centermostLayoutItem = centermostLayoutItem( comparing: layoutItem, to: context.centermostLayoutItem, inBounds: bounds) + + context.firstLayoutItem = firstLayoutItem( + comparing: layoutItem, + to: context.firstLayoutItem) } } else { shouldStop = true @@ -1188,6 +1212,7 @@ final class VisibleItemsProvider { private struct VisibleItemsContext { var centermostLayoutItem: LayoutItem + var firstLayoutItem: LayoutItem var firstVisibleDay: Day? var lastVisibleDay: Day? var firstVisibleMonth: Month? @@ -1211,6 +1236,7 @@ private struct VisibleItemsContext { struct VisibleItemsDetails { let visibleItems: Set let centermostLayoutItem: LayoutItem + let firstLayoutItem: LayoutItem? let visibleDayRange: DayRange? let visibleMonthRange: MonthRange? let framesForVisibleMonths: [Month: CGRect] diff --git a/Sources/Public/CalendarView.swift b/Sources/Public/CalendarView.swift index b40e248..d96c387 100644 --- a/Sources/Public/CalendarView.swift +++ b/Sources/Public/CalendarView.swift @@ -281,7 +281,14 @@ public final class CalendarView: UIView { isAnchorLayoutItemValid = false } - if !isAnchorLayoutItemValid { + if isAnchorLayoutItemValid { + // If we have a valid `anchorLayoutItem`, change it to be the topmost item. Normally, the + // `anchorLayoutItem` is the centermost item, but when our content changes, it can make the + // transition look better if our layout reference point is at the top of the screen. + anchorLayoutItem = visibleItemsDetails?.firstLayoutItem ?? anchorLayoutItem + } else { + // If the `anchorLayoutItem` is no longer valid (due to it no longer being in the visible day + // range), set it to nil. This will force us to find a new `anchorLayoutItem`. anchorLayoutItem = nil }