diff --git a/doc/documentation.md b/doc/documentation.md index 4c8e1491..f250e407 100644 --- a/doc/documentation.md +++ b/doc/documentation.md @@ -603,6 +603,64 @@ CalendarControllerProvider( When a view does not receive `controller` directly, it reads the controller from `CalendarControllerProvider`. +## Advanced Internals: `ZoomScrollController` (Optional) + +`ZoomScrollController` is an internal utility used by `DayView`, `WeekView`, and +`MultiDayView` to keep the same time range visible when `heightPerMinute` +changes. + +> This is **not part of the stable public package API** exported by +> `package:calendar_view/calendar_view.dart`. +> +> Use this only when building a custom calendar/scroll implementation and when +> you are okay with potential breaking changes in internal files. + +## Why it exists + +When zoom changes (for example from `heightPerMinute: 1.0` to `1.4`), a normal +`ScrollController` update done after build can produce a brief visual jump. +`ZoomScrollController` avoids that by preparing the next offset before layout +and applying it during content-dimension calculation. + +## Internal contract + +1. Read the current vertical offset. +2. Compute scaled offset: + `scaledOffset = (currentOffset / oldHeightPerMinute) * newHeightPerMinute` +3. Call `prepareZoomJump(scaledOffset)` before rebuild. +4. Rebuild with new `heightPerMinute`. + +Because the correction is applied during layout, viewport size and scroll +position update together in the same frame. + +## Minimal internal example + +```dart +import 'package:calendar_view/src/zoom_scroll_controller.dart'; + +class MyZoomState { + final controller = ZoomScrollController(); + double heightPerMinute = 1.0; + + void onZoomChange(double nextHeightPerMinute) { + final currentOffset = controller.hasClients ? controller.offset : 0.0; + + final scaledOffset = + (currentOffset / heightPerMinute) * nextHeightPerMinute; + + controller.prepareZoomJump(scaledOffset); + heightPerMinute = nextHeightPerMinute; + } +} +``` + +## Recommendation + +Prefer the built-in `DayView`, `WeekView`, and `MultiDayView` behavior unless +you are implementing a custom view layer that needs zoom-aware scroll +correction. + + # Localization Guide This guide covers localization support in `calendar_view` and how to keep localized strings aligned with the package API. @@ -1159,7 +1217,6 @@ dependencies: bool hideDaysNotInMonth, ); ``` - # Contributors ## Main Contributors diff --git a/example/lib/widgets/day_view_widget.dart b/example/lib/widgets/day_view_widget.dart index 370a79fa..9c438d1c 100644 --- a/example/lib/widgets/day_view_widget.dart +++ b/example/lib/widgets/day_view_widget.dart @@ -22,6 +22,7 @@ class DayViewWidget extends StatelessWidget { timeLineBuilder: (date) => _timeLineBuilder(date, isLtr), scrollPhysics: const BouncingScrollPhysics(), eventArranger: SideEventArranger(), + keepScrollOffset: true, showQuarterHours: false, showMidnightHour: true, hourIndicatorSettings: HourIndicatorSettings( diff --git a/example/lib/widgets/week_view_widget.dart b/example/lib/widgets/week_view_widget.dart index 4dd17fb7..0a668082 100644 --- a/example/lib/widgets/week_view_widget.dart +++ b/example/lib/widgets/week_view_widget.dart @@ -24,6 +24,7 @@ class WeekViewWidget extends StatelessWidget { showWeekends: true, showMidnightHour: true, showLiveTimeLineInAllDays: true, + keepScrollOffset: true, timeSlotColorBuilder: (_, slotStartTime, __, ___) { final hour = slotStartTime.hour; final isBusinessHours = hour >= 9 && hour < 17; diff --git a/lib/src/day_view/_internal_day_view_page.dart b/lib/src/day_view/_internal_day_view_page.dart index 9778c2b2..a6778ebf 100644 --- a/lib/src/day_view/_internal_day_view_page.dart +++ b/lib/src/day_view/_internal_day_view_page.dart @@ -103,8 +103,6 @@ class InternalDayViewPage extends StatefulWidget { /// Display full day events. final FullDayEventBuilder fullDayEventBuilder; - final ScrollController dayViewScrollController; - /// Flag to display half hours. final bool showHalfHours; @@ -120,8 +118,18 @@ class InternalDayViewPage extends StatefulWidget { /// Settings for half hour indicator lines. final HourIndicatorSettings quarterHourIndicatorSettings; - /// Scroll listener to set every page's last offset - final void Function(ScrollController) scrollListener; + /// Scroll listener to set every page's last offset. + final void Function( + int pageIndex, + double offset, + ZoomScrollController controller, + ) scrollListener; + + /// Page index in the parent [PageView]. + final int pageIndex; + + /// Whether this page is currently visible in parent [PageView]. + final bool isCurrentPage; /// Last scroll offset of day view page. final double lastScrollOffset; @@ -176,9 +184,10 @@ class InternalDayViewPage extends StatefulWidget { required this.minuteSlotSize, required this.scrollNotifier, required this.fullDayEventBuilder, - required this.dayViewScrollController, required this.scrollPhysics, required this.scrollListener, + required this.pageIndex, + required this.isCurrentPage, required this.dayDetectorBuilder, required this.showHalfHours, required this.showQuarterHours, @@ -210,6 +219,17 @@ class _InternalDayViewPageState initialScrollOffset: widget.lastScrollOffset, ); scrollController.addListener(_scrollControllerListener); + + if (widget.isCurrentPage) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + widget.scrollListener( + widget.pageIndex, + scrollController.offset, + scrollController, + ); + }); + } } @override @@ -225,6 +245,23 @@ class _InternalDayViewPageState (oldWidget.heightPerMinute > 0 ? oldWidget.heightPerMinute : 1.0); scrollController.prepareZoomJump(scaledOffset); } + + if (!widget.keepScrollOffset && + widget.isCurrentPage && + !oldWidget.isCurrentPage && + scrollController.hasClients) { + scrollController.jumpTo(widget.lastScrollOffset); + } + + if (widget.isCurrentPage && !oldWidget.isCurrentPage) { + widget.scrollListener( + widget.pageIndex, + scrollController.hasClients + ? scrollController.offset + : widget.lastScrollOffset, + scrollController, + ); + } } @override @@ -236,7 +273,11 @@ class _InternalDayViewPageState } void _scrollControllerListener() { - widget.scrollListener(scrollController); + widget.scrollListener( + widget.pageIndex, + scrollController.offset, + scrollController, + ); } /// Builds the background color layer for time slots in the day view. @@ -325,9 +366,7 @@ class _InternalDayViewPageState scrollbars: widget.keepScrollOffset, ), child: SingleChildScrollView( - controller: widget.keepScrollOffset - ? scrollController - : widget.dayViewScrollController, + controller: scrollController, physics: widget.scrollPhysics, child: SizedBox( height: widget.height, diff --git a/lib/src/day_view/day_view.dart b/lib/src/day_view/day_view.dart index af1f5270..734968b1 100644 --- a/lib/src/day_view/day_view.dart +++ b/lib/src/day_view/day_view.dart @@ -486,13 +486,23 @@ class DayViewState extends State> { /// Provides data for rendering events and tracks event changes. EventController? _controller; - /// Scroll controller for managing vertical scrolling within the day view. - /// Controls scroll position for time axis (top-to-bottom). - late ZoomScrollController _scrollController; + /// Per-page scroll offset cache keyed by page index. + final Map _pageOffsets = {}; + + /// Currently visible page scroll controller. + ZoomScrollController? _activeScrollController; /// Public getter for accessing the scroll controller. /// Allows external code to control or listen to scroll events. - ScrollController get scrollController => _scrollController; + ZoomScrollController get scrollController { + final controller = _activeScrollController; + if (controller == null || !controller.hasClients) { + throw StateError( + "ScrollController is not attached to any scroll views yet.", + ); + } + return controller; + } /// Callback function triggered when the controller changes or events are modified. /// Used to rebuild the view when event data changes. @@ -505,8 +515,7 @@ class DayViewState extends State> { @override void initState() { super.initState(); - _lastScrollOffset = widget.scrollOffset ?? - widget.startDuration.inMinutes * widget.heightPerMinute; + _lastScrollOffset = _defaultPageOffset; _reloadCallback = _reload; _setDateRange(); @@ -516,10 +525,8 @@ class DayViewState extends State> { _regulateCurrentDate(); _calculateHeights(); - _scrollController = ZoomScrollController( - initialScrollOffset: _lastScrollOffset, - ); _pageController = PageController(initialPage: _currentIndex); + _pageOffsets[_currentIndex] = _lastScrollOffset; _eventArranger = widget.eventArranger ?? SideEventArranger(); _assignBuilders(); } @@ -562,6 +569,8 @@ class DayViewState extends State> { widget.maxDay != oldWidget.maxDay) { _setDateRange(); _regulateCurrentDate(); + _pageOffsets.clear(); + _pageOffsets[_currentIndex] = _defaultPageOffset; _pageController.jumpToPage(_currentIndex); } @@ -575,14 +584,19 @@ class DayViewState extends State> { _assignBuilders(); if (widget.heightPerMinute != oldWidget.heightPerMinute) { - final currentOffset = _scrollController.hasClients - ? _scrollController.offset - : _lastScrollOffset; + final activeController = _activeScrollController; + final currentOffset = + activeController != null && activeController.hasClients + ? activeController.offset + : _lastScrollOffset; final scaledOffset = currentOffset * widget.heightPerMinute / (oldWidget.heightPerMinute > 0 ? oldWidget.heightPerMinute : 1.0); _lastScrollOffset = scaledOffset; - _scrollController.prepareZoomJump(scaledOffset); + _pageOffsets[_currentIndex] = scaledOffset; + if (activeController != null && activeController.hasClients) { + activeController.prepareZoomJump(scaledOffset); + } } } @@ -668,9 +682,12 @@ class DayViewState extends State> { _quarterHourIndicatorSettings, emulateVerticalOffsetBy: widget.emulateVerticalOffsetBy, - lastScrollOffset: _lastScrollOffset, - dayViewScrollController: _scrollController, + lastScrollOffset: widget.keepScrollOffset + ? (_pageOffsets[index] ?? _defaultPageOffset) + : _defaultPageOffset, scrollPhysics: widget.scrollPhysics, + pageIndex: index, + isCurrentPage: index == _currentIndex, scrollListener: _scrollPageListener, keepScrollOffset: widget.keepScrollOffset, timeSlotColorBuilder: _timeSlotColorBuilder, @@ -951,9 +968,15 @@ class DayViewState extends State> { _currentIndex = index; }); } + _activeScrollController = null; + _lastScrollOffset = widget.keepScrollOffset + ? (_pageOffsets[index] ?? _defaultPageOffset) + : _defaultPageOffset; + if (!widget.keepScrollOffset) { + _pageOffsets[index] = _defaultPageOffset; _jumpToOffsetAfterPageTransition( - _offsetForDuration(widget.startDuration).toDouble(), + _defaultPageOffset, ); } widget.onPageChange?.call(_currentDate, _currentIndex); @@ -990,24 +1013,46 @@ class DayViewState extends State> { void _jumpToOffsetAfterPageTransition(double offset) { _runAfterPageTransition(() { - if (!_scrollController.hasClients) return; + _withAttachedScrollController((controller) { + final clampedOffset = offset.clamp( + controller.position.minScrollExtent, + controller.position.maxScrollExtent, + ); - final clampedOffset = offset.clamp( - _scrollController.position.minScrollExtent, - _scrollController.position.maxScrollExtent, - ); + _lastScrollOffset = clampedOffset.toDouble(); + _pageOffsets[_currentIndex] = _lastScrollOffset; + controller.jumpTo(clampedOffset.toDouble()); + }); + }); + } + + void _withAttachedScrollController( + void Function(ZoomScrollController controller) action, + ) { + final controller = _activeScrollController; + if (controller != null && controller.hasClients) { + action(controller); + return; + } - _lastScrollOffset = clampedOffset.toDouble(); - _scrollController.jumpTo(clampedOffset.toDouble()); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + final updatedController = _activeScrollController; + if (updatedController != null && updatedController.hasClients) { + action(updatedController); + } }); } + double get _defaultPageOffset => + widget.scrollOffset ?? _offsetForDuration(widget.startDuration); + double _offsetForDuration(Duration startDuration) { - final offSetForSingleMinute = _height / 24 / 60; final startDurationInMinutes = startDuration.inMinutes; final minuteOffset = startDurationInMinutes > 3600 ? 3600 : startDurationInMinutes; - return offSetForSingleMinute * minuteOffset; + return widget.heightPerMinute * minuteOffset; } /// Animate to next page (next day). @@ -1208,11 +1253,13 @@ class DayViewState extends State> { Duration duration = const Duration(milliseconds: 200), Curve curve = Curves.linear, }) { - _scrollController.animateTo( - offset, - duration: duration, - curve: curve, - ); + _withAttachedScrollController((controller) { + controller.animateTo( + offset, + duration: duration, + curve: curve, + ); + }); } /// Returns the current visible date in day view. @@ -1220,8 +1267,18 @@ class DayViewState extends State> { DateTime(_currentDate.year, _currentDate.month, _currentDate.day); /// Listener for every day page ScrollController - void _scrollPageListener(ScrollController controller) { - _lastScrollOffset = controller.offset; + void _scrollPageListener( + int pageIndex, + double offset, + ZoomScrollController controller, + ) { + _activeScrollController = controller; + if (!widget.keepScrollOffset) return; + + _pageOffsets[pageIndex] = offset; + if (pageIndex == _currentIndex) { + _lastScrollOffset = offset; + } } } diff --git a/lib/src/multi_day_view/_internal_multi_day_view_page.dart b/lib/src/multi_day_view/_internal_multi_day_view_page.dart index 3be0f648..c097ea1d 100644 --- a/lib/src/multi_day_view/_internal_multi_day_view_page.dart +++ b/lib/src/multi_day_view/_internal_multi_day_view_page.dart @@ -130,8 +130,6 @@ class InternalMultiDayViewPage extends StatefulWidget { /// Display full day events. final FullDayEventBuilder fullDayEventBuilder; - final ScrollController multiDayViewScrollController; - /// First hour displayed in the layout final int startHour; @@ -159,8 +157,18 @@ class InternalMultiDayViewPage extends StatefulWidget { /// Defines full day events header text config final FullDayHeaderTextConfig fullDayHeaderTextConfig; - /// Scroll listener to set every page's last offset - final void Function(ScrollController) scrollListener; + /// Scroll listener to set every page's last offset. + final void Function( + int pageIndex, + double offset, + ZoomScrollController controller, + ) scrollListener; + + /// Page index in the parent [PageView]. + final int pageIndex; + + /// Whether this page is currently visible in parent [PageView]. + final bool isCurrentPage; /// Last scroll offset of week view page. final double lastScrollOffset; @@ -222,7 +230,8 @@ class InternalMultiDayViewPage extends StatefulWidget { required this.fullDayHeaderTextConfig, required this.scrollPhysics, required this.scrollListener, - required this.multiDayViewScrollController, + required this.pageIndex, + required this.isCurrentPage, this.lastScrollOffset = 0.0, this.keepScrollOffset = false, this.showMutliDayBottomLine = true}) @@ -244,6 +253,17 @@ class _InternalMultiDayViewPageState initialScrollOffset: widget.lastScrollOffset, ); scrollController.addListener(_scrollControllerListener); + + if (widget.isCurrentPage) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + widget.scrollListener( + widget.pageIndex, + scrollController.offset, + scrollController, + ); + }); + } } @override @@ -259,6 +279,23 @@ class _InternalMultiDayViewPageState (oldWidget.heightPerMinute > 0 ? oldWidget.heightPerMinute : 1.0); scrollController.prepareZoomJump(scaledOffset); } + + if (!widget.keepScrollOffset && + widget.isCurrentPage && + !oldWidget.isCurrentPage && + scrollController.hasClients) { + scrollController.jumpTo(widget.lastScrollOffset); + } + + if (widget.isCurrentPage && !oldWidget.isCurrentPage) { + widget.scrollListener( + widget.pageIndex, + scrollController.hasClients + ? scrollController.offset + : widget.lastScrollOffset, + scrollController, + ); + } } @override @@ -270,7 +307,11 @@ class _InternalMultiDayViewPageState } void _scrollControllerListener() { - widget.scrollListener(scrollController); + widget.scrollListener( + widget.pageIndex, + scrollController.offset, + scrollController, + ); } @override @@ -384,9 +425,7 @@ class _InternalMultiDayViewPageState scrollbars: widget.keepScrollOffset, ), child: SingleChildScrollView( - controller: widget.keepScrollOffset - ? scrollController - : widget.multiDayViewScrollController, + controller: scrollController, physics: widget.scrollPhysics, child: SizedBox( height: widget.height, diff --git a/lib/src/multi_day_view/multi_day_view.dart b/lib/src/multi_day_view/multi_day_view.dart index 1787f950..9772f8b4 100644 --- a/lib/src/multi_day_view/multi_day_view.dart +++ b/lib/src/multi_day_view/multi_day_view.dart @@ -341,9 +341,19 @@ class MultiDayViewState extends State> { EventController? _controller; - late ZoomScrollController _scrollController; + final Map _pageOffsets = {}; - ScrollController get scrollController => _scrollController; + ZoomScrollController? _activeScrollController; + + ScrollController get scrollController { + final controller = _activeScrollController; + if (controller == null || !controller.hasClients) { + throw StateError( + "ScrollController is not attached to any scroll views yet.", + ); + } + return controller; + } late List _weekDays; @@ -355,10 +365,7 @@ class MultiDayViewState extends State> { @override void initState() { super.initState(); - _lastScrollOffset = widget.scrollOffset; - - _scrollController = - ZoomScrollController(initialScrollOffset: widget.scrollOffset); + _lastScrollOffset = _defaultPageOffset; _startHour = widget.startHour; _endHour = widget.endHour; @@ -375,6 +382,7 @@ class MultiDayViewState extends State> { _calculateHeights(); _pageController = PageController(initialPage: _currentIndex); + _pageOffsets[_currentIndex] = _lastScrollOffset; _eventArranger = widget.eventArranger ?? SideEventArranger(); _assignBuilders(); @@ -423,6 +431,8 @@ class MultiDayViewState extends State> { widget.maxDay != oldWidget.maxDay) { _setDateRange(); _regulateCurrentDate(); + _pageOffsets.clear(); + _pageOffsets[_currentIndex] = _defaultPageOffset; // updateRange(); _pageController.jumpToPage(_currentIndex); @@ -439,18 +449,24 @@ class MultiDayViewState extends State> { _assignBuilders(); if (widget.heightPerMinute != oldWidget.heightPerMinute) { - final currentOffset = _scrollController.hasClients - ? _scrollController.offset - : _lastScrollOffset; + final activeController = _activeScrollController; + final currentOffset = + activeController != null && activeController.hasClients + ? activeController.offset + : _lastScrollOffset; final scaledOffset = currentOffset * widget.heightPerMinute / (oldWidget.heightPerMinute > 0 ? oldWidget.heightPerMinute : 1.0); _lastScrollOffset = scaledOffset; - _scrollController.prepareZoomJump(scaledOffset); + _pageOffsets[_currentIndex] = scaledOffset; + if (activeController != null && activeController.hasClients) { + activeController.prepareZoomJump(scaledOffset); + } } if (widget.scrollOffset != oldWidget.scrollOffset) { _lastScrollOffset = widget.scrollOffset; + _pageOffsets[_currentIndex] = _lastScrollOffset; _jumpToOffsetAfterPageTransition(widget.scrollOffset); } } @@ -486,18 +502,40 @@ class MultiDayViewState extends State> { void _jumpToOffsetAfterPageTransition(double offset) { _runAfterPageTransition(() { - if (!_scrollController.hasClients) return; + _withAttachedScrollController((controller) { + final clampedOffset = offset.clamp( + controller.position.minScrollExtent, + controller.position.maxScrollExtent, + ); - final clampedOffset = offset.clamp( - _scrollController.position.minScrollExtent, - _scrollController.position.maxScrollExtent, - ); + _lastScrollOffset = clampedOffset.toDouble(); + _pageOffsets[_currentIndex] = _lastScrollOffset; + controller.jumpTo(clampedOffset.toDouble()); + }); + }); + } - _lastScrollOffset = clampedOffset.toDouble(); - _scrollController.jumpTo(clampedOffset.toDouble()); + void _withAttachedScrollController( + void Function(ZoomScrollController controller) action, + ) { + final controller = _activeScrollController; + if (controller != null && controller.hasClients) { + action(controller); + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + final updatedController = _activeScrollController; + if (updatedController != null && updatedController.hasClients) { + action(updatedController); + } }); } + double get _defaultPageOffset => widget.scrollOffset; + @override void dispose() { _controller?.removeListener(_reloadCallback); @@ -583,7 +621,6 @@ class MultiDayViewState extends State> { showVerticalLine: widget.showVerticalLines, controller: controller, hourHeight: _hourHeight, - multiDayViewScrollController: _scrollController, eventArranger: _eventArranger, showMutliDayBottomLine: widget.showWeekDayBottomLine, @@ -600,8 +637,12 @@ class MultiDayViewState extends State> { endHour: _endHour, fullDayHeaderTitle: _fullDayHeaderTitle, fullDayHeaderTextConfig: _fullDayHeaderTextConfig, - lastScrollOffset: _lastScrollOffset, + lastScrollOffset: widget.keepScrollOffset + ? (_pageOffsets[index] ?? _defaultPageOffset) + : _defaultPageOffset, scrollPhysics: widget.scrollPhysics, + pageIndex: index, + isCurrentPage: index == _currentIndex, scrollListener: _scrollPageListener, keepScrollOffset: widget.keepScrollOffset, ), @@ -974,6 +1015,16 @@ class MultiDayViewState extends State> { _currentIndex = index; }); } + _activeScrollController = null; + + _lastScrollOffset = widget.keepScrollOffset + ? (_pageOffsets[index] ?? _defaultPageOffset) + : _defaultPageOffset; + if (!widget.keepScrollOffset) { + _pageOffsets[index] = _defaultPageOffset; + _jumpToOffsetAfterPageTransition(_defaultPageOffset); + } + widget.onPageChange?.call(_currentStartDate, _currentIndex); } @@ -1111,11 +1162,13 @@ class MultiDayViewState extends State> { Duration duration = const Duration(milliseconds: 200), Curve curve = Curves.linear, }) { - _scrollController.animateTo( - offset, - duration: duration, - curve: curve, - ); + _withAttachedScrollController((controller) { + controller.animateTo( + offset, + duration: duration, + curve: curve, + ); + }); } /// check if any dates contains current date or not. @@ -1127,7 +1180,17 @@ class MultiDayViewState extends State> { } /// Listener for every week page ScrollController - void _scrollPageListener(ScrollController controller) { - _lastScrollOffset = controller.offset; + void _scrollPageListener( + int pageIndex, + double offset, + ZoomScrollController controller, + ) { + _activeScrollController = controller; + if (!widget.keepScrollOffset) return; + + _pageOffsets[pageIndex] = offset; + if (pageIndex == _currentIndex) { + _lastScrollOffset = offset; + } } } diff --git a/lib/src/week_view/_internal_week_view_page.dart b/lib/src/week_view/_internal_week_view_page.dart index b2567080..a09345cc 100644 --- a/lib/src/week_view/_internal_week_view_page.dart +++ b/lib/src/week_view/_internal_week_view_page.dart @@ -127,8 +127,6 @@ class InternalWeekViewPage extends StatefulWidget { /// Display full day events. final FullDayEventBuilder fullDayEventBuilder; - final ScrollController weekViewScrollController; - /// First hour displayed in the layout final int startHour; @@ -153,8 +151,18 @@ class InternalWeekViewPage extends StatefulWidget { /// Defines full day events header text config final FullDayHeaderTextConfig fullDayHeaderTextConfig; - /// Scroll listener to set every page's last offset - final void Function(ScrollController) scrollListener; + /// Scroll listener to set every page's last offset. + final void Function( + int pageIndex, + double offset, + ZoomScrollController controller, + ) scrollListener; + + /// Page index in the parent [PageView]. + final int pageIndex; + + /// Whether this page is currently visible in parent [PageView]. + final bool isCurrentPage; /// Last scroll offset of week view page. final double lastScrollOffset; @@ -228,7 +236,8 @@ class InternalWeekViewPage extends StatefulWidget { required this.fullDayHeaderTextConfig, required this.scrollPhysics, required this.scrollListener, - required this.weekViewScrollController, + required this.pageIndex, + required this.isCurrentPage, this.backgroundColor, this.timeSlotColorBuilder, this.fullDayHeaderTitle = '', @@ -253,6 +262,17 @@ class _InternalWeekViewPageState initialScrollOffset: widget.lastScrollOffset, ); scrollController.addListener(_scrollControllerListener); + + if (widget.isCurrentPage) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + widget.scrollListener( + widget.pageIndex, + scrollController.offset, + scrollController, + ); + }); + } } @override @@ -271,6 +291,23 @@ class _InternalWeekViewPageState (oldWidget.heightPerMinute > 0 ? oldWidget.heightPerMinute : 1.0); scrollController.prepareZoomJump(scaledOffset); } + + if (!widget.keepScrollOffset && + widget.isCurrentPage && + !oldWidget.isCurrentPage && + scrollController.hasClients) { + scrollController.jumpTo(widget.lastScrollOffset); + } + + if (widget.isCurrentPage && !oldWidget.isCurrentPage) { + widget.scrollListener( + widget.pageIndex, + scrollController.hasClients + ? scrollController.offset + : widget.lastScrollOffset, + scrollController, + ); + } } @override @@ -282,7 +319,11 @@ class _InternalWeekViewPageState } void _scrollControllerListener() { - widget.scrollListener(scrollController); + widget.scrollListener( + widget.pageIndex, + scrollController.offset, + scrollController, + ); } /// Builds background layers for time slots in the week view. @@ -478,9 +519,7 @@ class _InternalWeekViewPageState scrollbars: widget.keepScrollOffset, ), child: SingleChildScrollView( - controller: widget.keepScrollOffset - ? scrollController - : widget.weekViewScrollController, + controller: scrollController, physics: widget.scrollPhysics, child: SizedBox( height: widget.height, diff --git a/lib/src/week_view/week_view.dart b/lib/src/week_view/week_view.dart index 0cc2732a..26be7ecf 100644 --- a/lib/src/week_view/week_view.dart +++ b/lib/src/week_view/week_view.dart @@ -444,11 +444,22 @@ class WeekViewState extends State> { /// Provides data for rendering events for the week. EventController? _controller; - /// Scroll controller for managing vertical scrolling. - late ZoomScrollController _scrollController; + /// Per-page scroll offset cache keyed by page index. + final Map _pageOffsets = {}; + + /// Currently visible page scroll controller. + ZoomScrollController? _activeScrollController; /// Public getter for accessing the scroll controller. - ScrollController get scrollController => _scrollController; + ScrollController get scrollController { + final controller = _activeScrollController; + if (controller == null || !controller.hasClients) { + throw StateError( + "ScrollController is not attached to any scroll views yet.", + ); + } + return controller; + } /// List of days in a week with their properties (name, order, etc.). /// Used for rendering day headers and determining week layout. @@ -468,10 +479,7 @@ class WeekViewState extends State> { @override void initState() { super.initState(); - _lastScrollOffset = widget.scrollOffset; - - _scrollController = - ZoomScrollController(initialScrollOffset: widget.scrollOffset); + _lastScrollOffset = _defaultPageOffset; _startHour = widget.startHour; _endHour = widget.endHour; @@ -488,6 +496,7 @@ class WeekViewState extends State> { _calculateHeights(); _pageController = PageController(initialPage: _currentIndex); + _pageOffsets[_currentIndex] = _lastScrollOffset; _eventArranger = widget.eventArranger ?? SideEventArranger(); _assignBuilders(); @@ -536,6 +545,8 @@ class WeekViewState extends State> { widget.maxDay != oldWidget.maxDay) { _setDateRange(); _regulateCurrentDate(); + _pageOffsets.clear(); + _pageOffsets[_currentIndex] = _defaultPageOffset; _pageController.jumpToPage(_currentIndex); } @@ -554,21 +565,27 @@ class WeekViewState extends State> { // Read the ACTUAL current scroll position from the shared controller // (not _lastScrollOffset, which is only updated when keepScrollOffset=true). // Scale it proportionally so the same time slot stays visible after zoom. - final currentOffset = _scrollController.hasClients - ? _scrollController.offset - : _lastScrollOffset; + final activeController = _activeScrollController; + final currentOffset = + activeController != null && activeController.hasClients + ? activeController.offset + : _lastScrollOffset; final scaledOffset = currentOffset * widget.heightPerMinute / (oldWidget.heightPerMinute > 0 ? oldWidget.heightPerMinute : 1.0); _lastScrollOffset = scaledOffset; - // prepareZoomJump stores the target offset so ZoomScrollController can - // apply it inside applyContentDimensions (during layout, before paint), - // eliminating the one-frame flicker that addPostFrameCallback caused. - _scrollController.prepareZoomJump(scaledOffset); + _pageOffsets[_currentIndex] = scaledOffset; + if (activeController != null && activeController.hasClients) { + // prepareZoomJump stores the target offset so ZoomScrollController can + // apply it inside applyContentDimensions (during layout, before paint), + // eliminating the one-frame flicker that addPostFrameCallback caused. + activeController.prepareZoomJump(scaledOffset); + } } if (widget.scrollOffset != oldWidget.scrollOffset) { _lastScrollOffset = widget.scrollOffset; + _pageOffsets[_currentIndex] = _lastScrollOffset; _jumpToOffsetAfterPageTransition(widget.scrollOffset); } } @@ -604,18 +621,40 @@ class WeekViewState extends State> { void _jumpToOffsetAfterPageTransition(double offset) { _runAfterPageTransition(() { - if (!_scrollController.hasClients) return; + _withAttachedScrollController((controller) { + final clampedOffset = offset.clamp( + controller.position.minScrollExtent, + controller.position.maxScrollExtent, + ); - final clampedOffset = offset.clamp( - _scrollController.position.minScrollExtent, - _scrollController.position.maxScrollExtent, - ); + _lastScrollOffset = clampedOffset.toDouble(); + _pageOffsets[_currentIndex] = _lastScrollOffset; + controller.jumpTo(clampedOffset.toDouble()); + }); + }); + } - _lastScrollOffset = clampedOffset.toDouble(); - _scrollController.jumpTo(clampedOffset.toDouble()); + void _withAttachedScrollController( + void Function(ZoomScrollController controller) action, + ) { + final controller = _activeScrollController; + if (controller != null && controller.hasClients) { + action(controller); + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + final updatedController = _activeScrollController; + if (updatedController != null && updatedController.hasClients) { + action(updatedController); + } }); } + double get _defaultPageOffset => widget.scrollOffset; + @override void dispose() { _controller?.removeListener(_reloadCallback); @@ -695,7 +734,6 @@ class WeekViewState extends State> { showVerticalLine: widget.showVerticalLines, controller: controller, hourHeight: _hourHeight, - weekViewScrollController: _scrollController, eventArranger: _eventArranger, weekDays: _weekDays, minuteSlotSize: widget.minuteSlotSize, @@ -710,8 +748,12 @@ class WeekViewState extends State> { endHour: _endHour, fullDayHeaderTitle: _fullDayHeaderTitle, fullDayHeaderTextConfig: _fullDayHeaderTextConfig, - lastScrollOffset: _lastScrollOffset, + lastScrollOffset: widget.keepScrollOffset + ? (_pageOffsets[index] ?? _defaultPageOffset) + : _defaultPageOffset, scrollPhysics: widget.scrollPhysics, + pageIndex: index, + isCurrentPage: index == _currentIndex, scrollListener: _scrollPageListener, keepScrollOffset: widget.keepScrollOffset, timeSlotColorBuilder: _timeSlotColorBuilder, @@ -1070,6 +1112,16 @@ class WeekViewState extends State> { _currentIndex = index; }); } + _activeScrollController = null; + + _lastScrollOffset = widget.keepScrollOffset + ? (_pageOffsets[index] ?? _defaultPageOffset) + : _defaultPageOffset; + if (!widget.keepScrollOffset) { + _pageOffsets[index] = _defaultPageOffset; + _jumpToOffsetAfterPageTransition(_defaultPageOffset); + } + widget.onPageChange?.call(_currentStartDate, _currentIndex); } @@ -1180,11 +1232,13 @@ class WeekViewState extends State> { Duration duration = const Duration(milliseconds: 200), Curve curve = Curves.linear, }) { - _scrollController.animateTo( - offset, - duration: duration, - curve: curve, - ); + _withAttachedScrollController((controller) { + controller.animateTo( + offset, + duration: duration, + curve: curve, + ); + }); } /// Check if any dates contain current date. Returns true if found. @@ -1195,8 +1249,18 @@ class WeekViewState extends State> { } /// Listener for every week page ScrollController - void _scrollPageListener(ScrollController controller) { - _lastScrollOffset = controller.offset; + void _scrollPageListener( + int pageIndex, + double offset, + ZoomScrollController controller, + ) { + _activeScrollController = controller; + if (!widget.keepScrollOffset) return; + + _pageOffsets[pageIndex] = offset; + if (pageIndex == _currentIndex) { + _lastScrollOffset = offset; + } } }