diff --git a/packages/flet/lib/src/controls/scrollable_control.dart b/packages/flet/lib/src/controls/scrollable_control.dart index 188a42122d..6134bb8862 100644 --- a/packages/flet/lib/src/controls/scrollable_control.dart +++ b/packages/flet/lib/src/controls/scrollable_control.dart @@ -35,6 +35,19 @@ class _ScrollableControlState extends State late bool _ownController = false; final _ConstraintsHolder _outerConstraints = _ConstraintsHolder(); + // Auto-scroll: keep the view pinned to the bottom (end) while the user + // hasn't scrolled away from it. Unlike a one-shot scroll on build, this + // follows content that grows *in place* — e.g. streamed text appended to an + // existing child — which does not rebuild this widget and so is otherwise + // missed. + static const double _autoScrollThreshold = 40.0; + bool _pinnedToEnd = true; + double _lastMaxScrollExtent = 0; + bool _autoScrollScheduled = false; + // When set (via the `auto_scroll_animation` property) auto-scroll animates + // to the end with this duration/curve; otherwise it jumps instantly. + ImplicitAnimationDetails? _autoScrollAnimation; + @override void initState() { super.initState(); @@ -45,6 +58,52 @@ class _ScrollableControlState extends State _controller = ScrollController(); _ownController = true; } + _controller.addListener(_onScroll); + } + + void _onScroll() { + if (!_controller.hasClients) return; + final pos = _controller.position; + // "At the end" if within a small threshold of the max extent — so a + // partially-visible last line still counts as pinned. + _pinnedToEnd = pos.pixels >= pos.maxScrollExtent - _autoScrollThreshold; + } + + void _scheduleScrollToEnd({bool force = false}) { + if (_autoScrollScheduled) return; + _autoScrollScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _autoScrollScheduled = false; + if (!mounted || !_controller.hasClients) return; + final max = _controller.position.maxScrollExtent; + _lastMaxScrollExtent = max; + if (force || _pinnedToEnd) { + final anim = _autoScrollAnimation; + if (anim != null && anim.duration > Duration.zero) { + _controller.animateTo(max, + duration: anim.duration, curve: anim.curve); + } else { + _controller.jumpTo(max); + } + _pinnedToEnd = true; + } + }); + } + + // Wraps the scrollable so auto_scroll follows extent growth (streamed text, + // inserted items) even when this widget itself does not rebuild. + Widget _wrapAutoScroll(Widget child) { + return NotificationListener( + onNotification: (notification) { + final max = notification.metrics.maxScrollExtent; + if (max > _lastMaxScrollExtent + 0.5 && _pinnedToEnd) { + _scheduleScrollToEnd(); + } + _lastMaxScrollExtent = max; + return false; + }, + child: child, + ); } Future _invokeMethod(String name, dynamic args) async { @@ -86,6 +145,7 @@ class _ScrollableControlState extends State @override void dispose() { + _controller.removeListener(_onScroll); if (_ownController) { _controller.dispose(); } @@ -99,17 +159,25 @@ class _ScrollableControlState extends State final scrollConfiguration = widget.control.getScrollbarConfiguration("scroll"); - if (widget.control.getBool("auto_scroll", false)!) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _controller.animateTo( - _controller.position.maxScrollExtent, - duration: const Duration(seconds: 1), - curve: Curves.ease, - ); - }); + final autoScroll = widget.control.getBool("auto_scroll", false)!; + if (autoScroll) { + // Default to a 1s ease animation (the historical auto_scroll behavior); + // set `auto_scroll_animation` to override, or to duration 0 for an + // instant jump. + _autoScrollAnimation = widget.control.getAnimation( + "auto_scroll_animation", + ImplicitAnimationDetails( + duration: const Duration(seconds: 1), curve: Curves.ease), + ); + // Re-pin on rebuild (e.g. an item added). Extent growth without a + // rebuild is handled by the ScrollMetricsNotification listener in + // _wrapAutoScroll. + _scheduleScrollToEnd(); } - if (scrollConfiguration == null) return widget.child; + if (scrollConfiguration == null) { + return autoScroll ? _wrapAutoScroll(widget.child) : widget.child; + } Widget child = widget.child; if (widget.wrapIntoScrollableView) { @@ -155,7 +223,7 @@ class _ScrollableControlState extends State ); } - return result; + return autoScroll ? _wrapAutoScroll(result) : result; } } diff --git a/sdk/python/packages/flet/src/flet/controls/scrollable_control.py b/sdk/python/packages/flet/src/flet/controls/scrollable_control.py index e0e760af68..1701cb1bc2 100644 --- a/sdk/python/packages/flet/src/flet/controls/scrollable_control.py +++ b/sdk/python/packages/flet/src/flet/controls/scrollable_control.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Optional, Union -from flet.controls.animation import AnimationCurve +from flet.controls.animation import AnimationCurve, AnimationValue from flet.controls.base_control import control from flet.controls.control import Control from flet.controls.control_event import Event, EventHandler @@ -351,13 +351,28 @@ class ScrollableControl(Control): auto_scroll: bool = False """ - Whether the scrollbar should automatically move its position to the end when \ - children updated. + Whether to automatically move the scroll position to the end when the content + changes. + + This keeps the view pinned to the end as new children are added *and* as + existing content grows in place (e.g. text streamed into an existing child). + Pinning is suspended while the user has scrolled away from the end, and + resumes once they scroll back. Note: Must be `False` for :meth:`scroll_to` method to work. """ + auto_scroll_animation: Optional[AnimationValue] = None + """ + Animation used when :attr:`auto_scroll` moves the view to the end. + + Accepts an :class:`~flet.Animation` (duration + curve), an `int` duration in + milliseconds, or `True`. Defaults to a 1 second `ease` animation; set a + duration of `0` for an instant jump (recommended when following fast, + token-by-token streaming so the view stays tightly pinned). + """ + scroll_interval: Number = 10 """ Throttling in milliseconds for :attr:`on_scroll` event.