SliverFloatingHeader (#151145)
A sliver that shows its [child] when the user scrolls forward and hides it when the user scrolls backwards. Similar headers can be found in Google Photos and Facebook. This sliver is preferable to the general purpose SliverPersistentHeader for its relatively narrow use case because there's no need to create a SliverPersistentHeaderDelegate or to predict the header's size. https://github.com/flutter/flutter/assets/1377460/82b67dfb-5d38-4adf-9415-fc8527d0eb9f
This commit is contained in:
121
examples/api/lib/widgets/sliver/sliver_floating_header.0.dart
Normal file
121
examples/api/lib/widgets/sliver/sliver_floating_header.0.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
// 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 SliverFloatingHeaderApp());
|
||||
}
|
||||
|
||||
class SliverFloatingHeaderApp extends StatelessWidget {
|
||||
const SliverFloatingHeaderApp({ super.key });
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const MaterialApp(
|
||||
home: FloatingHeaderExample(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FloatingHeaderExample extends StatefulWidget {
|
||||
const FloatingHeaderExample({ super.key });
|
||||
|
||||
@override
|
||||
State<FloatingHeaderExample> createState() => _FloatingHeaderExampleState();
|
||||
}
|
||||
|
||||
class _FloatingHeaderExampleState extends State<FloatingHeaderExample> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverFloatingHeader(
|
||||
child: ListHeader(
|
||||
text: 'SliverFloatingHeader\nScroll down a little to show\nScroll up a little to hide',
|
||||
),
|
||||
),
|
||||
ItemList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A widget that displays its text within a thick rounded rectangle border
|
||||
class ListHeader extends StatelessWidget {
|
||||
const ListHeader({
|
||||
super.key,
|
||||
required this.text,
|
||||
});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
color: colorScheme.background,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Material(
|
||||
color: colorScheme.primaryContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
width: 7,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineMedium!.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A placeholder SliverList of 50 items.
|
||||
class ItemList extends StatelessWidget {
|
||||
const ItemList({
|
||||
super.key,
|
||||
this.itemCount = 50,
|
||||
});
|
||||
|
||||
final int itemCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return Card(
|
||||
color: colorScheme.onSecondary,
|
||||
child: ListTile(
|
||||
textColor: colorScheme.secondary,
|
||||
title: Text('Item $index'),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: itemCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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_api_samples/widgets/sliver/sliver_floating_header.0.dart' as example;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('SliverFloatingHeader example', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const example.SliverFloatingHeaderApp(),
|
||||
);
|
||||
|
||||
final Finder headerText = find.text('SliverFloatingHeader\nScroll down a little to show\nScroll up a little to hide');
|
||||
final double headerHeight = tester.getSize(headerText).height;
|
||||
|
||||
await tester.drag(find.byType(CustomScrollView), Offset(0, -2 * headerHeight));
|
||||
await tester.pumpAndSettle();
|
||||
expect(headerText, findsNothing);
|
||||
|
||||
await tester.drag(find.byType(CustomScrollView), Offset(0, 0.5 * headerHeight));
|
||||
await tester.pumpAndSettle();
|
||||
expect(headerText, findsOneWidget);
|
||||
|
||||
await tester.drag(find.byType(CustomScrollView), Offset(0, -0.5 * headerHeight));
|
||||
await tester.pumpAndSettle();
|
||||
expect(headerText, findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -2222,6 +2222,9 @@ FocusNode? get primaryFocus => WidgetsBinding.instance.focusManager.primaryFocus
|
||||
String debugDescribeFocusTree() {
|
||||
String? result;
|
||||
assert(() {
|
||||
// TODO(yjbanov): remove this once https://github.com/dart-lang/sdk/issues/56129 has been fixed.
|
||||
// ignore: unnecessary_statements
|
||||
FocusManager.instance.toStringDeep;
|
||||
result = FocusManager.instance.toStringDeep();
|
||||
return true;
|
||||
}());
|
||||
|
||||
@@ -29,6 +29,8 @@ import 'framework.dart';
|
||||
/// * [SliverResizingHeader] - which similarly pins the header at the top
|
||||
/// of the [CustomScrollView] but reacts to scrolling by resizing the header
|
||||
/// between its minimum and maximum extent limits.
|
||||
/// * [SliverFloatingHeader] - which animates the header in and out of view
|
||||
/// in response to downward and upwards scrolls.
|
||||
/// * [SliverPersistentHeader] - a general purpose header that can be
|
||||
/// configured as a pinned, resizing, or floating header.
|
||||
class PinnedHeaderSliver extends SingleChildRenderObjectWidget {
|
||||
|
||||
304
packages/flutter/lib/src/widgets/sliver_floating_header.dart
Normal file
304
packages/flutter/lib/src/widgets/sliver_floating_header.dart
Normal file
@@ -0,0 +1,304 @@
|
||||
// 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 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/animation.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
import 'framework.dart';
|
||||
import 'scroll_position.dart';
|
||||
import 'scrollable.dart';
|
||||
import 'ticker_provider.dart';
|
||||
|
||||
/// A sliver that shows its [child] when the user scrolls forward and hides it
|
||||
/// when the user scrolls backwards.
|
||||
///
|
||||
/// This sliver is preferable to the general purpose [SliverPersistentHeader]
|
||||
/// for its relatively narrow use case because there's no need to create a
|
||||
/// [SliverPersistentHeaderDelegate] or to predict the header's size.
|
||||
///
|
||||
/// {@tool dartpad}
|
||||
/// This example shows how to create a SliverFloatingHeader.
|
||||
///
|
||||
/// ** See code in examples/api/lib/widgets/sliver/sliver_floating_header.0.dart **
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [PinnedHeaderSliver] - which just pins the header at the top
|
||||
/// of the [CustomScrollView].
|
||||
/// * [SliverResizingHeader] - which similarly pins the header at the top
|
||||
/// of the [CustomScrollView] but reacts to scrolling by resizing the header
|
||||
/// between its minimum and maximum extent limits.
|
||||
/// * [SliverPersistentHeader] - a general purpose header that can be
|
||||
/// configured as a pinned, resizing, or floating header.
|
||||
class SliverFloatingHeader extends StatefulWidget {
|
||||
/// Create a floating header sliver that animates into view when the user
|
||||
/// scrolls forward, and disappears the user starts scrolling in the
|
||||
/// opposite direction.
|
||||
const SliverFloatingHeader({
|
||||
super.key,
|
||||
this.animationStyle,
|
||||
required this.child
|
||||
});
|
||||
|
||||
/// Non null properties override the default durations (300ms) and
|
||||
/// curves (Curves.easeInOut) for subsequent header animations.
|
||||
///
|
||||
/// The reverse duration and curve apply to the animation that hides the header.
|
||||
final AnimationStyle? animationStyle;
|
||||
|
||||
/// The widget contained by this sliver.
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<SliverFloatingHeader> createState() => _SliverFloatingHeaderState();
|
||||
}
|
||||
|
||||
class _SliverFloatingHeaderState extends State<SliverFloatingHeader> with SingleTickerProviderStateMixin {
|
||||
ScrollPosition? position;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _SliverFloatingHeader(
|
||||
vsync: this,
|
||||
animationStyle: widget.animationStyle,
|
||||
child: _SnapTrigger(widget.child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SnapTrigger extends StatefulWidget {
|
||||
const _SnapTrigger(this.child);
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
_SnapTriggerState createState() => _SnapTriggerState();
|
||||
}
|
||||
|
||||
class _SnapTriggerState extends State<_SnapTrigger> {
|
||||
ScrollPosition? position;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (position != null) {
|
||||
position!.isScrollingNotifier.removeListener(isScrollingListener);
|
||||
}
|
||||
position = Scrollable.maybeOf(context)?.position;
|
||||
if (position != null) {
|
||||
position!.isScrollingNotifier.addListener(isScrollingListener);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (position != null) {
|
||||
position!.isScrollingNotifier.removeListener(isScrollingListener);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Called when the sliver starts or ends scrolling.
|
||||
void isScrollingListener() {
|
||||
assert(position != null);
|
||||
final _RenderSliverFloatingHeader? renderer = context.findAncestorRenderObjectOfType<_RenderSliverFloatingHeader>();
|
||||
renderer?.isScrollingUpdate(position!);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
|
||||
class _SliverFloatingHeader extends SingleChildRenderObjectWidget {
|
||||
const _SliverFloatingHeader({
|
||||
this.vsync,
|
||||
this.animationStyle,
|
||||
super.child,
|
||||
});
|
||||
|
||||
final TickerProvider? vsync;
|
||||
final AnimationStyle? animationStyle;
|
||||
|
||||
@override
|
||||
_RenderSliverFloatingHeader createRenderObject(BuildContext context) {
|
||||
return _RenderSliverFloatingHeader(
|
||||
vsync: vsync,
|
||||
animationStyle: animationStyle,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, _RenderSliverFloatingHeader renderObject) {
|
||||
renderObject
|
||||
..vsync = vsync
|
||||
..animationStyle = animationStyle;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
|
||||
_RenderSliverFloatingHeader({
|
||||
TickerProvider? vsync,
|
||||
this.animationStyle,
|
||||
}) : _vsync = vsync;
|
||||
|
||||
late Animation<double> snapAnimation;
|
||||
AnimationController? snapController;
|
||||
double? lastScrollOffset;
|
||||
|
||||
// The distance from the start of the header to the start of the viewport. Whent the
|
||||
// header is showing it varies between 0 (completely visible) and childExtent (not visible
|
||||
// because it's just abopve the viewport's starting edge). It's used to compute the
|
||||
// header's paintExtent which defines where the header will appear - see paint().
|
||||
late double effectiveScrollOffset;
|
||||
|
||||
TickerProvider? get vsync => _vsync;
|
||||
TickerProvider? _vsync;
|
||||
set vsync(TickerProvider? value) {
|
||||
if (value == _vsync) {
|
||||
return;
|
||||
}
|
||||
_vsync = value;
|
||||
if (value == null) {
|
||||
snapController?.dispose();
|
||||
snapController = null;
|
||||
} else {
|
||||
snapController?.resync(value);
|
||||
}
|
||||
}
|
||||
|
||||
AnimationStyle? animationStyle;
|
||||
|
||||
// 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) {
|
||||
if (position.isScrollingNotifier.value) {
|
||||
snapController?.stop();
|
||||
} else {
|
||||
final ScrollDirection direction = position.userScrollDirection;
|
||||
final bool headerIsPartiallyVisible = switch (direction) {
|
||||
ScrollDirection.forward when effectiveScrollOffset <= 0 => false, // completely visible
|
||||
ScrollDirection.reverse when effectiveScrollOffset >= childExtent => false, // not visible
|
||||
_ => true,
|
||||
};
|
||||
if (headerIsPartiallyVisible) {
|
||||
snapController ??= AnimationController(vsync: vsync!)
|
||||
..addListener(() {
|
||||
if (effectiveScrollOffset != snapAnimation.value) {
|
||||
effectiveScrollOffset = snapAnimation.value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
});
|
||||
snapController!.duration = switch (direction) {
|
||||
ScrollDirection.forward => animationStyle?.duration ?? const Duration(milliseconds: 300),
|
||||
_ => animationStyle?.reverseDuration ?? const Duration(milliseconds: 300),
|
||||
};
|
||||
snapAnimation = snapController!.drive(
|
||||
Tween<double>(
|
||||
begin: effectiveScrollOffset,
|
||||
end: switch (direction) {
|
||||
ScrollDirection.forward => 0,
|
||||
_ => childExtent,
|
||||
},
|
||||
).chain(
|
||||
CurveTween(
|
||||
curve: switch (direction) {
|
||||
ScrollDirection.forward => animationStyle?.curve ?? Curves.easeInOut,
|
||||
_ => animationStyle?.reverseCurve ?? Curves.easeInOut,
|
||||
}
|
||||
),
|
||||
),
|
||||
);
|
||||
snapController!.forward(from: 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double get childExtent {
|
||||
if (child == null) {
|
||||
return 0.0;
|
||||
}
|
||||
assert(child!.hasSize);
|
||||
return switch (constraints.axis) {
|
||||
Axis.vertical => child!.size.height,
|
||||
Axis.horizontal => child!.size.width,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void detach() {
|
||||
snapController?.dispose();
|
||||
snapController = null; // lazily recreated if we're reattached.
|
||||
super.detach();
|
||||
}
|
||||
|
||||
// True if the header has been laid at at least once (lastScrollOffset != null) and either:
|
||||
// - We're scrolling forward: constraints.scrollOffset < lastScrollOffset
|
||||
// - The header's already partially visible: effectiveScrollOffset < childExtent
|
||||
// Scrolling forwards (towards the scrollable's start) is the trigger that causes the
|
||||
// header to be shown.
|
||||
bool get floatingHeaderNeedsToBeUpdated {
|
||||
return lastScrollOffset != null &&
|
||||
(constraints.scrollOffset < lastScrollOffset! || effectiveScrollOffset < childExtent);
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
if (!floatingHeaderNeedsToBeUpdated) {
|
||||
effectiveScrollOffset = constraints.scrollOffset;
|
||||
} else {
|
||||
double delta = lastScrollOffset! - constraints.scrollOffset; // > 0 when the header is growing
|
||||
if (constraints.userScrollDirection == ScrollDirection.forward) {
|
||||
if (effectiveScrollOffset > childExtent) {
|
||||
effectiveScrollOffset = childExtent; // The header is now just above the start edge of viewport.
|
||||
}
|
||||
} else {
|
||||
// delta > 0 and scrolling forward is a contradiction. Assume that it's noise (set delta to 0).
|
||||
delta = clampDouble(delta, -double.infinity, 0);
|
||||
}
|
||||
effectiveScrollOffset = clampDouble(effectiveScrollOffset - delta, 0.0, constraints.scrollOffset);
|
||||
}
|
||||
|
||||
child?.layout(constraints.asBoxConstraints(), parentUsesSize: true);
|
||||
final double paintExtent = childExtent - effectiveScrollOffset;
|
||||
final double layoutExtent = childExtent - constraints.scrollOffset;
|
||||
geometry = SliverGeometry(
|
||||
paintOrigin: math.min(constraints.overlap, 0.0),
|
||||
scrollExtent: childExtent,
|
||||
paintExtent: clampDouble(paintExtent, 0.0, constraints.remainingPaintExtent),
|
||||
layoutExtent: clampDouble(layoutExtent, 0.0, constraints.remainingPaintExtent),
|
||||
maxPaintExtent: childExtent,
|
||||
hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
|
||||
);
|
||||
|
||||
lastScrollOffset = constraints.scrollOffset;
|
||||
}
|
||||
|
||||
@override
|
||||
double childMainAxisPosition(covariant RenderObject child) {
|
||||
return geometry == null ? 0 : math.min(0, geometry!.paintExtent - childExtent);
|
||||
}
|
||||
|
||||
@override
|
||||
void applyPaintTransform(RenderObject child, Matrix4 transform) {
|
||||
assert(child == this.child);
|
||||
applyPaintTransformForBoxChild(child as RenderBox, transform);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
if (child != null && geometry!.visible) {
|
||||
offset += switch (applyGrowthDirectionToAxisDirection(constraints.axisDirection, constraints.growthDirection)) {
|
||||
AxisDirection.up => Offset(0.0, geometry!.paintExtent - childMainAxisPosition(child!) - childExtent),
|
||||
AxisDirection.left => Offset(geometry!.paintExtent - childMainAxisPosition(child!) - childExtent, 0.0),
|
||||
AxisDirection.right => Offset(childMainAxisPosition(child!), 0.0),
|
||||
AxisDirection.down => Offset(0.0, childMainAxisPosition(child!)),
|
||||
};
|
||||
context.paintChild(child!, offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ import 'slotted_render_object_widget.dart';
|
||||
///
|
||||
/// * [PinnedHeaderSliver] - which just pins the header at the top
|
||||
/// of the [CustomScrollView].
|
||||
/// * [SliverFloatingHeader] - which animates the header in and out of view
|
||||
/// in response to downward and upwards scrolls.
|
||||
/// * [SliverPersistentHeader] - a general purpose header that can be
|
||||
/// configured as a pinned, resizing, or floating header.
|
||||
class SliverResizingHeader extends StatelessWidget {
|
||||
|
||||
@@ -135,6 +135,7 @@ export 'src/widgets/single_child_scroll_view.dart';
|
||||
export 'src/widgets/size_changed_layout_notifier.dart';
|
||||
export 'src/widgets/sliver.dart';
|
||||
export 'src/widgets/sliver_fill.dart';
|
||||
export 'src/widgets/sliver_floating_header.dart';
|
||||
export 'src/widgets/sliver_layout_builder.dart';
|
||||
export 'src/widgets/sliver_persistent_header.dart';
|
||||
export 'src/widgets/sliver_prototype_extent_list.dart';
|
||||
|
||||
237
packages/flutter/test/widgets/sliver_floating_header_test.dart
Normal file
237
packages/flutter/test/widgets/sliver_floating_header_test.dart
Normal file
@@ -0,0 +1,237 @@
|
||||
// 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_test/flutter_test.dart';
|
||||
|
||||
|
||||
void main() {
|
||||
testWidgets('SliverFloatingHeader basics', (WidgetTester tester) async {
|
||||
Widget buildFrame({ required Axis axis, required bool reverse }) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: CustomScrollView(
|
||||
scrollDirection: axis,
|
||||
reverse: reverse,
|
||||
slivers: <Widget>[
|
||||
SliverFloatingHeader(
|
||||
child: switch (axis) {
|
||||
Axis.vertical => const SizedBox(height: 200, child: Text('header')),
|
||||
Axis.horizontal => const SizedBox(width: 200, child: Text('header')),
|
||||
},
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return switch (axis) {
|
||||
Axis.vertical => SizedBox(height: 100, child: Text('item $index')),
|
||||
Axis.horizontal => SizedBox(width: 100, child: Text('item $index')),
|
||||
};
|
||||
},
|
||||
childCount: 100,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Rect getHeaderRect() => tester.getRect(find.text('header'));
|
||||
|
||||
Future<int> scroll(Offset offset) async {
|
||||
await tester.timedDrag(find.byType(CustomScrollView), offset, const Duration(milliseconds: 500));
|
||||
return tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// axis: Axis.vertical, reverse: false
|
||||
{
|
||||
await tester.pumpWidget(buildFrame(axis: Axis.vertical, reverse: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The test viewport is width=800 x height=600
|
||||
// The height=200 header is at the top of the scroll view and all items are the same height.
|
||||
expect(getHeaderRect().topLeft, Offset.zero);
|
||||
expect(getHeaderRect().width, 800);
|
||||
expect(getHeaderRect().height, 200);
|
||||
|
||||
// First and last visible items, each item has height=100
|
||||
const int visibleItemCount = 4; // viewport height - header height = 400
|
||||
expect(find.text('item 0'), findsOneWidget);
|
||||
expect(find.text('item ${visibleItemCount - 1}'), findsOneWidget);
|
||||
|
||||
// Scroll the header past the top of the viewport.
|
||||
await scroll(const Offset(0, -200));
|
||||
expect(find.text('header'), findsNothing);
|
||||
|
||||
// Scroll in the opposite direction a little to trigger the appearance of the floating header.
|
||||
await scroll(const Offset(0, 25));
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
|
||||
|
||||
// Scrolling further in the same direction, leaves the header where it is.
|
||||
await scroll(const Offset(0, 25));
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
|
||||
|
||||
// Scroll in the original direction a little to trigger the header's disappearance.
|
||||
await scroll(const Offset(0, -25));
|
||||
expect(find.text('header'), findsNothing);
|
||||
}
|
||||
|
||||
// axis: Axis.horizontal, reverse: false
|
||||
{
|
||||
await tester.pumpWidget(buildFrame(axis: Axis.horizontal, reverse: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(getHeaderRect().topLeft, Offset.zero);
|
||||
expect(getHeaderRect().width, 200);
|
||||
expect(getHeaderRect().height, 600);
|
||||
|
||||
// First and last visible items. Each item has width=100
|
||||
const int visibleItemCount = 6; // 600 = viewport width - header width
|
||||
expect(find.text('item 0'), findsOneWidget);
|
||||
expect(find.text('item ${visibleItemCount - 1}'), findsOneWidget);
|
||||
|
||||
// Scroll the header past the left edge of the viewport.
|
||||
await scroll(const Offset(-200, 0));
|
||||
expect(find.text('header'), findsNothing);
|
||||
|
||||
// Scroll in the opposite direction a little to trigger the appearance of the floating header.
|
||||
await scroll(const Offset(25, 0));
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 200, 600));
|
||||
|
||||
// Scrolling further in the same direction, leaves the header where it is.
|
||||
await scroll(const Offset(25, 0));
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 200, 600));
|
||||
|
||||
// Scroll in the original direction a little to trigger the header's disappearance.
|
||||
await scroll(const Offset(-25, 0));
|
||||
expect(find.text('header'), findsNothing);
|
||||
}
|
||||
|
||||
// axis: Axis.vertical, reverse: true
|
||||
{
|
||||
await tester.pumpWidget(buildFrame(axis: Axis.vertical, reverse: true));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(getHeaderRect().topLeft, const Offset(0, 400));
|
||||
expect(getHeaderRect().width, 800);
|
||||
expect(getHeaderRect().height, 200);
|
||||
|
||||
// First and last visible items, each item has height=100
|
||||
const int visibleItemCount = 4; // viewport height - header height = 400
|
||||
expect(find.text('item 0'), findsOneWidget);
|
||||
expect(find.text('item ${visibleItemCount - 1}'), findsOneWidget);
|
||||
|
||||
// Scroll the header past the bottom of the viewport.
|
||||
await scroll(const Offset(0, 200));
|
||||
expect(find.text('header'), findsNothing);
|
||||
|
||||
// Scroll in the opposite direction a little to trigger the appearance of the floating header.
|
||||
await scroll(const Offset(0, -25));
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(0, 400, 800, 600));
|
||||
|
||||
// Scrolling further in the same direction, leaves the header where it is.
|
||||
await scroll(const Offset(0, -25));
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(0, 400, 800, 600));
|
||||
|
||||
// Scroll in the original direction a little to trigger the header's disappearance.
|
||||
await scroll(const Offset(0, 25));
|
||||
expect(find.text('header'), findsNothing);
|
||||
}
|
||||
|
||||
// axis: Axis.horizontal, reverse: true
|
||||
{
|
||||
await tester.pumpWidget(buildFrame(axis: Axis.horizontal, reverse: true));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(getHeaderRect().topLeft, const Offset(600, 0));
|
||||
expect(getHeaderRect().width, 200);
|
||||
expect(getHeaderRect().height, 600);
|
||||
|
||||
// First and last visible items. Each item has width=100
|
||||
const int visibleItemCount = 6; // 600 = viewport width - header width
|
||||
expect(find.text('item 0'), findsOneWidget);
|
||||
expect(find.text('item ${visibleItemCount - 1}'), findsOneWidget);
|
||||
|
||||
// Scroll the header past the right edge of the viewport.
|
||||
await scroll(const Offset(200, 0));
|
||||
expect(find.text('header'), findsNothing);
|
||||
|
||||
// Scroll in the opposite direction a little to trigger the appearance of the floating header.
|
||||
await scroll(const Offset(-25, 0));
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(600, 0, 800, 600));
|
||||
|
||||
// Scrolling further in the same direction, leaves the header where it is.
|
||||
await scroll(const Offset(-25, 0));
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(600, 0, 800, 600));
|
||||
|
||||
// Scroll in the original direction a little to trigger the header's disappearance.
|
||||
await scroll(const Offset(25, 0));
|
||||
expect(find.text('header'), findsNothing);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('SliverFloatingHeader override default AnimationStyle', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverFloatingHeader(
|
||||
animationStyle: AnimationStyle(
|
||||
curve: Curves.linear,
|
||||
reverseCurve: Curves.linear,
|
||||
duration: const Duration(seconds: 1),
|
||||
reverseDuration: const Duration(seconds: 1),
|
||||
),
|
||||
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'));
|
||||
|
||||
Future<void> scroll(Offset offset) async {
|
||||
return tester.timedDrag(find.byType(CustomScrollView), offset, const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
// The test viewport is width=800 x height=600
|
||||
// The height=200 header is at the top of the scroll view and all items are the same height.
|
||||
expect(getHeaderRect().topLeft, Offset.zero);
|
||||
expect(getHeaderRect().width, 800);
|
||||
expect(getHeaderRect().height, 200);
|
||||
|
||||
// Scroll the header past the top of the viewport.
|
||||
await scroll(const Offset(0, -200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('header'), findsNothing);
|
||||
|
||||
// Scroll in the opposite direction a little to trigger the appearance of the floating header.
|
||||
await scroll(const Offset(0, 25));
|
||||
|
||||
// Initially the header is where the drag left it => it's moved 25 downwards
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(0, -175, 800, 25));
|
||||
|
||||
// With a linear animation curve, after half the animation's duration (500ms), we'll
|
||||
// have moved downwards half of the remaining 175:
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(0, -175/2, 800, 200 - 175/2));
|
||||
|
||||
// After the remainder of the animation's duration the header is back
|
||||
// where it started.
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user