Skip to content

Commit cb3ad8a

Browse files
Make auto_scroll follow in-place content growth (#6637)
auto_scroll only scrolled to the end from ScrollableControl.build(), so it followed added children but not content that grows *in place* (e.g. text streamed into an existing child) — that never rebuilds the scrollable. - Add a ScrollMetricsNotification listener that re-pins to the end whenever the scroll extent grows, even without a rebuild. - Track the user's position; suspend pinning while they've scrolled away from the end and resume when they return. - Add an auto_scroll_animation property (duration + curve) to control the scroll; defaults to the historical 1s ease, set duration 0 for an instant jump.
1 parent ff08962 commit cb3ad8a

2 files changed

Lines changed: 96 additions & 13 deletions

File tree

packages/flet/lib/src/controls/scrollable_control.dart

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ class _ScrollableControlState extends State<ScrollableControl>
3535
late bool _ownController = false;
3636
final _ConstraintsHolder _outerConstraints = _ConstraintsHolder();
3737

38+
// Auto-scroll: keep the view pinned to the bottom (end) while the user
39+
// hasn't scrolled away from it. Unlike a one-shot scroll on build, this
40+
// follows content that grows *in place* — e.g. streamed text appended to an
41+
// existing child — which does not rebuild this widget and so is otherwise
42+
// missed.
43+
static const double _autoScrollThreshold = 40.0;
44+
bool _pinnedToEnd = true;
45+
double _lastMaxScrollExtent = 0;
46+
bool _autoScrollScheduled = false;
47+
// When set (via the `auto_scroll_animation` property) auto-scroll animates
48+
// to the end with this duration/curve; otherwise it jumps instantly.
49+
ImplicitAnimationDetails? _autoScrollAnimation;
50+
3851
@override
3952
void initState() {
4053
super.initState();
@@ -45,6 +58,52 @@ class _ScrollableControlState extends State<ScrollableControl>
4558
_controller = ScrollController();
4659
_ownController = true;
4760
}
61+
_controller.addListener(_onScroll);
62+
}
63+
64+
void _onScroll() {
65+
if (!_controller.hasClients) return;
66+
final pos = _controller.position;
67+
// "At the end" if within a small threshold of the max extent — so a
68+
// partially-visible last line still counts as pinned.
69+
_pinnedToEnd = pos.pixels >= pos.maxScrollExtent - _autoScrollThreshold;
70+
}
71+
72+
void _scheduleScrollToEnd({bool force = false}) {
73+
if (_autoScrollScheduled) return;
74+
_autoScrollScheduled = true;
75+
WidgetsBinding.instance.addPostFrameCallback((_) {
76+
_autoScrollScheduled = false;
77+
if (!mounted || !_controller.hasClients) return;
78+
final max = _controller.position.maxScrollExtent;
79+
_lastMaxScrollExtent = max;
80+
if (force || _pinnedToEnd) {
81+
final anim = _autoScrollAnimation;
82+
if (anim != null && anim.duration > Duration.zero) {
83+
_controller.animateTo(max,
84+
duration: anim.duration, curve: anim.curve);
85+
} else {
86+
_controller.jumpTo(max);
87+
}
88+
_pinnedToEnd = true;
89+
}
90+
});
91+
}
92+
93+
// Wraps the scrollable so auto_scroll follows extent growth (streamed text,
94+
// inserted items) even when this widget itself does not rebuild.
95+
Widget _wrapAutoScroll(Widget child) {
96+
return NotificationListener<ScrollMetricsNotification>(
97+
onNotification: (notification) {
98+
final max = notification.metrics.maxScrollExtent;
99+
if (max > _lastMaxScrollExtent + 0.5 && _pinnedToEnd) {
100+
_scheduleScrollToEnd();
101+
}
102+
_lastMaxScrollExtent = max;
103+
return false;
104+
},
105+
child: child,
106+
);
48107
}
49108

50109
Future<dynamic> _invokeMethod(String name, dynamic args) async {
@@ -86,6 +145,7 @@ class _ScrollableControlState extends State<ScrollableControl>
86145

87146
@override
88147
void dispose() {
148+
_controller.removeListener(_onScroll);
89149
if (_ownController) {
90150
_controller.dispose();
91151
}
@@ -99,17 +159,25 @@ class _ScrollableControlState extends State<ScrollableControl>
99159
final scrollConfiguration =
100160
widget.control.getScrollbarConfiguration("scroll");
101161

102-
if (widget.control.getBool("auto_scroll", false)!) {
103-
WidgetsBinding.instance.addPostFrameCallback((_) {
104-
_controller.animateTo(
105-
_controller.position.maxScrollExtent,
106-
duration: const Duration(seconds: 1),
107-
curve: Curves.ease,
108-
);
109-
});
162+
final autoScroll = widget.control.getBool("auto_scroll", false)!;
163+
if (autoScroll) {
164+
// Default to a 1s ease animation (the historical auto_scroll behavior);
165+
// set `auto_scroll_animation` to override, or to duration 0 for an
166+
// instant jump.
167+
_autoScrollAnimation = widget.control.getAnimation(
168+
"auto_scroll_animation",
169+
ImplicitAnimationDetails(
170+
duration: const Duration(seconds: 1), curve: Curves.ease),
171+
);
172+
// Re-pin on rebuild (e.g. an item added). Extent growth without a
173+
// rebuild is handled by the ScrollMetricsNotification listener in
174+
// _wrapAutoScroll.
175+
_scheduleScrollToEnd();
110176
}
111177

112-
if (scrollConfiguration == null) return widget.child;
178+
if (scrollConfiguration == null) {
179+
return autoScroll ? _wrapAutoScroll(widget.child) : widget.child;
180+
}
113181

114182
Widget child = widget.child;
115183
if (widget.wrapIntoScrollableView) {
@@ -155,7 +223,7 @@ class _ScrollableControlState extends State<ScrollableControl>
155223
);
156224
}
157225

158-
return result;
226+
return autoScroll ? _wrapAutoScroll(result) : result;
159227
}
160228
}
161229

sdk/python/packages/flet/src/flet/controls/scrollable_control.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from enum import Enum
33
from typing import Optional, Union
44

5-
from flet.controls.animation import AnimationCurve
5+
from flet.controls.animation import AnimationCurve, AnimationValue
66
from flet.controls.base_control import control
77
from flet.controls.control import Control
88
from flet.controls.control_event import Event, EventHandler
@@ -351,13 +351,28 @@ class ScrollableControl(Control):
351351

352352
auto_scroll: bool = False
353353
"""
354-
Whether the scrollbar should automatically move its position to the end when \
355-
children updated.
354+
Whether to automatically move the scroll position to the end when the content
355+
changes.
356+
357+
This keeps the view pinned to the end as new children are added *and* as
358+
existing content grows in place (e.g. text streamed into an existing child).
359+
Pinning is suspended while the user has scrolled away from the end, and
360+
resumes once they scroll back.
356361
357362
Note:
358363
Must be `False` for :meth:`scroll_to` method to work.
359364
"""
360365

366+
auto_scroll_animation: Optional[AnimationValue] = None
367+
"""
368+
Animation used when :attr:`auto_scroll` moves the view to the end.
369+
370+
Accepts an :class:`~flet.Animation` (duration + curve), an `int` duration in
371+
milliseconds, or `True`. Defaults to a 1 second `ease` animation; set a
372+
duration of `0` for an instant jump (recommended when following fast,
373+
token-by-token streaming so the view stays tightly pinned).
374+
"""
375+
361376
scroll_interval: Number = 10
362377
"""
363378
Throttling in milliseconds for :attr:`on_scroll` event.

0 commit comments

Comments
 (0)