Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .fvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"flutter": "3.27.4"
}
11 changes: 9 additions & 2 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@

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

🚀 Improved

- `ScrollablePositionedList.padding` now accepts `EdgeInsetsGeometry` (resolved against
`Directionality`), and `scrollTo` lands the target at the content-area edge by adjusting for
leading padding.

## 9.24.0

✅ Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ class PositionedList extends StatefulWidget {
final bool addSemanticIndexes;

/// The amount of space by which to inset the children.
final EdgeInsets? padding;
final EdgeInsetsGeometry? padding;

/// Whether to wrap each child in a [RepaintBoundary].
///
Expand Down Expand Up @@ -452,57 +452,85 @@ class _PositionedListState extends State<PositionedList> {
);
}

EdgeInsets get _leadingSliverPadding =>
(widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(top: 0)
: widget.padding?.copyWith(bottom: 0)
: widget.reverse
? widget.padding?.copyWith(left: 0)
: widget.padding?.copyWith(right: 0)) ??
EdgeInsets get _resolvedPadding =>
widget.padding
?.resolve(Directionality.maybeOf(context) ?? TextDirection.ltr) ??
EdgeInsets.zero;

EdgeInsets get _centerSliverPadding => widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(
top: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.top
: 0,
bottom:
widget.positionedIndex == 0 ? widget.padding!.bottom : 0,
) ??
EdgeInsets.zero
: widget.padding?.copyWith(
top: widget.positionedIndex == 0 ? widget.padding!.top : 0,
bottom: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.bottom
: 0,
) ??
EdgeInsets.zero
: widget.reverse
? widget.padding?.copyWith(
left: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.left
: 0,
right: widget.positionedIndex == 0 ? widget.padding!.right : 0,
) ??
EdgeInsets.zero
: widget.padding?.copyWith(
left: widget.positionedIndex == 0 ? widget.padding!.left : 0,
right: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.right
: 0,
) ??
EdgeInsets.zero;

EdgeInsets get _trailingSliverPadding =>
widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(bottom: 0) ?? EdgeInsets.zero
: widget.padding?.copyWith(top: 0) ?? EdgeInsets.zero
: widget.reverse
? widget.padding?.copyWith(right: 0) ?? EdgeInsets.zero
: widget.padding?.copyWith(left: 0) ?? EdgeInsets.zero;
AxisDirection get _axisDirection {
if (widget.scrollDirection == Axis.vertical) {
return widget.reverse ? AxisDirection.up : AxisDirection.down;
}
final ltr = (Directionality.maybeOf(context) ?? TextDirection.ltr) ==
TextDirection.ltr;
if (widget.reverse) {
return ltr ? AxisDirection.left : AxisDirection.right;
}
return ltr ? AxisDirection.right : AxisDirection.left;
}

EdgeInsets _stripAxisTrailing(EdgeInsets base) {
switch (_axisDirection) {
case AxisDirection.up:
return base.copyWith(top: 0);
case AxisDirection.down:
return base.copyWith(bottom: 0);
case AxisDirection.left:
return base.copyWith(left: 0);
case AxisDirection.right:
return base.copyWith(right: 0);
}
}

EdgeInsets _stripAxisLeading(EdgeInsets base) {
switch (_axisDirection) {
case AxisDirection.up:
return base.copyWith(bottom: 0);
case AxisDirection.down:
return base.copyWith(top: 0);
case AxisDirection.left:
return base.copyWith(right: 0);
case AxisDirection.right:
return base.copyWith(left: 0);
}
}

EdgeInsets get _leadingSliverPadding => _stripAxisTrailing(_resolvedPadding);

EdgeInsets get _trailingSliverPadding => _stripAxisLeading(_resolvedPadding);

// Center keeps cross-axis padding always; carries the leading/trailing
// edge padding only when it owns the first/last item.
EdgeInsets get _centerSliverPadding {
final resolved = _resolvedPadding;
// Empty list: center is the only sliver in the tree. Drop neither
// edge or the gap would be asymmetric.
if (widget.itemCount == 0) return resolved;
final isFirst = widget.positionedIndex == 0;
final isLast = widget.positionedIndex == widget.itemCount - 1;
switch (_axisDirection) {
case AxisDirection.up:
return resolved.copyWith(
top: isLast ? resolved.top : 0,
bottom: isFirst ? resolved.bottom : 0,
);
case AxisDirection.down:
return resolved.copyWith(
top: isFirst ? resolved.top : 0,
bottom: isLast ? resolved.bottom : 0,
);
case AxisDirection.left:
return resolved.copyWith(
left: isLast ? resolved.left : 0,
right: isFirst ? resolved.right : 0,
);
case AxisDirection.right:
return resolved.copyWith(
left: isFirst ? resolved.left : 0,
right: isLast ? resolved.right : 0,
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

void _schedulePositionNotificationUpdate() {
if (!updateScheduled) {
Expand Down Expand Up @@ -551,32 +579,36 @@ class _PositionedListState extends State<PositionedList> {
final itemOffset = reveal -
viewport.offset.pixels +
anchor * viewport.size.height;
positions.add(ItemPosition(
index: key.index,
itemLeadingEdge: itemOffset.round() /
scrollController.position.viewportDimension,
itemTrailingEdge: (itemOffset + box.size.height).round() /
scrollController.position.viewportDimension,
));
positions.add(
ItemPosition(
index: key.index,
itemLeadingEdge: itemOffset.round() /
scrollController.position.viewportDimension,
itemTrailingEdge: (itemOffset + box.size.height).round() /
scrollController.position.viewportDimension,
),
);
} else {
final itemOffset =
box.localToGlobal(Offset.zero, ancestor: viewport).dx;
if (!itemOffset.isFinite) continue;
positions.add(ItemPosition(
index: key.index,
itemLeadingEdge: (widget.reverse
? scrollController.position.viewportDimension -
(itemOffset + box.size.width)
: itemOffset)
.round() /
scrollController.position.viewportDimension,
itemTrailingEdge: (widget.reverse
? scrollController.position.viewportDimension -
itemOffset
: (itemOffset + box.size.width))
.round() /
scrollController.position.viewportDimension,
));
final viewportDimension =
scrollController.position.viewportDimension;
// Branch on the resolved axis direction so RTL is handled.
final isRight = _axisDirection == AxisDirection.right;
final leadingPx = isRight
? itemOffset
: viewportDimension - (itemOffset + box.size.width);
final trailingPx = isRight
? itemOffset + box.size.width
: viewportDimension - itemOffset;
positions.add(
ItemPosition(
index: key.index,
itemLeadingEdge: leadingPx.round() / viewportDimension,
itemTrailingEdge: trailingPx.round() / viewportDimension,
),
);
}
} on TypeError catch (_) {
// Specifically the null-check failure inside
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ class ScrollablePositionedList extends StatefulWidget {
final int? semanticChildCount;

/// The amount of space by which to inset the children.
final EdgeInsets? padding;
final EdgeInsetsGeometry? padding;

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

if (newIndex == primary.target &&
primary.alignment == currentAlignment &&
pixels == 0) {
return;
}
if (newIndex == primary.target && primary.alignment == newAnchor) return;
primary
..target = newIndex
..alignment = currentAlignment;
if (hasClients && pixels != 0) {
position!.correctBy(-pixels);
}
// The reanchor itself moves the pixel baseline to 0; record that
// alongside the (now-applied) anchor so subsequent reanchors that
// fire before the next layout compute deltas against the right
// starting point.
_firstVisibleItemAlignmentAtPixels = 0;
..alignment = newAnchor;
_firstVisibleItemAlignmentAtPixels = pixels;
_lastKnownFirstItemIndex = newIndex;
_firstVisibleItemAlignment = currentAlignment;
}
Expand Down Expand Up @@ -656,6 +647,35 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
widget.minCacheExtent ?? 0,
);

/// Pixels of [widget.padding] on the leading side of the scroll
/// axis. Direction-aware: resolves [EdgeInsetsDirectional] against
/// [Directionality] for horizontal axes.
double _resolveLeadingPadding() {
final geometry = widget.padding;
if (geometry == null) return 0;
final textDirection = Directionality.maybeOf(context) ?? TextDirection.ltr;
final resolved = geometry.resolve(textDirection);
if (widget.scrollDirection == Axis.vertical) {
return widget.reverse ? resolved.bottom : resolved.top;
}
final axisDirectionIsRight =
(textDirection == TextDirection.ltr) ^ widget.reverse;
return axisDirectionIsRight ? resolved.left : resolved.right;
}

/// Adjustment for `_startScroll`'s target so the leading-end item
/// lands at the content-area edge. Only applies when the center
/// sliver carries the leading padding — for other indices SPL's
/// pre-existing math is preserved.
double _resolveLeadingEndPaddingAdjust(
{required int index, required double alignment}) {
if (alignment != 0) return 0;
// Center carries the leading-edge padding only when
// `positionedIndex == 0`, in both axis directions.
if (index != 0) return 0;
return _resolveLeadingPadding();
}

void _jumpTo({required int index, required double alignment}) {
_stopScroll(canceled: true);
if (index > widget.itemCount - 1) {
Expand Down Expand Up @@ -737,12 +757,18 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
);
if (itemPosition != null) {
// Scroll directly.
final localScrollAmount = itemPosition.itemLeadingEdge *
primary.scrollController.position.viewportDimension;
final viewport = primary.scrollController.position.viewportDimension;
final localScrollAmount = itemPosition.itemLeadingEdge * viewport;
// `itemLeadingEdge` comes from `getOffsetToReveal`, which is
// geometric and padding-blind. Subtract `leadingPadding` so the
// target lands at the content-area edge.
final paddingAdjust =
_resolveLeadingEndPaddingAdjust(index: index, alignment: alignment);
await primary.scrollController.animateTo(
primary.scrollController.offset +
localScrollAmount -
alignment * primary.scrollController.position.viewportDimension,
alignment * viewport -
paddingAdjust,
duration: duration,
curve: curve,
);
Expand All @@ -759,17 +785,21 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
AnimationController(vsync: this, duration: duration)..forward();
opacity.parent = _opacityAnimation(opacityAnimationWeights)
.animate(_animationController!);
secondary.scrollController.jumpTo(-direction *
(_screenScrollCount *
primary.scrollController.position.viewportDimension -
alignment *
secondary.scrollController.position.viewportDimension));

startCompleter.complete(primary.scrollController.animateTo(
primary.scrollController.offset + direction * scrollAmount,
duration: duration,
curve: curve,
));
secondary.scrollController.jumpTo(
-direction *
(_screenScrollCount *
primary.scrollController.position.viewportDimension -
alignment *
secondary.scrollController.position.viewportDimension),
);

startCompleter.complete(
primary.scrollController.animateTo(
primary.scrollController.offset + direction * scrollAmount,
duration: duration,
curve: curve,
),
);
endCompleter.complete(secondary.scrollController
.animateTo(0, duration: duration, curve: curve));
});
Expand Down Expand Up @@ -841,9 +871,11 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
}

void _updatePositions() {
final itemPositions = primary.itemPositionsNotifier.itemPositions.value
.where((ItemPosition position) =>
position.itemLeadingEdge < 1 && position.itemTrailingEdge > 0);
final itemPositions =
primary.itemPositionsNotifier.itemPositions.value.where(
(ItemPosition position) =>
position.itemLeadingEdge < 1 && position.itemTrailingEdge > 0,
);
if (itemPositions.isNotEmpty) {
// The visually topmost item — smallest `itemLeadingEdge` along the
// scroll axis.
Expand Down
Loading
Loading