@@ -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
0 commit comments