Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 6 additions & 18 deletions lib/src/components/auto_scroll_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,25 +42,13 @@ class AutoScrollController extends ScrollController {
axisDirection: axisDirection,
);

// Auto-scroll if we were near bottom and content grew
// Auto-scroll if we were near bottom and content grew.
//
// This must be a silent layout-time correction. Deferring to a post-frame
// jump lets one frame paint with the old offset when content wraps onto a
// new line, which shows up as a visible flicker in chat/log panels.
if (_isAutoScrollEnabled && wasNearBottom && contentGrew) {
// Schedule scroll to end after the frame
try {
TerminalBinding.instance.addPostFrameCallback((_) {
if (isReversed) {
scrollToStart(); // In reverse mode, scroll to start (offset 0)
} else {
scrollToEnd(); // In normal mode, scroll to end
}
});
} catch (e) {
// In test environment or when binding is not available, scroll immediately
if (isReversed) {
scrollToStart();
} else {
scrollToEnd();
}
}
correctOffset(isReversed ? minScrollExtent : maxScrollExtent);
}

_previousMaxScrollExtent = maxScrollExtent;
Expand Down
10 changes: 10 additions & 0 deletions lib/src/components/scroll_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ class ScrollController extends ChangeNotifier {
notifyListeners();
}

/// Corrects the scroll position during layout without notifying listeners.
///
/// Scrollable render objects can call this while updating metrics to keep
/// paint offsets consistent in the same frame. This mirrors Flutter's
/// layout-time pixel correction and avoids scheduling a second frame for
/// purely metric-driven adjustments.
void correctOffset(double value) {
_offset = value.clamp(minScrollExtent, maxScrollExtent);
}

/// Scrolls by the given delta.
void scrollBy(double delta) {
jumpTo(offset + delta);
Expand Down
42 changes: 42 additions & 0 deletions test/layout/auto_scroll_controller_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,48 @@ void main() {
);
});

test('auto-scrolls in the same frame when an item wraps', () async {
await testNocterm(
'auto-scroll same frame on wrap',
(tester) async {
final scrollController = AutoScrollController();
final items = <String>[
'Message 1',
'Message 2',
'Message 3',
'tail',
];

Component buildList() {
return ListView.builder(
controller: scrollController,
itemCount: items.length,
itemBuilder: (context, index) {
return Text(items[index]);
},
);
}

await tester.pumpComponent(buildList());
expect(scrollController.offset, 0);
expect(scrollController.isAutoScrollEnabled, isTrue);

items[3] = 'alpha beta gamma delta epsilon zeta needle';

await tester.pumpComponent(buildList());

expect(scrollController.atEnd, isTrue);
expect(
tester.terminalState.getText(),
contains('needle'),
reason: 'The first painted frame after wrapping should already '
'be pinned to the new bottom.',
);
},
size: Size(20, 4),
);
});

test('disables auto-scroll when user scrolls up', () async {
await testNocterm(
'disable auto-scroll on manual scroll',
Expand Down
Loading