Skip to content

anchors 1/n: Handle GrowthDirection.reverse in sticky_header #496

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jan 30, 2024
151 changes: 81 additions & 70 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,13 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
}

void _adjustButtonVisibility(ScrollMetrics scrollMetrics) {
if (scrollMetrics.extentBefore == 0) {
if (scrollMetrics.extentAfter == 0) {
_scrollToBottomVisibleValue.value = false;
} else {
_scrollToBottomVisibleValue.value = true;
}

final extentRemainingAboveViewport = scrollMetrics.maxScrollExtent - scrollMetrics.pixels;
if (extentRemainingAboveViewport < kFetchMessagesBufferPixels) {
if (scrollMetrics.extentBefore < kFetchMessagesBufferPixels) {
// TODO: This ends up firing a second time shortly after we fetch a batch.
// The result is that each time we decide to fetch a batch, we end up
// fetching two batches in quick succession. This is basically harmless
Expand Down Expand Up @@ -279,7 +278,8 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat

Widget _buildListView(context) {
final length = model!.items.length;
return StickyHeaderListView.builder(
const centerSliverKey = ValueKey('center sliver');
return CustomScrollView(
// TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or
// similar) if that is ever offered:
// https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849
Expand All @@ -291,73 +291,84 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
_ => ScrollViewKeyboardDismissBehavior.manual,
},

// To preserve state across rebuilds for individual [MessageItem]
// widgets as the size of [MessageListView.items] changes we need
// to match old widgets by their key to their new position in
// the list.
//
// The keys are of type [ValueKey] with a value of [Message.id]
// and here we use a O(log n) binary search method. This could
// be improved but for now it only triggers for materialized
// widgets. As a simple test, flinging through All Messages in
// CZO on a Pixel 5, this only runs about 10 times per rebuild
// and the timing for each call is <100 microseconds.
//
// Non-message items (e.g., start and end markers) that do not
// have state that needs to be preserved have not been given keys
// and will not trigger this callback.
findChildIndexCallback: (Key key) {
final valueKey = key as ValueKey;
final index = model!.findItemWithMessageId(valueKey.value);
if (index == -1) return null;
return length - 1 - (index - 2);
},
controller: scrollController,
itemCount: length + 2,
// Setting reverse: true means the scroll starts at the bottom.
// Flipping the indexes (in itemBuilder) means the start/bottom
// has the latest messages.
// This works great when we want to start from the latest.
// TODO handle scroll starting at first unread, or link anchor
// TODO on new message when scrolled up, anchor scroll to what's in view
reverse: true,
itemBuilder: (context, i) {
// To reinforce that the end of the feed has been reached:
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
if (i == 0) return const SizedBox(height: 36);

if (i == 1) return MarkAsReadWidget(narrow: widget.narrow);

final data = model!.items[length - 1 - (i - 2)];
switch (data) {
case MessageListHistoryStartItem():
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 16.0),
child: Text("No earlier messages."))); // TODO use an icon
case MessageListLoadingItem():
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 16.0),
child: CircularProgressIndicator())); // TODO perhaps a different indicator
case MessageListRecipientHeaderItem():
final header = RecipientHeader(message: data.message, narrow: widget.narrow);
return StickyHeaderItem(allowOverflow: true,
header: header, child: header);
case MessageListDateSeparatorItem():
final header = RecipientHeader(message: data.message, narrow: widget.narrow);
return StickyHeaderItem(allowOverflow: true,
header: header,
child: DateSeparator(message: data.message));
case MessageListMessageItem():
final header = RecipientHeader(message: data.message, narrow: widget.narrow);
return MessageItem(
key: ValueKey(data.message.id),
header: header,
trailingWhitespace: i == 1 ? 8 : 11,
item: data);
}
});
semanticChildCount: length + 2,
anchor: 1.0,
center: centerSliverKey,

slivers: [
SliverStickyHeaderList(
headerPlacement: HeaderPlacement.scrollingStart,
delegate: SliverChildBuilderDelegate(
// To preserve state across rebuilds for individual [MessageItem]
// widgets as the size of [MessageListView.items] changes we need
// to match old widgets by their key to their new position in
// the list.
//
// The keys are of type [ValueKey] with a value of [Message.id]
// and here we use a O(log n) binary search method. This could
// be improved but for now it only triggers for materialized
// widgets. As a simple test, flinging through All Messages in
// CZO on a Pixel 5, this only runs about 10 times per rebuild
// and the timing for each call is <100 microseconds.
//
// Non-message items (e.g., start and end markers) that do not
// have state that needs to be preserved have not been given keys
// and will not trigger this callback.
findChildIndexCallback: (Key key) {
final valueKey = key as ValueKey;
final index = model!.findItemWithMessageId(valueKey.value);
if (index == -1) return null;
return length - 1 - (index - 2);
},
childCount: length + 2,
(context, i) {
// To reinforce that the end of the feed has been reached:
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
if (i == 0) return const SizedBox(height: 36);

if (i == 1) return MarkAsReadWidget(narrow: widget.narrow);

final data = model!.items[length - 1 - (i - 2)];
return _buildItem(data, i);
})),

// This is a trivial placeholder that occupies no space. Its purpose is
// to have the key that's passed to [ScrollView.center], and so to cause
// the above [SliverStickyHeaderList] to run from bottom to top.
const SliverToBoxAdapter(key: centerSliverKey),
]);
}

Widget _buildItem(MessageListItem data, int i) {
switch (data) {
case MessageListHistoryStartItem():
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 16.0),
child: Text("No earlier messages."))); // TODO use an icon
case MessageListLoadingItem():
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 16.0),
child: CircularProgressIndicator())); // TODO perhaps a different indicator
case MessageListRecipientHeaderItem():
final header = RecipientHeader(message: data.message, narrow: widget.narrow);
return StickyHeaderItem(allowOverflow: true,
header: header, child: header);
case MessageListDateSeparatorItem():
final header = RecipientHeader(message: data.message, narrow: widget.narrow);
return StickyHeaderItem(allowOverflow: true,
header: header,
child: DateSeparator(message: data.message));
case MessageListMessageItem():
final header = RecipientHeader(message: data.message, narrow: widget.narrow);
return MessageItem(
key: ValueKey(data.message.id),
header: header,
trailingWhitespace: i == 1 ? 8 : 11,
item: data);
}
}
}

Expand Down
93 changes: 68 additions & 25 deletions lib/widgets/sticky_header.dart
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ class StickyHeaderListView extends BoxScrollView {

@override
Widget buildChildLayout(BuildContext context) {
return _SliverStickyHeaderList(
return SliverStickyHeaderList(
headerPlacement: (reverseHeader ^ reverse)
? HeaderPlacement.scrollingEnd : HeaderPlacement.scrollingStart,
delegate: childrenDelegate);
Expand All @@ -272,25 +272,48 @@ class StickyHeaderListView extends BoxScrollView {
/// For example if the list scrolls to the left, then
/// [scrollingStart] means the right edge of the list, regardless of whether
/// the ambient [Directionality] is RTL or LTR.
enum HeaderPlacement { scrollingStart, scrollingEnd }
enum HeaderPlacement {
scrollingStart,
scrollingEnd;

_HeaderGrowthPlacement _byGrowth(GrowthDirection growthDirection) {
return switch ((growthDirection, this)) {
(GrowthDirection.forward, scrollingStart) => _HeaderGrowthPlacement.growthStart,
(GrowthDirection.forward, scrollingEnd) => _HeaderGrowthPlacement.growthEnd,
(GrowthDirection.reverse, scrollingStart) => _HeaderGrowthPlacement.growthEnd,
(GrowthDirection.reverse, scrollingEnd) => _HeaderGrowthPlacement.growthStart,
};
}
}

class _SliverStickyHeaderList extends RenderObjectWidget {
_SliverStickyHeaderList({
/// Where a header goes, in terms of the list sliver's growth direction.
///
/// This will agree with the [HeaderPlacement] value if the growth direction
/// is [GrowthDirection.forward], but contrast with it if the growth direction
/// is [GrowthDirection.reverse]. See [HeaderPlacement._byGrowth].
enum _HeaderGrowthPlacement {
growthStart,
growthEnd
}

class SliverStickyHeaderList extends RenderObjectWidget {
SliverStickyHeaderList({
super.key,
required this.headerPlacement,
required SliverChildDelegate delegate,
}) : child = _SliverStickyHeaderListInner(
}) : _child = _SliverStickyHeaderListInner(
headerPlacement: headerPlacement,
delegate: delegate,
);

final HeaderPlacement headerPlacement;
final _SliverStickyHeaderListInner child;
final _SliverStickyHeaderListInner _child;

@override
_SliverStickyHeaderListElement createElement() => _SliverStickyHeaderListElement(this);
RenderObjectElement createElement() => _SliverStickyHeaderListElement(this);

@override
_RenderSliverStickyHeaderList createRenderObject(BuildContext context) {
RenderSliver createRenderObject(BuildContext context) {
final element = context as _SliverStickyHeaderListElement;
return _RenderSliverStickyHeaderList(element: element);
}
Expand All @@ -299,10 +322,10 @@ class _SliverStickyHeaderList extends RenderObjectWidget {
enum _SliverStickyHeaderListSlot { header, list }

class _SliverStickyHeaderListElement extends RenderObjectElement {
_SliverStickyHeaderListElement(_SliverStickyHeaderList super.widget);
_SliverStickyHeaderListElement(SliverStickyHeaderList super.widget);

@override
_SliverStickyHeaderList get widget => super.widget as _SliverStickyHeaderList;
SliverStickyHeaderList get widget => super.widget as SliverStickyHeaderList;

@override
_RenderSliverStickyHeaderList get renderObject => super.renderObject as _RenderSliverStickyHeaderList;
Expand Down Expand Up @@ -334,14 +357,14 @@ class _SliverStickyHeaderListElement extends RenderObjectElement {
@override
void mount(Element? parent, Object? newSlot) {
super.mount(parent, newSlot);
_child = updateChild(_child, widget.child, _SliverStickyHeaderListSlot.list);
_child = updateChild(_child, widget._child, _SliverStickyHeaderListSlot.list);
}

@override
void update(_SliverStickyHeaderList newWidget) {
void update(SliverStickyHeaderList newWidget) {
super.update(newWidget);
assert(widget == newWidget);
_child = updateChild(_child, widget.child, _SliverStickyHeaderListSlot.list);
_child = updateChild(_child, widget._child, _SliverStickyHeaderListSlot.list);
renderObject.child!.markHeaderNeedsRebuild();
}

Expand Down Expand Up @@ -398,6 +421,8 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper

final _SliverStickyHeaderListElement _element;

SliverStickyHeaderList get _widget => _element.widget;

Widget? _headerWidget;

/// The limiting edge (if any) of the header's item,
Expand All @@ -424,10 +449,10 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper
double? endBound;
if (item != null && !item.allowOverflow) {
final childParentData = listChild!.parentData! as SliverMultiBoxAdaptorParentData;
endBound = switch (_element.widget.headerPlacement) {
HeaderPlacement.scrollingStart =>
endBound = switch (_widget.headerPlacement._byGrowth(constraints.growthDirection)) {
_HeaderGrowthPlacement.growthStart =>
childParentData.layoutOffset! + listChild.size.onAxis(constraints.axis),
HeaderPlacement.scrollingEnd =>
_HeaderGrowthPlacement.growthEnd =>
childParentData.layoutOffset!,
};
}
Expand Down Expand Up @@ -522,7 +547,7 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper

bool _headerAtCoordinateEnd() {
return axisDirectionIsReversed(constraints.axisDirection)
^ (_element.widget.headerPlacement == HeaderPlacement.scrollingEnd);
^ (_widget.headerPlacement == HeaderPlacement.scrollingEnd);
}

@override
Expand Down Expand Up @@ -559,7 +584,7 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper
} else {
// The limiting edge of the header's item,
// in the outer, non-scrolling coordinates.
final endBoundAbsolute = axisDirectionIsReversed(constraints.axisDirection)
final endBoundAbsolute = axisDirectionIsReversed(constraints.growthAxisDirection)
? geometry.layoutExtent - (_headerEndBound! - constraints.scrollOffset)
: _headerEndBound! - constraints.scrollOffset;

Expand Down Expand Up @@ -611,7 +636,7 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper
// need to convert to the sliver's coordinate system.
final headerParentData = (header!.parentData as SliverPhysicalParentData);
final paintOffset = headerParentData.paintOffset;
return switch (constraints.axisDirection) {
return switch (constraints.growthAxisDirection) {
AxisDirection.right => paintOffset.dx,
AxisDirection.left => geometry!.layoutExtent - header!.size.width - paintOffset.dx,
AxisDirection.down => paintOffset.dy,
Expand Down Expand Up @@ -707,18 +732,36 @@ class _RenderSliverStickyHeaderListInner extends RenderSliverList {

@override
void performLayout() {
assert(constraints.growthDirection == GrowthDirection.forward); // TODO dir

super.performLayout();

final child = switch (widget.headerPlacement) {
HeaderPlacement.scrollingStart => _findChildAtStart(),
HeaderPlacement.scrollingEnd => _findChildAtEnd(),
};
final RenderBox? child;
switch (widget.headerPlacement._byGrowth(constraints.growthDirection)) {
case _HeaderGrowthPlacement.growthEnd:
child = _findChildAtEnd();
case _HeaderGrowthPlacement.growthStart:
child = _findChildAtStart();
}

(parent! as _RenderSliverStickyHeaderList)._rebuildHeader(child);
}
}

extension SliverConstraintsGrowthAxisDirection on SliverConstraints {
AxisDirection get growthAxisDirection => switch (growthDirection) {
GrowthDirection.forward => axisDirection,
GrowthDirection.reverse => axisDirection.reversed,
};
}

extension AxisDirectionReversed on AxisDirection {
AxisDirection get reversed => switch (this) {
AxisDirection.down => AxisDirection.up,
AxisDirection.up => AxisDirection.down,
AxisDirection.right => AxisDirection.left,
AxisDirection.left => AxisDirection.right,
};
}

extension AxisCoordinateDirection on Axis {
AxisDirection get coordinateDirection => switch (this) {
Axis.horizontal => AxisDirection.right,
Expand Down
Loading