mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Added SliverFloatingHeader.snapMode (#151289)
When a user scroll gesture ends, Material Design floating headers snap into place by animating as far as needed and overlaying the underlying scrollable content. For example Gmail's search header works this way. Other apps handle the snap animation by scrolling content out of the way. Instagram for example. Added `SliverFloatingHeader.snapMode`, whose value can be `FloatingHeaderSnapMode.overlay` (the default) or `FloatingHeaderSnapMode.scroll`, so that developers can choose the snap animation style they want. | FloatingHeaderSnapMode.overlay | FloatingHeaderSnapMode.scroll | | --- | --- | | <video src="https://github.com/flutter/flutter/assets/1377460/05c82ddf-05a6-4431-9b1e-88b901feea68" /> | <video src="https://github.com/flutter/flutter/assets/1377460/fedc34de-0b55-4f0d-976f-2df1965c90bc" /> |
This commit is contained in:
parent
b713445298
commit
f2be1260df
@ -13,6 +13,26 @@ import 'scroll_position.dart';
|
||||
import 'scrollable.dart';
|
||||
import 'ticker_provider.dart';
|
||||
|
||||
/// Specifies how a partially visible [SliverFloatingHeader] animates
|
||||
/// into a view when a user scroll gesture ends.
|
||||
///
|
||||
/// During a user scroll gesture the header and the rest of the scrollable
|
||||
/// content move in sync. If the header is partially visible when the
|
||||
/// scroll gesture ends, [SliverFloatingHeader.snapMode] specifies if
|
||||
/// the header should [FloatingHeaderSnapMode.overlay] the scrollable's
|
||||
/// content as it expands until it's completely visible, or if the
|
||||
/// content should scroll out of the way as the header expands.
|
||||
enum FloatingHeaderSnapMode {
|
||||
/// At the end of a user scroll gesture, the [SliverFloatingHeader] will
|
||||
/// expand over the scrollable's content.
|
||||
overlay,
|
||||
|
||||
/// At the end of a user scroll gesture, the [SliverFloatingHeader] will
|
||||
/// expand and the scrollable's content will continue to scroll out
|
||||
/// of the way.
|
||||
scroll,
|
||||
}
|
||||
|
||||
/// A sliver that shows its [child] when the user scrolls forward and hides it
|
||||
/// when the user scrolls backwards.
|
||||
///
|
||||
@ -42,6 +62,7 @@ class SliverFloatingHeader extends StatefulWidget {
|
||||
const SliverFloatingHeader({
|
||||
super.key,
|
||||
this.animationStyle,
|
||||
this.snapMode,
|
||||
required this.child
|
||||
});
|
||||
|
||||
@ -51,6 +72,13 @@ class SliverFloatingHeader extends StatefulWidget {
|
||||
/// The reverse duration and curve apply to the animation that hides the header.
|
||||
final AnimationStyle? animationStyle;
|
||||
|
||||
/// Specifies how a partially visible [SliverFloatingHeader] animates
|
||||
/// into a view when a user scroll gesture ends.
|
||||
///
|
||||
/// The default is [FloatingHeaderSnapMode.overlay]. This parameter doesn't
|
||||
/// modify an animation in progress, just subsequent animations.
|
||||
final FloatingHeaderSnapMode? snapMode;
|
||||
|
||||
/// The widget contained by this sliver.
|
||||
final Widget child;
|
||||
|
||||
@ -66,6 +94,7 @@ class _SliverFloatingHeaderState extends State<SliverFloatingHeader> with Single
|
||||
return _SliverFloatingHeader(
|
||||
vsync: this,
|
||||
animationStyle: widget.animationStyle,
|
||||
snapMode: widget.snapMode,
|
||||
child: _SnapTrigger(widget.child),
|
||||
);
|
||||
}
|
||||
@ -118,17 +147,20 @@ class _SliverFloatingHeader extends SingleChildRenderObjectWidget {
|
||||
const _SliverFloatingHeader({
|
||||
this.vsync,
|
||||
this.animationStyle,
|
||||
this.snapMode,
|
||||
super.child,
|
||||
});
|
||||
|
||||
final TickerProvider? vsync;
|
||||
final AnimationStyle? animationStyle;
|
||||
final FloatingHeaderSnapMode? snapMode;
|
||||
|
||||
@override
|
||||
_RenderSliverFloatingHeader createRenderObject(BuildContext context) {
|
||||
return _RenderSliverFloatingHeader(
|
||||
vsync: vsync,
|
||||
animationStyle: animationStyle,
|
||||
snapMode: snapMode,
|
||||
);
|
||||
}
|
||||
|
||||
@ -136,7 +168,8 @@ class _SliverFloatingHeader extends SingleChildRenderObjectWidget {
|
||||
void updateRenderObject(BuildContext context, _RenderSliverFloatingHeader renderObject) {
|
||||
renderObject
|
||||
..vsync = vsync
|
||||
..animationStyle = animationStyle;
|
||||
..animationStyle = animationStyle
|
||||
..snapMode = snapMode;
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,6 +177,7 @@ class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
|
||||
_RenderSliverFloatingHeader({
|
||||
TickerProvider? vsync,
|
||||
this.animationStyle,
|
||||
this.snapMode,
|
||||
}) : _vsync = vsync;
|
||||
|
||||
late Animation<double> snapAnimation;
|
||||
@ -173,6 +207,8 @@ class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
|
||||
|
||||
AnimationStyle? animationStyle;
|
||||
|
||||
FloatingHeaderSnapMode? snapMode;
|
||||
|
||||
// Called each time the position's isScrollingNotifier indicates that user scrolling has
|
||||
// stopped or started, i.e. if the sliver "is scrolling".
|
||||
void isScrollingUpdate(ScrollPosition position) {
|
||||
@ -265,7 +301,10 @@ class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
|
||||
|
||||
child?.layout(constraints.asBoxConstraints(), parentUsesSize: true);
|
||||
final double paintExtent = childExtent - effectiveScrollOffset;
|
||||
final double layoutExtent = childExtent - constraints.scrollOffset;
|
||||
final double layoutExtent = switch (snapMode ?? FloatingHeaderSnapMode.overlay) {
|
||||
FloatingHeaderSnapMode.overlay => childExtent - constraints.scrollOffset,
|
||||
FloatingHeaderSnapMode.scroll => paintExtent,
|
||||
};
|
||||
geometry = SliverGeometry(
|
||||
paintOrigin: math.min(constraints.overlap, 0.0),
|
||||
scrollExtent: childExtent,
|
||||
|
||||
@ -234,4 +234,92 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
|
||||
});
|
||||
|
||||
testWidgets('SliverFloatingHeader snapMode parameter', (WidgetTester tester) async {
|
||||
Widget buildFrame(FloatingHeaderSnapMode snapMode) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverFloatingHeader(
|
||||
snapMode: snapMode,
|
||||
child: const SizedBox(height: 200, child: Text('header')),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return SizedBox(height: 100, child: Text('item $index'));
|
||||
},
|
||||
childCount: 100,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Rect getHeaderRect() => tester.getRect(find.text('header'));
|
||||
double getItem0Y() => tester.getRect(find.text('item 0')).topLeft.dy;
|
||||
|
||||
Future<void> scroll(Offset offset) async {
|
||||
return tester.timedDrag(find.byType(CustomScrollView), offset, const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
// FloatingHeaderSnapMode.overlay
|
||||
{
|
||||
await tester.pumpWidget(buildFrame(FloatingHeaderSnapMode.overlay));
|
||||
await tester.pumpAndSettle();
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
|
||||
expect(getItem0Y(), 200);
|
||||
|
||||
// Scrolling in this direction will move more than 200 because
|
||||
// timedDrag() concludes with a fling and there's room for a
|
||||
// 200+ scroll.
|
||||
await scroll(const Offset(0, -200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('header'), findsNothing);
|
||||
final double item0StartY = getItem0Y();
|
||||
expect(item0StartY, lessThan(0));
|
||||
|
||||
// Trigger the appearance of the floating header. There's no
|
||||
// fling component to the scroll in this case because the scroll
|
||||
// offset is small.
|
||||
await scroll(const Offset(0, 25));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Item0 has only moved as far as the scroll because
|
||||
// the snapMode is overlay.
|
||||
expect(getItem0Y(), item0StartY + 25);
|
||||
|
||||
// Return the header and item0 to their initial layout.
|
||||
await scroll(const Offset(0, 200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
|
||||
expect(getItem0Y(), 200);
|
||||
}
|
||||
|
||||
// FloatingHeaderSnapMode.scroll
|
||||
{
|
||||
await tester.pumpWidget(buildFrame(FloatingHeaderSnapMode.scroll));
|
||||
await tester.pumpAndSettle();
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
|
||||
expect(getItem0Y(), 200);
|
||||
|
||||
await scroll(const Offset(0, -200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('header'), findsNothing);
|
||||
final double item0StartY = getItem0Y();
|
||||
expect(item0StartY, lessThan(0));
|
||||
|
||||
// Trigger the appearance of the floating header.
|
||||
await scroll(const Offset(0, 25));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Item0 has moved as far as the scroll (25) plus the height of
|
||||
// the header (200) because the snapMode is scroll and the
|
||||
// entire header had to snap in.
|
||||
expect(getItem0Y(), item0StartY + 200 + 25);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user