Skip to content

Commit 58331ec

Browse files
xsahil03xclaude
andauthored
fix(ui): stop StreamMessageListView from tripping RenderViewport layout-cycle limit (#2703)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 03c8827 commit 58331ec

6 files changed

Lines changed: 323 additions & 115 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: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,19 @@
44

55
- Fixed `StreamMessageListView` not auto-scrolling to the bottom on the user's own outgoing message
66
until the server confirmed it.
7-
- Fixed a `FlutterError` ("A RenderViewport exceeded its maximum number of layout cycles") that
8-
could occur when fast-scrolling through the message list.
7+
- Fixed `StreamMessageListView` tripping `A RenderViewport exceeded its maximum number of layout
8+
cycles` under mid-list anchored layout. `ScrollablePositionedList` now preserves the scroll offset
9+
across reanchors instead of resetting it to 0.
910
- Fixed `RenderBox was not laid out` thrown by `MessageCard._updateWidthLimit` when the attachments
1011
subtree was detached between scheduling the post-frame callback and it firing (e.g. the list was
1112
rebuilt or the message removed). Added a `hasSize` guard before reading `RenderBox.size`.
1213

14+
🚀 Improved
15+
16+
- `ScrollablePositionedList.padding` now accepts `EdgeInsetsGeometry` (resolved against
17+
`Directionality`), and `scrollTo` lands the target at the content-area edge by adjusting for
18+
leading padding.
19+
1320
## 9.24.0
1421

1522
✅ Added

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

Lines changed: 104 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ class PositionedList extends StatefulWidget {
125125
final bool addSemanticIndexes;
126126

127127
/// The amount of space by which to inset the children.
128-
final EdgeInsets? padding;
128+
final EdgeInsetsGeometry? padding;
129129

130130
/// Whether to wrap each child in a [RepaintBoundary].
131131
///
@@ -452,57 +452,85 @@ class _PositionedListState extends State<PositionedList> {
452452
);
453453
}
454454

455-
EdgeInsets get _leadingSliverPadding =>
456-
(widget.scrollDirection == Axis.vertical
457-
? widget.reverse
458-
? widget.padding?.copyWith(top: 0)
459-
: widget.padding?.copyWith(bottom: 0)
460-
: widget.reverse
461-
? widget.padding?.copyWith(left: 0)
462-
: widget.padding?.copyWith(right: 0)) ??
455+
EdgeInsets get _resolvedPadding =>
456+
widget.padding
457+
?.resolve(Directionality.maybeOf(context) ?? TextDirection.ltr) ??
463458
EdgeInsets.zero;
464459

465-
EdgeInsets get _centerSliverPadding => widget.scrollDirection == Axis.vertical
466-
? widget.reverse
467-
? widget.padding?.copyWith(
468-
top: widget.positionedIndex == widget.itemCount - 1
469-
? widget.padding!.top
470-
: 0,
471-
bottom:
472-
widget.positionedIndex == 0 ? widget.padding!.bottom : 0,
473-
) ??
474-
EdgeInsets.zero
475-
: widget.padding?.copyWith(
476-
top: widget.positionedIndex == 0 ? widget.padding!.top : 0,
477-
bottom: widget.positionedIndex == widget.itemCount - 1
478-
? widget.padding!.bottom
479-
: 0,
480-
) ??
481-
EdgeInsets.zero
482-
: widget.reverse
483-
? widget.padding?.copyWith(
484-
left: widget.positionedIndex == widget.itemCount - 1
485-
? widget.padding!.left
486-
: 0,
487-
right: widget.positionedIndex == 0 ? widget.padding!.right : 0,
488-
) ??
489-
EdgeInsets.zero
490-
: widget.padding?.copyWith(
491-
left: widget.positionedIndex == 0 ? widget.padding!.left : 0,
492-
right: widget.positionedIndex == widget.itemCount - 1
493-
? widget.padding!.right
494-
: 0,
495-
) ??
496-
EdgeInsets.zero;
497-
498-
EdgeInsets get _trailingSliverPadding =>
499-
widget.scrollDirection == Axis.vertical
500-
? widget.reverse
501-
? widget.padding?.copyWith(bottom: 0) ?? EdgeInsets.zero
502-
: widget.padding?.copyWith(top: 0) ?? EdgeInsets.zero
503-
: widget.reverse
504-
? widget.padding?.copyWith(right: 0) ?? EdgeInsets.zero
505-
: widget.padding?.copyWith(left: 0) ?? EdgeInsets.zero;
460+
AxisDirection get _axisDirection {
461+
if (widget.scrollDirection == Axis.vertical) {
462+
return widget.reverse ? AxisDirection.up : AxisDirection.down;
463+
}
464+
final ltr = (Directionality.maybeOf(context) ?? TextDirection.ltr) ==
465+
TextDirection.ltr;
466+
if (widget.reverse) {
467+
return ltr ? AxisDirection.left : AxisDirection.right;
468+
}
469+
return ltr ? AxisDirection.right : AxisDirection.left;
470+
}
471+
472+
EdgeInsets _stripAxisTrailing(EdgeInsets base) {
473+
switch (_axisDirection) {
474+
case AxisDirection.up:
475+
return base.copyWith(top: 0);
476+
case AxisDirection.down:
477+
return base.copyWith(bottom: 0);
478+
case AxisDirection.left:
479+
return base.copyWith(left: 0);
480+
case AxisDirection.right:
481+
return base.copyWith(right: 0);
482+
}
483+
}
484+
485+
EdgeInsets _stripAxisLeading(EdgeInsets base) {
486+
switch (_axisDirection) {
487+
case AxisDirection.up:
488+
return base.copyWith(bottom: 0);
489+
case AxisDirection.down:
490+
return base.copyWith(top: 0);
491+
case AxisDirection.left:
492+
return base.copyWith(right: 0);
493+
case AxisDirection.right:
494+
return base.copyWith(left: 0);
495+
}
496+
}
497+
498+
EdgeInsets get _leadingSliverPadding => _stripAxisTrailing(_resolvedPadding);
499+
500+
EdgeInsets get _trailingSliverPadding => _stripAxisLeading(_resolvedPadding);
501+
502+
// Center keeps cross-axis padding always; carries the leading/trailing
503+
// edge padding only when it owns the first/last item.
504+
EdgeInsets get _centerSliverPadding {
505+
final resolved = _resolvedPadding;
506+
// Empty list: center is the only sliver in the tree. Drop neither
507+
// edge or the gap would be asymmetric.
508+
if (widget.itemCount == 0) return resolved;
509+
final isFirst = widget.positionedIndex == 0;
510+
final isLast = widget.positionedIndex == widget.itemCount - 1;
511+
switch (_axisDirection) {
512+
case AxisDirection.up:
513+
return resolved.copyWith(
514+
top: isLast ? resolved.top : 0,
515+
bottom: isFirst ? resolved.bottom : 0,
516+
);
517+
case AxisDirection.down:
518+
return resolved.copyWith(
519+
top: isFirst ? resolved.top : 0,
520+
bottom: isLast ? resolved.bottom : 0,
521+
);
522+
case AxisDirection.left:
523+
return resolved.copyWith(
524+
left: isLast ? resolved.left : 0,
525+
right: isFirst ? resolved.right : 0,
526+
);
527+
case AxisDirection.right:
528+
return resolved.copyWith(
529+
left: isFirst ? resolved.left : 0,
530+
right: isLast ? resolved.right : 0,
531+
);
532+
}
533+
}
506534

507535
void _schedulePositionNotificationUpdate() {
508536
if (!updateScheduled) {
@@ -551,32 +579,36 @@ class _PositionedListState extends State<PositionedList> {
551579
final itemOffset = reveal -
552580
viewport.offset.pixels +
553581
anchor * viewport.size.height;
554-
positions.add(ItemPosition(
555-
index: key.index,
556-
itemLeadingEdge: itemOffset.round() /
557-
scrollController.position.viewportDimension,
558-
itemTrailingEdge: (itemOffset + box.size.height).round() /
559-
scrollController.position.viewportDimension,
560-
));
582+
positions.add(
583+
ItemPosition(
584+
index: key.index,
585+
itemLeadingEdge: itemOffset.round() /
586+
scrollController.position.viewportDimension,
587+
itemTrailingEdge: (itemOffset + box.size.height).round() /
588+
scrollController.position.viewportDimension,
589+
),
590+
);
561591
} else {
562592
final itemOffset =
563593
box.localToGlobal(Offset.zero, ancestor: viewport).dx;
564594
if (!itemOffset.isFinite) continue;
565-
positions.add(ItemPosition(
566-
index: key.index,
567-
itemLeadingEdge: (widget.reverse
568-
? scrollController.position.viewportDimension -
569-
(itemOffset + box.size.width)
570-
: itemOffset)
571-
.round() /
572-
scrollController.position.viewportDimension,
573-
itemTrailingEdge: (widget.reverse
574-
? scrollController.position.viewportDimension -
575-
itemOffset
576-
: (itemOffset + box.size.width))
577-
.round() /
578-
scrollController.position.viewportDimension,
579-
));
595+
final viewportDimension =
596+
scrollController.position.viewportDimension;
597+
// Branch on the resolved axis direction so RTL is handled.
598+
final isRight = _axisDirection == AxisDirection.right;
599+
final leadingPx = isRight
600+
? itemOffset
601+
: viewportDimension - (itemOffset + box.size.width);
602+
final trailingPx = isRight
603+
? itemOffset + box.size.width
604+
: viewportDimension - itemOffset;
605+
positions.add(
606+
ItemPosition(
607+
index: key.index,
608+
itemLeadingEdge: leadingPx.round() / viewportDimension,
609+
itemTrailingEdge: trailingPx.round() / viewportDimension,
610+
),
611+
);
580612
}
581613
} on TypeError catch (_) {
582614
// Specifically the null-check failure inside

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

Lines changed: 71 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ class ScrollablePositionedList extends StatefulWidget {
143143
final int? semanticChildCount;
144144

145145
/// The amount of space by which to inset the children.
146-
final EdgeInsets? padding;
146+
final EdgeInsetsGeometry? padding;
147147

148148
/// Whether to wrap each child in an [IndexedSemantics].
149149
///
@@ -526,13 +526,10 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
526526
/// reanchor would silently revert the user's in-progress scroll
527527
/// and the list would feel locked in place.
528528
///
529-
/// 2. The scroll offset is brought to zero via
530-
/// [ScrollPosition.correctBy], not [ScrollController.jumpTo].
531-
/// `correctBy` mutates `pixels` directly without firing `goIdle()`
532-
/// or dispatching scroll notifications, so an in-flight
533-
/// `DragScrollActivity` or `BallisticScrollActivity` keeps
534-
/// integrating its delta against the new baseline instead of
535-
/// being cancelled.
529+
/// 2. The new anchor folds the current pixel offset in so we can
530+
/// leave [primary.scrollController.position.pixels] untouched.
531+
/// Resetting pixels could exhaust [UnboundedRenderViewport]'s
532+
/// layout-cycle budget on deep mid-list anchors.
536533
void _updateFirstVisibleItemIfNeeded() {
537534
final keyBuilder = widget.itemKeyBuilder;
538535
if (keyBuilder == null || _lastKnownFirstItemKey == null) return;
@@ -547,23 +544,17 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
547544
final currentAlignment = viewport > 0
548545
? _firstVisibleItemAlignment - pixelDelta / viewport
549546
: _firstVisibleItemAlignment;
547+
// `newAnchor * viewport - pixels == currentAlignment * viewport`
548+
// so the anchored item lands at the same visual position without
549+
// touching pixels.
550+
final newAnchor =
551+
viewport > 0 ? currentAlignment + pixels / viewport : currentAlignment;
550552

551-
if (newIndex == primary.target &&
552-
primary.alignment == currentAlignment &&
553-
pixels == 0) {
554-
return;
555-
}
553+
if (newIndex == primary.target && primary.alignment == newAnchor) return;
556554
primary
557555
..target = newIndex
558-
..alignment = currentAlignment;
559-
if (hasClients && pixels != 0) {
560-
position!.correctBy(-pixels);
561-
}
562-
// The reanchor itself moves the pixel baseline to 0; record that
563-
// alongside the (now-applied) anchor so subsequent reanchors that
564-
// fire before the next layout compute deltas against the right
565-
// starting point.
566-
_firstVisibleItemAlignmentAtPixels = 0;
556+
..alignment = newAnchor;
557+
_firstVisibleItemAlignmentAtPixels = pixels;
567558
_lastKnownFirstItemIndex = newIndex;
568559
_firstVisibleItemAlignment = currentAlignment;
569560
}
@@ -656,6 +647,35 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
656647
widget.minCacheExtent ?? 0,
657648
);
658649

650+
/// Pixels of [widget.padding] on the leading side of the scroll
651+
/// axis. Direction-aware: resolves [EdgeInsetsDirectional] against
652+
/// [Directionality] for horizontal axes.
653+
double _resolveLeadingPadding() {
654+
final geometry = widget.padding;
655+
if (geometry == null) return 0;
656+
final textDirection = Directionality.maybeOf(context) ?? TextDirection.ltr;
657+
final resolved = geometry.resolve(textDirection);
658+
if (widget.scrollDirection == Axis.vertical) {
659+
return widget.reverse ? resolved.bottom : resolved.top;
660+
}
661+
final axisDirectionIsRight =
662+
(textDirection == TextDirection.ltr) ^ widget.reverse;
663+
return axisDirectionIsRight ? resolved.left : resolved.right;
664+
}
665+
666+
/// Adjustment for `_startScroll`'s target so the leading-end item
667+
/// lands at the content-area edge. Only applies when the center
668+
/// sliver carries the leading padding — for other indices SPL's
669+
/// pre-existing math is preserved.
670+
double _resolveLeadingEndPaddingAdjust(
671+
{required int index, required double alignment}) {
672+
if (alignment != 0) return 0;
673+
// Center carries the leading-edge padding only when
674+
// `positionedIndex == 0`, in both axis directions.
675+
if (index != 0) return 0;
676+
return _resolveLeadingPadding();
677+
}
678+
659679
void _jumpTo({required int index, required double alignment}) {
660680
_stopScroll(canceled: true);
661681
if (index > widget.itemCount - 1) {
@@ -737,12 +757,18 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
737757
);
738758
if (itemPosition != null) {
739759
// Scroll directly.
740-
final localScrollAmount = itemPosition.itemLeadingEdge *
741-
primary.scrollController.position.viewportDimension;
760+
final viewport = primary.scrollController.position.viewportDimension;
761+
final localScrollAmount = itemPosition.itemLeadingEdge * viewport;
762+
// `itemLeadingEdge` comes from `getOffsetToReveal`, which is
763+
// geometric and padding-blind. Subtract `leadingPadding` so the
764+
// target lands at the content-area edge.
765+
final paddingAdjust =
766+
_resolveLeadingEndPaddingAdjust(index: index, alignment: alignment);
742767
await primary.scrollController.animateTo(
743768
primary.scrollController.offset +
744769
localScrollAmount -
745-
alignment * primary.scrollController.position.viewportDimension,
770+
alignment * viewport -
771+
paddingAdjust,
746772
duration: duration,
747773
curve: curve,
748774
);
@@ -759,17 +785,21 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
759785
AnimationController(vsync: this, duration: duration)..forward();
760786
opacity.parent = _opacityAnimation(opacityAnimationWeights)
761787
.animate(_animationController!);
762-
secondary.scrollController.jumpTo(-direction *
763-
(_screenScrollCount *
764-
primary.scrollController.position.viewportDimension -
765-
alignment *
766-
secondary.scrollController.position.viewportDimension));
767-
768-
startCompleter.complete(primary.scrollController.animateTo(
769-
primary.scrollController.offset + direction * scrollAmount,
770-
duration: duration,
771-
curve: curve,
772-
));
788+
secondary.scrollController.jumpTo(
789+
-direction *
790+
(_screenScrollCount *
791+
primary.scrollController.position.viewportDimension -
792+
alignment *
793+
secondary.scrollController.position.viewportDimension),
794+
);
795+
796+
startCompleter.complete(
797+
primary.scrollController.animateTo(
798+
primary.scrollController.offset + direction * scrollAmount,
799+
duration: duration,
800+
curve: curve,
801+
),
802+
);
773803
endCompleter.complete(secondary.scrollController
774804
.animateTo(0, duration: duration, curve: curve));
775805
});
@@ -841,9 +871,11 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
841871
}
842872

843873
void _updatePositions() {
844-
final itemPositions = primary.itemPositionsNotifier.itemPositions.value
845-
.where((ItemPosition position) =>
846-
position.itemLeadingEdge < 1 && position.itemTrailingEdge > 0);
874+
final itemPositions =
875+
primary.itemPositionsNotifier.itemPositions.value.where(
876+
(ItemPosition position) =>
877+
position.itemLeadingEdge < 1 && position.itemTrailingEdge > 0,
878+
);
847879
if (itemPositions.isNotEmpty) {
848880
// The visually topmost item — smallest `itemLeadingEdge` along the
849881
// scroll axis.

0 commit comments

Comments
 (0)