Skip to content

Commit 8d6576b

Browse files
✨ Fixes issue #331: Add auto-scroll to current time for Day, Week, and Multi-Day views
1 parent 78d29f0 commit 8d6576b

10 files changed

Lines changed: 577 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- Updated documentation for `DayView`, `WeekView`, `MonthView` and `MultiDayView` to add more details about the parameters and their usage. [#448](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/pull/448)
1212
- Fixed `onlyShowToday` parameter in `WeekView` to update `liveTimeIndicator` properly. [#518](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/518)
1313
- Added `ZoomScrollController` to `DayView`, `WeekView` and `MultiDayView` for programmatic control of zoom level and scroll position. [#522](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/522)
14+
- Added `scrollToCurrentTime` to `DayView`, `WeekView` and `MultiDayView` to auto-center the timeline on the current time, along with `animateToCurrentTime()` and `jumpToCurrentTime()` methods to trigger it imperatively. [#331](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/331)
1415

1516
# [2.0.0 - 17 Mar 2026](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/tree/2.0.0)
1617

doc/documentation.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ DayView(
254254
backgroundColor: Colors.white, // Background color of day view
255255
showLiveTimeLineInAllDays: true, // Display live time line in all pages
256256
scrollOffset: 0, // Initial scroll position
257+
scrollToCurrentTime: true, // Auto-center the timeline on the current time
257258
width: 400, // Width of day view page
258259
timeLineOffset: 0, // Offset for timeline
259260
// Event handling
@@ -424,6 +425,7 @@ WeekView(
424425
),
425426
// Scroll configuration
426427
scrollOffset: 0.0,
428+
scrollToCurrentTime: true, // Auto-center the timeline on the current time
427429
scrollPhysics: ScrollPhysics(), // ScrollPhysics for vertical scrolling
428430
pageViewPhysics: ScrollPhysics(), // ScrollPhysics for page view
429431
keepScrollOffset: true, // Maintain scroll offset when the page changes
@@ -543,6 +545,7 @@ MultiDayView(
543545
),
544546
// Scroll configuration
545547
scrollOffset: 0.0,
548+
scrollToCurrentTime: true, // Auto-center the timeline on the current time
546549
scrollPhysics: ScrollPhysics(), // ScrollPhysics for vertical scrolling
547550
pageViewPhysics: ScrollPhysics(), // ScrollPhysics for page view
548551
keepScrollOffset: true, // Maintain scroll offset when the page changes

example/lib/widgets/day_view_widget.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class DayViewWidget extends StatelessWidget {
1616
return DayView(
1717
key: state,
1818
width: width,
19-
startDuration: Duration(hours: 8),
19+
scrollToCurrentTime: true,
2020
showHalfHours: true,
2121
heightPerMinute: 3,
2222
timeLineBuilder: (date) => _timeLineBuilder(date, isLtr),

example/lib/widgets/multi_day_view_widget.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class MultiDayViewWidget extends StatelessWidget {
1616
daysInView: 3,
1717
width: width,
1818
showLiveTimeLineInAllDays: true,
19+
scrollToCurrentTime: true,
1920
eventArranger: SideEventArranger(maxWidth: 30),
2021
timeLineWidth: 65,
2122
scrollPhysics: const BouncingScrollPhysics(),

example/lib/widgets/week_view_widget.dart

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,15 @@ class WeekViewWidget extends StatelessWidget {
2323
heightPerMinute: heightPerMinute,
2424
showWeekends: true,
2525
showMidnightHour: true,
26+
scrollToCurrentTime: true,
2627
showLiveTimeLineInAllDays: true,
2728
keepScrollOffset: true,
2829
timeSlotColorBuilder: (_, slotStartTime, __, ___) {
2930
final hour = slotStartTime.hour;
3031
final isBusinessHours = hour >= 9 && hour < 17;
3132
final isLunchBreak = hour == 12;
32-
final isWeekend =
33-
slotStartTime.weekday == DateTime.saturday ||
34-
slotStartTime.weekday == DateTime.sunday;
3533

36-
return isWeekend
37-
? Colors.grey.shade100
38-
: isLunchBreak
34+
return isLunchBreak
3935
? Colors.orange.shade100
4036
: isBusinessHours
4137
? Colors.green.shade50

lib/src/day_view/day_view.dart

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import '../../calendar_view.dart';
1010
import '../constants.dart';
1111
import '../extensions.dart';
1212
import '../painters.dart';
13+
import '../scroll_to_current_time_mixin.dart';
1314
import '../zoom_scroll_controller.dart';
1415
import '_internal_day_view_page.dart';
1516

@@ -179,6 +180,20 @@ class DayView<T extends Object?> extends StatefulWidget {
179180
/// rather than relying on the [startDuration] parameter.
180181
final double? scrollOffset;
181182

183+
/// When true, the timeline automatically scrolls so that the current time is
184+
/// centered in the viewport on the first build.
185+
///
186+
/// The current time honors [LiveTimeIndicatorSettings.currentTimeProvider]
187+
/// when provided and is clamped to the visible [startHour]/[endHour] range.
188+
/// This takes precedence over [scrollOffset] and [startDuration] for the
189+
/// initial scroll position.
190+
///
191+
/// To trigger this imperatively at any time (e.g. from a "Now" button) use
192+
/// [DayViewState.animateToCurrentTime] or [DayViewState.jumpToCurrentTime].
193+
///
194+
/// Default value is false.
195+
final bool scrollToCurrentTime;
196+
182197
/// This method will be called when user taps on timestamp in timeline.
183198
///
184199
/// Called when user taps on a time value in the timeline (left side of view).
@@ -327,6 +342,7 @@ class DayView<T extends Object?> extends StatefulWidget {
327342
this.verticalLineOffset = 10,
328343
this.backgroundColor,
329344
this.scrollOffset,
345+
this.scrollToCurrentTime = false,
330346
this.onEventTap,
331347
this.onEventLongTap,
332348
this.onDateLongPress,
@@ -381,7 +397,8 @@ class DayView<T extends Object?> extends StatefulWidget {
381397
DayViewState<T> createState() => DayViewState<T>();
382398
}
383399

384-
class DayViewState<T extends Object?> extends State<DayView<T>> {
400+
class DayViewState<T extends Object?> extends State<DayView<T>>
401+
with ScrollToCurrentTimeMixin<DayView<T>> {
385402
/// Width of the Day View widget in pixels.
386403
/// Calculated from widget width or device constraint width.
387404
late double _width;
@@ -504,6 +521,30 @@ class DayViewState<T extends Object?> extends State<DayView<T>> {
504521
return controller;
505522
}
506523

524+
// --- ScrollToCurrentTimeMixin implementation ---
525+
526+
@override
527+
DateTime Function()? get currentTimeProvider =>
528+
widget.liveTimeIndicatorSettings?.currentTimeProvider;
529+
530+
@override
531+
int get viewStartHour => widget.startHour;
532+
533+
@override
534+
int get viewEndHour => widget.endHour;
535+
536+
@override
537+
double get viewHeightPerMinute => widget.heightPerMinute;
538+
539+
@override
540+
ZoomScrollController? get activeScrollController => _activeScrollController;
541+
542+
@override
543+
void onCurrentTimeJumped(double offset) {
544+
_lastScrollOffset = offset;
545+
_pageOffsets[_currentIndex] = offset;
546+
}
547+
507548
/// Callback function triggered when the controller changes or events are modified.
508549
/// Used to rebuild the view when event data changes.
509550
late VoidCallback _reloadCallback;
@@ -515,7 +556,9 @@ class DayViewState<T extends Object?> extends State<DayView<T>> {
515556
@override
516557
void initState() {
517558
super.initState();
518-
_lastScrollOffset = _defaultPageOffset;
559+
_lastScrollOffset = widget.scrollToCurrentTime
560+
? offsetForTime(currentTime)
561+
: _defaultPageOffset;
519562

520563
_reloadCallback = _reload;
521564
_setDateRange();
@@ -529,6 +572,12 @@ class DayViewState<T extends Object?> extends State<DayView<T>> {
529572
_pageOffsets[_currentIndex] = _lastScrollOffset;
530573
_eventArranger = widget.eventArranger ?? SideEventArranger<T>();
531574
_assignBuilders();
575+
576+
if (widget.scrollToCurrentTime) {
577+
WidgetsBinding.instance.addPostFrameCallback(
578+
(_) => scrollToCurrentTimeAfterLayout(),
579+
);
580+
}
532581
}
533582

534583
@override

lib/src/multi_day_view/multi_day_view.dart

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import '../../calendar_view.dart';
88
import '../constants.dart';
99
import '../extensions.dart';
1010
import '../painters.dart';
11+
import '../scroll_to_current_time_mixin.dart';
1112
import '../zoom_scroll_controller.dart';
1213
import '_internal_multi_day_view_page.dart';
1314

@@ -129,6 +130,20 @@ class MultiDayView<T extends Object?> extends StatefulWidget {
129130
/// Scroll offset of week view page.
130131
final double scrollOffset;
131132

133+
/// When true, the timeline automatically scrolls so that the current time is
134+
/// centered in the viewport on the first build.
135+
///
136+
/// The current time honors [LiveTimeIndicatorSettings.currentTimeProvider]
137+
/// when provided and is clamped to the visible [startHour]/[endHour] range.
138+
/// This takes precedence over [scrollOffset] for the initial scroll position.
139+
///
140+
/// To trigger this imperatively at any time (e.g. from a "Now" button) use
141+
/// [MultiDayViewState.animateToCurrentTime] or
142+
/// [MultiDayViewState.jumpToCurrentTime].
143+
///
144+
/// Default value is false.
145+
final bool scrollToCurrentTime;
146+
132147
/// This method will be called when user taps on timestamp in timeline.
133148
final TimestampCallback? onTimestampTap;
134149

@@ -241,6 +256,7 @@ class MultiDayView<T extends Object?> extends StatefulWidget {
241256
this.backgroundColor,
242257
this.scrollPhysics,
243258
this.scrollOffset = 0.0,
259+
this.scrollToCurrentTime = false,
244260
this.onEventTap,
245261
this.onEventLongTap,
246262
this.onDateLongPress,
@@ -298,7 +314,8 @@ class MultiDayView<T extends Object?> extends StatefulWidget {
298314
MultiDayViewState<T> createState() => MultiDayViewState<T>();
299315
}
300316

301-
class MultiDayViewState<T extends Object?> extends State<MultiDayView<T>> {
317+
class MultiDayViewState<T extends Object?> extends State<MultiDayView<T>>
318+
with ScrollToCurrentTimeMixin<MultiDayView<T>> {
302319
late double _width;
303320
late double _height;
304321
late double _timeLineWidth;
@@ -355,6 +372,30 @@ class MultiDayViewState<T extends Object?> extends State<MultiDayView<T>> {
355372
return controller;
356373
}
357374

375+
// --- ScrollToCurrentTimeMixin implementation ---
376+
377+
@override
378+
DateTime Function()? get currentTimeProvider =>
379+
widget.liveTimeIndicatorSettings?.currentTimeProvider;
380+
381+
@override
382+
int get viewStartHour => widget.startHour;
383+
384+
@override
385+
int get viewEndHour => widget.endHour;
386+
387+
@override
388+
double get viewHeightPerMinute => widget.heightPerMinute;
389+
390+
@override
391+
ZoomScrollController? get activeScrollController => _activeScrollController;
392+
393+
@override
394+
void onCurrentTimeJumped(double offset) {
395+
_lastScrollOffset = offset;
396+
_pageOffsets[_currentIndex] = offset;
397+
}
398+
358399
late List<WeekDays> _weekDays;
359400

360401
late int _startHour;
@@ -365,7 +406,9 @@ class MultiDayViewState<T extends Object?> extends State<MultiDayView<T>> {
365406
@override
366407
void initState() {
367408
super.initState();
368-
_lastScrollOffset = _defaultPageOffset;
409+
_lastScrollOffset = widget.scrollToCurrentTime
410+
? offsetForTime(currentTime)
411+
: _defaultPageOffset;
369412

370413
_startHour = widget.startHour;
371414
_endHour = widget.endHour;
@@ -389,6 +432,12 @@ class MultiDayViewState<T extends Object?> extends State<MultiDayView<T>> {
389432
_fullDayHeaderTitle = widget.fullDayHeaderTitle;
390433
_fullDayHeaderTextConfig =
391434
widget.fullDayHeaderTextConfig ?? FullDayHeaderTextConfig();
435+
436+
if (widget.scrollToCurrentTime) {
437+
WidgetsBinding.instance.addPostFrameCallback(
438+
(_) => scrollToCurrentTimeAfterLayout(),
439+
);
440+
}
392441
}
393442

394443
@override
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) 2021 Simform Solutions. All rights reserved.
2+
// Use of this source code is governed by a MIT-style license
3+
// that can be found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
import 'zoom_scroll_controller.dart';
8+
9+
/// Provides scroll-to-current-time functionality shared by DayView, WeekView,
10+
/// and MultiDayView state classes.
11+
mixin ScrollToCurrentTimeMixin<W extends StatefulWidget> on State<W> {
12+
// --- Abstract interface (implemented by the host state) ---
13+
14+
/// Returns the [currentTimeProvider] from the view's
15+
/// [LiveTimeIndicatorSettings], or null to use [DateTime.now].
16+
DateTime Function()? get currentTimeProvider;
17+
18+
/// First hour shown in the timeline (0–23).
19+
int get viewStartHour;
20+
21+
/// Last hour shown in the timeline (1–24).
22+
int get viewEndHour;
23+
24+
/// Pixels per minute used to calculate scroll offsets.
25+
double get viewHeightPerMinute;
26+
27+
/// Currently active scroll controller, or null if not yet attached.
28+
ZoomScrollController? get activeScrollController;
29+
30+
/// Called by [jumpToCurrentTime] so the host can persist the new offset
31+
/// (e.g. update _lastScrollOffset and _pageOffsets[_currentIndex]).
32+
void onCurrentTimeJumped(double offset);
33+
34+
// --- Shared implementation ---
35+
36+
int _currentTimeScrollAttempts = 0;
37+
38+
/// Current time, honoring [currentTimeProvider] when provided.
39+
DateTime get currentTime => currentTimeProvider?.call() ?? DateTime.now();
40+
41+
/// Top-aligned pixel offset of [time] within the visible timeline range.
42+
///
43+
/// Accounts for [viewStartHour]/[viewEndHour] and clamps the result so that
44+
/// times outside the range map to the nearest edge.
45+
double offsetForTime(DateTime time) {
46+
final minutesFromStart = (time.hour - viewStartHour) * 60 + time.minute;
47+
final visibleMinutes = (viewEndHour - viewStartHour) * 60;
48+
// Guard against an inverted range (endHour < startHour). Asserts that are
49+
// meant to prevent this are stripped in release builds, and clamp() throws
50+
// when its lower bound exceeds the upper bound, so fail gracefully instead.
51+
if (visibleMinutes <= 0) return 0;
52+
final clampedMinutes = minutesFromStart.clamp(0, visibleMinutes);
53+
return viewHeightPerMinute * clampedMinutes;
54+
}
55+
56+
double? _currentTimeScrollOffset({required bool center}) {
57+
final controller = activeScrollController;
58+
if (controller == null || !controller.hasClients) return null;
59+
final position = controller.position;
60+
var offset = offsetForTime(currentTime);
61+
if (center) offset -= position.viewportDimension / 2;
62+
return offset.clamp(
63+
position.minScrollExtent,
64+
position.maxScrollExtent,
65+
);
66+
}
67+
68+
/// Centers the current time once the scrollable is ready.
69+
///
70+
/// Retries for a few frames if the controller is not attached yet, then
71+
/// gives up to avoid an endless frame-scheduling loop.
72+
void scrollToCurrentTimeAfterLayout() {
73+
if (!mounted) return;
74+
final controller = activeScrollController;
75+
if (controller == null || !controller.hasClients) {
76+
if (_currentTimeScrollAttempts++ >= 5) return;
77+
WidgetsBinding.instance.addPostFrameCallback(
78+
(_) => scrollToCurrentTimeAfterLayout(),
79+
);
80+
return;
81+
}
82+
jumpToCurrentTime();
83+
}
84+
85+
/// Instantly positions the timeline so the current time is visible.
86+
///
87+
/// When [center] is true (default) the current time is placed at the
88+
/// vertical center of the viewport; otherwise it aligns to the top.
89+
/// Does nothing if the scrollable is not yet attached.
90+
void jumpToCurrentTime({bool center = true}) {
91+
final offset = _currentTimeScrollOffset(center: center);
92+
if (offset == null) return;
93+
onCurrentTimeJumped(offset);
94+
activeScrollController?.jumpTo(offset);
95+
}
96+
97+
/// Animates the timeline so that the current time becomes visible.
98+
///
99+
/// When [center] is true (default) the current time is positioned at the
100+
/// vertical center of the viewport; otherwise it aligns to the top. The
101+
/// target is clamped to the scrollable range. Does nothing if the view has
102+
/// not been laid out yet.
103+
Future<void> animateToCurrentTime({
104+
bool center = true,
105+
Duration duration = const Duration(milliseconds: 200),
106+
Curve curve = Curves.linear,
107+
}) async {
108+
final offset = _currentTimeScrollOffset(center: center);
109+
if (offset == null) return;
110+
final controller = activeScrollController;
111+
if (controller == null || !controller.hasClients) return;
112+
// Persist the target so a rebuild mid-animation (which seeds the page from
113+
// the stored offset) doesn't reset the position, matching jumpToCurrentTime.
114+
onCurrentTimeJumped(offset);
115+
await controller.animateTo(offset, duration: duration, curve: curve);
116+
}
117+
}

0 commit comments

Comments
 (0)