Unverified Commit f2351f61 authored by Tae Hyung Kim's avatar Tae Hyung Kim Committed by GitHub

Sliver Main Axis Group (#126596)

This widget implements the ability to place slivers one after another in
a single ScrollView in a way that all child slivers are drawn within the
bounds of the group itself (i.e. SliverPersistentHeaders aren't drawn
outside of the scroll extent provided by all of the child slivers). The
design document for SliverMainAxisGroup can be found
[here](https://docs.google.com/document/d/1e2bdLSYV_Dq2h8aHpF8mda67aOmZocPiMyjCcTTZhTg/edit?resourcekey=0-Xj2X2XA3CAFae22Sv3hAiA).

Fixes https://github.com/flutter/flutter/issues/33137.

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/wiki/Tree-hygiene#overview
[Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene
[test-exempt]:
https://github.com/flutter/flutter/wiki/Tree-hygiene#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/wiki/Tree-hygiene#handling-breaking-changes
[Discord]: https://github.com/flutter/flutter/wiki/Chat

---------
Co-authored-by: 's avatarKate Lovett <katelovett@google.com>
parent d4733a3f
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
void main() => runApp(const SliverMainAxisGroupExampleApp());
class SliverMainAxisGroupExampleApp extends StatelessWidget {
const SliverMainAxisGroupExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('SliverMainAxisGroup Sample')),
body: const SliverMainAxisGroupExample(),
),
);
}
}
class SliverMainAxisGroupExample extends StatelessWidget {
const SliverMainAxisGroupExample({super.key});
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: <Widget>[
SliverMainAxisGroup(
slivers: <Widget>[
const SliverAppBar(
title: Text('Section Title'),
expandedHeight: 70.0,
pinned: true,
),
SliverList.builder(
itemBuilder: (BuildContext context, int index) {
return Container(
color: index.isEven ? Colors.amber[300] : Colors.blue[300],
height: 100.0,
child: Center(
child: Text(
'Item $index',
style: const TextStyle(fontSize: 24),
),
),
);
},
itemCount: 5,
),
SliverToBoxAdapter(
child: Container(
color: Colors.cyan,
height: 100,
child: const Center(
child: Text('Another sliver child', style: TextStyle(fontSize: 24)),
),
),
)
],
),
SliverToBoxAdapter(
child: Container(
height: 1000,
decoration: const BoxDecoration(color: Colors.greenAccent),
child: const Center(
child: Text('Hello World!', style: TextStyle(fontSize: 24)),
),
),
),
],
);
}
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_api_samples/widgets/sliver/sliver_main_axis_group.0.dart'
as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('SliverMainAxisGroup example', (WidgetTester tester) async {
await tester.pumpWidget(
const example.SliverMainAxisGroupExampleApp(),
);
final RenderSliverMainAxisGroup renderSliverGroup = tester.renderObject(find.byType(SliverMainAxisGroup));
expect(renderSliverGroup, isNotNull);
final RenderSliverPersistentHeader renderAppBar = tester.renderObject<RenderSliverPersistentHeader>(find.byType(SliverAppBar));
final RenderSliverList renderSliverList = tester.renderObject<RenderSliverList>(find.byType(SliverList));
final RenderSliverToBoxAdapter renderSliverAdapter = tester.renderObject<RenderSliverToBoxAdapter>(find.byType(SliverToBoxAdapter));
// renderAppBar, renderSliverList, and renderSliverAdapter1 are part of the same sliver group.
expect(renderAppBar.geometry!.scrollExtent, equals(70.0));
expect(renderSliverList.geometry!.scrollExtent, equals(100.0 * 5));
expect(renderSliverAdapter.geometry!.scrollExtent, equals(100.0));
expect(renderSliverGroup.geometry!.scrollExtent, equals(70.0 + 100.0 * 5 + 100.0));
});
}
...@@ -180,3 +180,160 @@ bool _assertOutOfExtent(double extent) { ...@@ -180,3 +180,160 @@ bool _assertOutOfExtent(double extent) {
} }
return true; return true;
} }
/// A sliver that places multiple sliver children in a linear array along the
/// main axis.
///
/// The layout algorithm lays out slivers one by one. If the sliver is at the top
/// of the viewport or above the top, then we pass in a nonzero [SliverConstraints.scrollOffset]
/// to inform the sliver at what point along the main axis we should start layout.
/// For the slivers that come after it, we compute the amount of space taken up so
/// far to be used as the [SliverPhysicalParentData.paintOffset] and the
/// [SliverConstraints.remainingPaintExtent] to be passed in as a constraint.
///
/// Finally, this sliver will also ensure that all child slivers are painted within
/// the total scroll extent of the group by adjusting the child's
/// [SliverPhysicalParentData.paintOffset] as necessary. This can happen for
/// slivers such as [SliverPersistentHeader] which, when pinned, positions itself
/// at the top of the [Viewport] regardless of the scroll offset.
class RenderSliverMainAxisGroup extends RenderSliver with ContainerRenderObjectMixin<RenderSliver, SliverPhysicalContainerParentData> {
@override
void setupParentData(RenderObject child) {
if (child.parentData is! SliverPhysicalContainerParentData) {
child.parentData = SliverPhysicalContainerParentData();
}
}
@override
double childMainAxisPosition(RenderSliver child) {
switch (constraints.axisDirection) {
case AxisDirection.up:
case AxisDirection.down:
return (child.parentData! as SliverPhysicalParentData).paintOffset.dy;
case AxisDirection.left:
case AxisDirection.right:
return (child.parentData! as SliverPhysicalParentData).paintOffset.dx;
}
}
@override
double childCrossAxisPosition(RenderSliver child) => 0.0;
@override
void performLayout() {
double offset = 0;
double maxPaintExtent = 0;
RenderSliver? child = firstChild;
while (child != null) {
final double beforeOffsetPaintExtent = calculatePaintOffset(
constraints,
from: 0.0,
to: offset,
);
child.layout(
constraints.copyWith(
scrollOffset: math.max(0.0, constraints.scrollOffset - offset),
cacheOrigin: math.min(0.0, constraints.cacheOrigin + offset),
overlap: math.max(0.0, constraints.overlap - beforeOffsetPaintExtent),
remainingPaintExtent: constraints.remainingPaintExtent - beforeOffsetPaintExtent,
remainingCacheExtent: constraints.remainingCacheExtent - calculateCacheOffset(constraints, from: 0.0, to: offset),
precedingScrollExtent: offset + constraints.precedingScrollExtent,
),
parentUsesSize: true,
);
final SliverGeometry childLayoutGeometry = child.geometry!;
final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData;
switch (constraints.axis) {
case Axis.vertical:
childParentData.paintOffset = Offset(0.0, beforeOffsetPaintExtent);
case Axis.horizontal:
childParentData.paintOffset = Offset(beforeOffsetPaintExtent, 0.0);
}
offset += childLayoutGeometry.scrollExtent;
maxPaintExtent += child.geometry!.maxPaintExtent;
child = childAfter(child);
}
final double totalScrollExtent = offset;
offset = 0.0;
child = firstChild;
// Second pass to correct out of bound paintOffsets.
while (child != null) {
final double beforeOffsetPaintExtent = calculatePaintOffset(
constraints,
from: 0.0,
to: offset,
);
final SliverGeometry childLayoutGeometry = child.geometry!;
final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData;
final double remainingExtent = totalScrollExtent - constraints.scrollOffset;
if (childLayoutGeometry.paintExtent > remainingExtent) {
final double paintCorrection = childLayoutGeometry.paintExtent - remainingExtent;
switch (constraints.axis) {
case Axis.vertical:
childParentData.paintOffset = Offset(0.0, beforeOffsetPaintExtent - paintCorrection);
case Axis.horizontal:
childParentData.paintOffset = Offset(beforeOffsetPaintExtent - paintCorrection, 0.0);
}
}
offset += child.geometry!.scrollExtent;
child = childAfter(child);
}
geometry = SliverGeometry(
scrollExtent: totalScrollExtent,
paintExtent: calculatePaintOffset(constraints, from: 0, to: totalScrollExtent),
maxPaintExtent: maxPaintExtent,
);
}
@override
void paint(PaintingContext context, Offset offset) {
RenderSliver? child = lastChild;
while (child != null) {
final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData;
context.paintChild(child, offset + childParentData.paintOffset);
child = childBefore(child);
}
}
@override
void applyPaintTransform(RenderSliver child, Matrix4 transform) {
final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData;
childParentData.applyPaintTransform(transform);
}
@override
bool hitTestChildren(SliverHitTestResult result, {required double mainAxisPosition, required double crossAxisPosition}) {
RenderSliver? child = firstChild;
while (child != null) {
final bool isHit = result.addWithAxisOffset(
mainAxisPosition: mainAxisPosition,
crossAxisPosition: crossAxisPosition,
paintOffset: null,
mainAxisOffset: childMainAxisPosition(child),
crossAxisOffset: childCrossAxisPosition(child),
hitTest: child.hitTest,
);
if (isHit) {
return true;
}
child = childAfter(child);
}
return false;
}
@override
void visitChildrenForSemantics(RenderObjectVisitor visitor) {
RenderSliver? child = firstChild;
while (child != null) {
if (child.geometry!.visible) {
visitor(child);
}
child = childAfter(child);
}
}
}
...@@ -1561,6 +1561,8 @@ class SliverCrossAxisExpanded extends ParentDataWidget<SliverPhysicalContainerPa ...@@ -1561,6 +1561,8 @@ class SliverCrossAxisExpanded extends ParentDataWidget<SliverPhysicalContainerPa
/// value to a widget. /// value to a widget.
/// * [SliverConstrainedCrossAxis], which is a [RenderObjectWidget] for setting /// * [SliverConstrainedCrossAxis], which is a [RenderObjectWidget] for setting
/// an extent to constrain the widget to. /// an extent to constrain the widget to.
/// * [SliverMainAxisGroup], which is the [RenderObjectWidget] for laying out
/// multiple slivers along the main axis.
class SliverCrossAxisGroup extends MultiChildRenderObjectWidget { class SliverCrossAxisGroup extends MultiChildRenderObjectWidget {
/// Creates a sliver that places sliver children in a linear array along /// Creates a sliver that places sliver children in a linear array along
/// the cross axis. /// the cross axis.
...@@ -1574,3 +1576,61 @@ class SliverCrossAxisGroup extends MultiChildRenderObjectWidget { ...@@ -1574,3 +1576,61 @@ class SliverCrossAxisGroup extends MultiChildRenderObjectWidget {
return RenderSliverCrossAxisGroup(); return RenderSliverCrossAxisGroup();
} }
} }
/// A sliver that places multiple sliver children in a linear array along
/// the main axis, one after another.
///
/// ## Layout algorithm
///
/// _This section describes how the framework causes [RenderSliverMainAxisGroup]
/// to position its children._
///
/// Layout for a [RenderSliverMainAxisGroup] has four steps:
///
/// 1. Keep track of an offset variable which is the total [SliverGeometry.scrollExtent]
/// of the slivers laid out so far.
/// 2. To determine the constraints for the next sliver child to layout, calculate the
/// amount of paint extent occupied from 0.0 to the offset variable and subtract this from
/// [SliverConstraints.remainingPaintExtent] minus to use as the child's
/// [SliverConstraints.remainingPaintExtent]. For the [SliverConstraints.scrollOffset],
/// take the provided constraint's value and subtract out the offset variable, using
/// 0.0 if negative.
/// 3. Once we finish laying out all the slivers, this offset variable represents
/// the total [SliverGeometry.scrollExtent] of the sliver group. Since it is possible
/// for specialized slivers to try to paint itself outside of the bounds of the
/// sliver group's scroll extent (see [SliverPersistentHeader]), we must do a
/// second pass to set a [SliverPhysicalParentData.paintOffset] to make sure it
/// is within the bounds of the sliver group.
/// 4. Finally, set the [RenderSliverMainAxisGroup.geometry] with the total
/// [SliverGeometry.scrollExtent], [SliverGeometry.paintExtent] calculated from
/// the constraints and [SliverGeometry.scrollExtent], and [SliverGeometry.maxPaintExtent].
///
/// {@tool dartpad}
/// In this sample the [CustomScrollView] renders a [SliverMainAxisGroup] and a
/// [SliverToBoxAdapter] with some content. The [SliverMainAxisGroup] renders a
/// [SliverAppBar], [SliverList], and [SliverToBoxAdapter]. Notice that when the
/// [SliverMainAxisGroup] goes out of view, so does the pinned [SliverAppBar].
///
/// ** See code in examples/api/lib/widgets/sliver/sliver_main_axis_group.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [SliverPersistentHeader], which is a [RenderObjectWidget] which may require
/// adjustment to its [SliverPhysicalParentData.paintOffset] to make it fit
/// within the computed [SliverGeometry.scrollExtent] of the [SliverMainAxisGroup].
/// * [SliverCrossAxisGroup], which is the [RenderObjectWidget] for laying out
/// multiple slivers along the cross axis.
class SliverMainAxisGroup extends MultiChildRenderObjectWidget {
/// Creates a sliver that places sliver children in a linear array along
/// the main axis.
const SliverMainAxisGroup({
super.key,
required List<Widget> slivers,
}) : super(children: slivers);
@override
RenderSliverMainAxisGroup createRenderObject(BuildContext context) {
return RenderSliverMainAxisGroup();
}
}
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment