Skip to content

Commit 0885ed2

Browse files
xsahil03xclaude
andcommitted
Merge branch 'master' into v10.0.0
Resolves SPL conflicts in scrollable_positioned_list and positioned_list: - Take master's preserve-pixels `_updateFirstVisibleItemIfNeeded` (PR #2703) - Take master's empty-list `_centerSliverPadding` guard - Take master's RTL `_axisDirection`-based `_updatePositions` branching - Take master's `_resolveLeadingEndPaddingAdjust` `index != 0` check - Drop orphaned `message_card_test.dart` (widget deleted in v10 design refresh); master's MessageCard fix from #2702 ported to StreamMessageContent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 parents 98097fa + 58331ec commit 0885ed2

10 files changed

Lines changed: 217 additions & 47 deletions

File tree

.fvmrc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"flutter": "3.27.4"
3+
}

packages/stream_chat_flutter/CHANGELOG.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,18 @@
148148

149149
- Fixed `StreamMessageListView` not auto-scrolling to the bottom on the user's own outgoing message
150150
until the server confirmed it.
151-
- Fixed a `FlutterError` ("A RenderViewport exceeded its maximum number of layout cycles") that
152-
could occur when fast-scrolling through the message list.
151+
- Fixed `StreamMessageListView` tripping `A RenderViewport exceeded its maximum number of layout
152+
cycles` under mid-list anchored layout. `ScrollablePositionedList` now preserves the scroll offset
153+
across reanchors instead of resetting it to 0.
154+
- Fixed `RenderBox was not laid out` thrown by `MessageCard._updateWidthLimit` when the attachments
155+
subtree was detached between scheduling the post-frame callback and it firing (e.g. the list was
156+
rebuilt or the message removed). Added a `hasSize` guard before reading `RenderBox.size`.
157+
158+
🚀 Improved
159+
160+
- `ScrollablePositionedList.padding` now accepts `EdgeInsetsGeometry` (resolved against
161+
`Directionality`), and `scrollTo` lands the target at the content-area edge by adjusting for
162+
leading padding.
153163

154164
## 9.24.0
155165

packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,9 @@ class _PositionedListState extends State<PositionedList> {
489489
// edge padding only when it owns the first/last item.
490490
EdgeInsets get _centerSliverPadding {
491491
final resolved = _resolvedPadding;
492+
// Empty list: center is the only sliver in the tree. Drop neither
493+
// edge or the gap would be asymmetric.
494+
if (widget.itemCount == 0) return resolved;
492495
final isFirst = widget.positionedIndex == 0;
493496
final isLast = widget.positionedIndex == widget.itemCount - 1;
494497
switch (_axisDirection) {
@@ -571,21 +574,16 @@ class _PositionedListState extends State<PositionedList> {
571574
} else {
572575
final itemOffset = box.localToGlobal(Offset.zero, ancestor: viewport).dx;
573576
if (!itemOffset.isFinite) continue;
577+
final viewportDimension = scrollController.position.viewportDimension;
578+
// Branch on the resolved axis direction so RTL is handled.
579+
final isRight = _axisDirection == AxisDirection.right;
580+
final leadingPx = isRight ? itemOffset : viewportDimension - (itemOffset + box.size.width);
581+
final trailingPx = isRight ? itemOffset + box.size.width : viewportDimension - itemOffset;
574582
positions.add(
575583
ItemPosition(
576584
index: key.index,
577-
itemLeadingEdge:
578-
(widget.reverse
579-
? scrollController.position.viewportDimension - (itemOffset + box.size.width)
580-
: itemOffset)
581-
.round() /
582-
scrollController.position.viewportDimension,
583-
itemTrailingEdge:
584-
(widget.reverse
585-
? scrollController.position.viewportDimension - itemOffset
586-
: (itemOffset + box.size.width))
587-
.round() /
588-
scrollController.position.viewportDimension,
585+
itemLeadingEdge: leadingPx.round() / viewportDimension,
586+
itemTrailingEdge: trailingPx.round() / viewportDimension,
589587
),
590588
);
591589
}

packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -522,13 +522,10 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList> wit
522522
/// reanchor would silently revert the user's in-progress scroll
523523
/// and the list would feel locked in place.
524524
///
525-
/// 2. The scroll offset is brought to zero via
526-
/// [ScrollPosition.correctBy], not [ScrollController.jumpTo].
527-
/// `correctBy` mutates `pixels` directly without firing `goIdle()`
528-
/// or dispatching scroll notifications, so an in-flight
529-
/// `DragScrollActivity` or `BallisticScrollActivity` keeps
530-
/// integrating its delta against the new baseline instead of
531-
/// being cancelled.
525+
/// 2. The new anchor folds the current pixel offset in so we can
526+
/// leave [primary.scrollController.position.pixels] untouched.
527+
/// Resetting pixels could exhaust [UnboundedRenderViewport]'s
528+
/// layout-cycle budget on deep mid-list anchors.
532529
void _updateFirstVisibleItemIfNeeded() {
533530
final keyBuilder = widget.itemKeyBuilder;
534531
if (keyBuilder == null || _lastKnownFirstItemKey == null) return;
@@ -543,21 +540,16 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList> wit
543540
final currentAlignment = viewport > 0
544541
? _firstVisibleItemAlignment - pixelDelta / viewport
545542
: _firstVisibleItemAlignment;
543+
// `newAnchor * viewport - pixels == currentAlignment * viewport`
544+
// so the anchored item lands at the same visual position without
545+
// touching pixels.
546+
final newAnchor = viewport > 0 ? currentAlignment + pixels / viewport : currentAlignment;
546547

547-
if (newIndex == primary.target && primary.alignment == currentAlignment && pixels == 0) {
548-
return;
549-
}
548+
if (newIndex == primary.target && primary.alignment == newAnchor) return;
550549
primary
551550
..target = newIndex
552-
..alignment = currentAlignment;
553-
if (hasClients && pixels != 0) {
554-
position!.correctBy(-pixels);
555-
}
556-
// The reanchor itself moves the pixel baseline to 0; record that
557-
// alongside the (now-applied) anchor so subsequent reanchors that
558-
// fire before the next layout compute deltas against the right
559-
// starting point.
560-
_firstVisibleItemAlignmentAtPixels = 0;
551+
..alignment = newAnchor;
552+
_firstVisibleItemAlignmentAtPixels = pixels;
561553
_lastKnownFirstItemIndex = newIndex;
562554
_firstVisibleItemAlignment = currentAlignment;
563555
}
@@ -663,13 +655,17 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList> wit
663655
}
664656

665657
/// Adjustment for `_startScroll`'s target so the leading-end item
666-
/// lands at the content-area edge (Compose semantic). Only applies
667-
/// when the center sliver carries the leading padding — for other
668-
/// indices SPL's pre-existing math is preserved.
669-
double _resolveLeadingEndPaddingAdjust({required int index, required double alignment}) {
658+
/// lands at the content-area edge. Only applies when the center
659+
/// sliver carries the leading padding — for other indices SPL's
660+
/// pre-existing math is preserved.
661+
double _resolveLeadingEndPaddingAdjust({
662+
required int index,
663+
required double alignment,
664+
}) {
670665
if (alignment != 0) return 0;
671-
final isAtLeadingEnd = widget.reverse ? index == 0 : index == widget.itemCount - 1;
672-
if (!isAtLeadingEnd) return 0;
666+
// Center carries the leading-edge padding only when
667+
// `positionedIndex == 0`, in both axis directions.
668+
if (index != 0) return 0;
673669
return _resolveLeadingPadding();
674670
}
675671

@@ -757,7 +753,7 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList> wit
757753
final localScrollAmount = itemPosition.itemLeadingEdge * viewport;
758754
// `itemLeadingEdge` comes from `getOffsetToReveal`, which is
759755
// geometric and padding-blind. Subtract `leadingPadding` so the
760-
// target lands at the content-area edge (Compose semantic).
756+
// target lands at the content-area edge.
761757
final paddingAdjust = _resolveLeadingEndPaddingAdjust(index: index, alignment: alignment);
762758
await primary.scrollController.animateTo(
763759
primary.scrollController.offset + localScrollAmount - alignment * viewport - paddingAdjust,

packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import 'dart:math' as math;
66

7+
import 'package:flutter/foundation.dart';
78
import 'package:flutter/rendering.dart';
89
import 'package:flutter/widgets.dart';
910

@@ -67,7 +68,10 @@ class UnboundedRenderViewport extends RenderViewport {
6768
}) : _requestedAnchor = anchor,
6869
_effectiveAnchor = anchor;
6970

70-
static const int _maxLayoutCycles = 10;
71+
// Raised from 10 to absorb walk-back cycles from the underlying
72+
// slivers after an anchor-preserving re-layout. Still bounded so
73+
// pathological infinite loops assert.
74+
static const int _maxLayoutCycles = 100;
7175

7276
/// The anchor the widget asked for. Used as the starting point each
7377
/// layout pass; may be overridden by the "fit anchor" fallback when
@@ -195,7 +199,9 @@ class UnboundedRenderViewport extends RenderViewport {
195199
offset.pixels + centerOffsetAdjustment,
196200
effectiveAnchor,
197201
);
198-
if (correction != 0.0) {
202+
// Sub-`precisionErrorTolerance` corrections can't move `pixels`
203+
// and would loop forever; treat as converged.
204+
if (correction.abs() > precisionErrorTolerance) {
199205
offset.correctBy(correction);
200206
} else {
201207
// *** Difference from [RenderViewport].

packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,18 @@ class _StreamMessageContentState extends State<StreamMessageContent> {
113113

114114
// Measures the attachment width after layout and constrains the bubble.
115115
void _updateWidthLimit() {
116+
if (!mounted) return;
117+
116118
final attachmentContext = attachmentsKey.currentContext;
117119
final renderBox = attachmentContext?.findRenderObject() as RenderBox?;
118-
final attachmentsWidth = renderBox?.size.width;
119-
120-
if (attachmentsWidth == null || attachmentsWidth == 0) return;
121-
if (mounted) setState(() => widthLimit = attachmentsWidth);
120+
// The attachments subtree may have been detached between scheduling this
121+
// post-frame callback and it firing. Reading [RenderBox.size] without
122+
// checking [RenderBox.hasSize] throws `RenderBox was not laid out`.
123+
if (renderBox == null || !renderBox.hasSize) return;
124+
final attachmentsWidth = renderBox.size.width;
125+
126+
if (attachmentsWidth == 0) return;
127+
setState(() => widthLimit = attachmentsWidth);
122128
}
123129

124130
@override

packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,6 +1258,135 @@ void main() {
12581258
);
12591259
});
12601260

1261+
testWidgets('padding - empty list keeps both main-axis edges', (WidgetTester tester) async {
1262+
tester.view.devicePixelRatio = 1.0;
1263+
tester.view.physicalSize = const Size(screenWidth, screenHeight);
1264+
1265+
// With itemCount: 0 the center sliver is the only sliver in the
1266+
// tree. Before the fix, isLast was false (positionedIndex 0 !=
1267+
// itemCount-1 = -1) so one main-axis padding edge was dropped.
1268+
// Smoke-test: rendering an empty list with padding must not throw
1269+
// and must keep symmetric padding.
1270+
await tester.pumpWidget(
1271+
MaterialApp(
1272+
home: ScrollablePositionedList.builder(
1273+
itemCount: 0,
1274+
itemBuilder: (_, __) => const SizedBox.shrink(),
1275+
padding: const EdgeInsets.symmetric(vertical: 20),
1276+
),
1277+
),
1278+
);
1279+
await tester.pumpAndSettle();
1280+
expect(tester.takeException(), isNull);
1281+
});
1282+
1283+
testWidgets(
1284+
'padding - scrollTo(0, alignment: 0) lands at content-area edge in '
1285+
'non-reversed list',
1286+
(WidgetTester tester) async {
1287+
final itemScrollController = ItemScrollController();
1288+
await setUpWidgetTest(
1289+
tester,
1290+
itemScrollController: itemScrollController,
1291+
padding: const EdgeInsets.only(top: 40),
1292+
);
1293+
1294+
// Scroll away from item 0 first so the next scrollTo isn't a no-op.
1295+
unawaited(
1296+
itemScrollController.scrollTo(
1297+
index: 50,
1298+
duration: const Duration(milliseconds: 1),
1299+
),
1300+
);
1301+
await tester.pumpAndSettle();
1302+
1303+
// Now scroll back to item 0 at alignment 0. Without the
1304+
// padding-adjust fix (the v10 `widget.reverse` ternary), the
1305+
// adjustment didn't fire for non-reversed lists and item 0 would
1306+
// not land at the content-area edge.
1307+
unawaited(
1308+
itemScrollController.scrollTo(
1309+
index: 0,
1310+
duration: const Duration(milliseconds: 1),
1311+
),
1312+
);
1313+
await tester.pumpAndSettle();
1314+
1315+
// Item 0's top edge sits at y = padding.top (the content-area
1316+
// edge), not the viewport edge.
1317+
expect(tester.getTopLeft(find.text('Item 0')), const Offset(0, 40));
1318+
},
1319+
);
1320+
1321+
testWidgets(
1322+
'horizontal RTL: itemLeadingEdge is measured from the right edge',
1323+
(WidgetTester tester) async {
1324+
final positionsListener = ItemPositionsListener.create();
1325+
tester.view.devicePixelRatio = 1.0;
1326+
tester.view.physicalSize = const Size(screenWidth, screenHeight);
1327+
1328+
await tester.pumpWidget(
1329+
MaterialApp(
1330+
home: Directionality(
1331+
textDirection: TextDirection.rtl,
1332+
child: ScrollablePositionedList.builder(
1333+
itemCount: 100,
1334+
scrollDirection: Axis.horizontal,
1335+
itemPositionsListener: positionsListener,
1336+
itemBuilder: (context, index) => SizedBox(
1337+
width: 50,
1338+
child: Text('Item $index'),
1339+
),
1340+
),
1341+
),
1342+
),
1343+
);
1344+
await tester.pumpAndSettle();
1345+
1346+
// RTL + scrollDirection horizontal + reverse=false resolves to
1347+
// AxisDirection.left, so item 0 sits flush against the viewport's
1348+
// right edge and `itemLeadingEdge` (measured from the leading
1349+
// side, i.e. the right) should be near 0.
1350+
final pos0 = positionsListener.itemPositions.value.firstWhere((p) => p.index == 0);
1351+
expect(pos0.itemLeadingEdge, lessThan(0.01));
1352+
expect(tester.getTopRight(find.text('Item 0')), const Offset(screenWidth, 0));
1353+
},
1354+
);
1355+
1356+
testWidgets(
1357+
'horizontal RTL: EdgeInsetsDirectional padding resolves correctly',
1358+
(WidgetTester tester) async {
1359+
tester.view.devicePixelRatio = 1.0;
1360+
tester.view.physicalSize = const Size(screenWidth, screenHeight);
1361+
1362+
await tester.pumpWidget(
1363+
MaterialApp(
1364+
home: Directionality(
1365+
textDirection: TextDirection.rtl,
1366+
child: ScrollablePositionedList.builder(
1367+
itemCount: 100,
1368+
scrollDirection: Axis.horizontal,
1369+
padding: const EdgeInsetsDirectional.only(start: 20),
1370+
itemBuilder: (context, index) => SizedBox(
1371+
width: 50,
1372+
child: Text('Item $index'),
1373+
),
1374+
),
1375+
),
1376+
),
1377+
);
1378+
await tester.pumpAndSettle();
1379+
1380+
// In RTL, `EdgeInsetsDirectional.start` resolves to right. Item
1381+
// 0 (the leading-end item) should be inset by 20 from the right
1382+
// viewport edge.
1383+
expect(
1384+
tester.getTopRight(find.text('Item 0')),
1385+
const Offset(screenWidth - 20, 0),
1386+
);
1387+
},
1388+
);
1389+
12611390
testWidgets('no repaint boundaries', (WidgetTester tester) async {
12621391
final itemScrollController = ItemScrollController();
12631392
await setUpWidgetTest(

packages/stream_chat_persistence/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414

1515
- Reduce the number of DB reads in the `ChatPersistenceClient.getChannelStates` method.
1616

17+
🔄 Changed
18+
19+
- Changed how dates are stored in the local cache, from integer seconds to ISO-8601 strings, in order to preserve millisecond precision.
20+
1721
## 9.24.0
1822

1923
- Updated `stream_chat` dependency to [`9.24.0`](https://pub.dev/packages/stream_chat/changelog).

packages/stream_chat_persistence/test/src/dao/connection_event_dao_test.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,23 @@ void main() {
112112
expect(updatedLastSyncAt, isSameDateAs(now));
113113
});
114114

115+
test('updateLastSyncAt preserves millisecond precision', () async {
116+
// A connection event must exist before lastSyncAt can be stored.
117+
await eventDao.updateConnectionEvent(
118+
Event(
119+
createdAt: DateTime.now(),
120+
me: OwnUser(id: 'testUserId'),
121+
),
122+
);
123+
124+
final preciseDate = DateTime.utc(2026, 5, 28, 12, 34, 56, 123);
125+
await eventDao.updateLastSyncAt(preciseDate);
126+
127+
final readBack = await eventDao.lastSyncAt;
128+
expect(readBack, equals(preciseDate));
129+
expect(readBack!.millisecond, equals(123));
130+
});
131+
115132
tearDown(() async {
116133
await database.disconnect();
117134
});

packages/stream_chat_persistence/test/src/utils/date_matcher.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ class _IsSameDateAs extends Matcher {
1414
date.day == targetDate?.day &&
1515
date.hour == targetDate?.hour &&
1616
date.minute == targetDate?.minute &&
17-
date.second == targetDate?.second;
17+
date.second == targetDate?.second &&
18+
date.millisecond == targetDate?.millisecond;
1819
}
1920

2021
@override

0 commit comments

Comments
 (0)