// 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:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; void verifyPaintPosition(GlobalKey key, Offset ideal, bool visible) { final RenderSliver target = key.currentContext!.findRenderObject()! as RenderSliver; expect(target.parent, isA<RenderViewport>()); final SliverPhysicalParentData parentData = target.parentData! as SliverPhysicalParentData; final Offset actual = parentData.paintOffset; expect(actual, ideal); final SliverGeometry geometry = target.geometry!; expect(geometry.visible, visible); } void verifyActualBoxPosition(WidgetTester tester, Finder finder, int index, Rect ideal) { final RenderBox box = tester.renderObjectList<RenderBox>(finder).elementAt(index); final Rect rect = Rect.fromPoints(box.localToGlobal(Offset.zero), box.localToGlobal(box.size.bottomRight(Offset.zero))); expect(rect, equals(ideal)); } void main() { testWidgets("Sliver appbars - floating - scroll offset doesn't change", (WidgetTester tester) async { const double bigHeight = 1000.0; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: CustomScrollView( slivers: <Widget>[ const BigSliver(height: bigHeight), SliverPersistentHeader(delegate: TestDelegate(), floating: true), const BigSliver(height: bigHeight), ], ), ), ); final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position; final double max = bigHeight * 2.0 + TestDelegate().maxExtent - 600.0; // 600 is the height of the test viewport assert(max < 10000.0); expect(max, 1600.0); expect(position.pixels, 0.0); expect(position.minScrollExtent, 0.0); expect(position.maxScrollExtent, max); position.animateTo(10000.0, curve: Curves.linear, duration: const Duration(minutes: 1)); await tester.pumpAndSettle(const Duration(milliseconds: 50)); expect(position.pixels, max); expect(position.minScrollExtent, 0.0); expect(position.maxScrollExtent, max); }); testWidgets('Sliver appbars - floating - normal behavior works', (WidgetTester tester) async { final TestDelegate delegate = TestDelegate(); const double bigHeight = 1000.0; GlobalKey key1, key2, key3; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: CustomScrollView( slivers: <Widget>[ BigSliver(key: key1 = GlobalKey(), height: bigHeight), SliverPersistentHeader(key: key2 = GlobalKey(), delegate: delegate, floating: true), BigSliver(key: key3 = GlobalKey(), height: bigHeight), ], ), ), ); final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position; verifyPaintPosition(key1, Offset.zero, true); verifyPaintPosition(key2, const Offset(0.0, 1000.0), false); verifyPaintPosition(key3, const Offset(0.0, 1200.0), false); position.animateTo(bigHeight - 600.0 + delegate.maxExtent, curve: Curves.linear, duration: const Duration(minutes: 1)); await tester.pumpAndSettle(const Duration(milliseconds: 1000)); verifyPaintPosition(key1, Offset.zero, true); verifyPaintPosition(key2, Offset(0.0, 600.0 - delegate.maxExtent), true); verifyActualBoxPosition(tester, find.byType(Container), 0, Rect.fromLTWH(0.0, 600.0 - delegate.maxExtent, 800.0, delegate.maxExtent)); verifyPaintPosition(key3, const Offset(0.0, 600.0), false); assert(delegate.maxExtent * 2.0 < 600.0); // make sure this fits on the test screen... position.animateTo(bigHeight - 600.0 + delegate.maxExtent * 2.0, curve: Curves.linear, duration: const Duration(minutes: 1)); await tester.pumpAndSettle(const Duration(milliseconds: 1000)); verifyPaintPosition(key1, Offset.zero, true); verifyPaintPosition(key2, Offset(0.0, 600.0 - delegate.maxExtent * 2.0), true); verifyActualBoxPosition(tester, find.byType(Container), 0, Rect.fromLTWH(0.0, 600.0 - delegate.maxExtent * 2.0, 800.0, delegate.maxExtent)); verifyPaintPosition(key3, Offset(0.0, 600.0 - delegate.maxExtent), true); position.animateTo(bigHeight, curve: Curves.linear, duration: const Duration(minutes: 1)); await tester.pumpAndSettle(const Duration(milliseconds: 1000)); verifyPaintPosition(key1, Offset.zero, false); verifyPaintPosition(key2, Offset.zero, true); verifyActualBoxPosition(tester, find.byType(Container), 0, Rect.fromLTWH(0.0, 0.0, 800.0, delegate.maxExtent)); verifyPaintPosition(key3, Offset(0.0, delegate.maxExtent), true); position.animateTo(bigHeight + delegate.maxExtent * 0.1, curve: Curves.linear, duration: const Duration(minutes: 1)); await tester.pumpAndSettle(const Duration(milliseconds: 1000)); verifyPaintPosition(key1, Offset.zero, false); verifyPaintPosition(key2, Offset.zero, true); verifyActualBoxPosition(tester, find.byType(Container), 0, Rect.fromLTWH(0.0, 0.0, 800.0, delegate.maxExtent * 0.9)); verifyPaintPosition(key3, Offset(0.0, delegate.maxExtent * 0.9), true); position.animateTo(bigHeight + delegate.maxExtent * 0.5, curve: Curves.linear, duration: const Duration(minutes: 1)); await tester.pumpAndSettle(const Duration(milliseconds: 1000)); verifyPaintPosition(key1, Offset.zero, false); verifyPaintPosition(key2, Offset.zero, true); verifyActualBoxPosition(tester, find.byType(Container), 0, Rect.fromLTWH(0.0, 0.0, 800.0, delegate.maxExtent * 0.5)); verifyPaintPosition(key3, Offset(0.0, delegate.maxExtent * 0.5), true); position.animateTo(bigHeight + delegate.maxExtent * 0.9, curve: Curves.linear, duration: const Duration(minutes: 1)); await tester.pumpAndSettle(const Duration(milliseconds: 1000)); verifyPaintPosition(key1, Offset.zero, false); verifyPaintPosition(key2, Offset.zero, true); verifyActualBoxPosition(tester, find.byType(Container), 0, Rect.fromLTWH(0.0, -delegate.maxExtent * 0.4, 800.0, delegate.maxExtent * 0.5)); verifyPaintPosition(key3, Offset(0.0, delegate.maxExtent * 0.1), true); position.animateTo(bigHeight + delegate.maxExtent * 2.0, curve: Curves.linear, duration: const Duration(minutes: 1)); await tester.pumpAndSettle(const Duration(milliseconds: 1000)); verifyPaintPosition(key1, Offset.zero, false); verifyPaintPosition(key2, Offset.zero, false); verifyPaintPosition(key3, Offset.zero, true); }); testWidgets('Sliver appbars - floating - no floating behavior when animating', (WidgetTester tester) async { final TestDelegate delegate = TestDelegate(); const double bigHeight = 1000.0; GlobalKey key1, key2, key3; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: CustomScrollView( slivers: <Widget>[ BigSliver(key: key1 = GlobalKey(), height: bigHeight), SliverPersistentHeader(key: key2 = GlobalKey(), delegate: delegate, floating: true), BigSliver(key: key3 = GlobalKey(), height: bigHeight), ], ), ), ); final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position; verifyPaintPosition(key1, Offset.zero, true); verifyPaintPosition(key2, const Offset(0.0, 1000.0), false); verifyPaintPosition(key3, const Offset(0.0, 1200.0), false); position.animateTo(bigHeight + delegate.maxExtent * 2.0, curve: Curves.linear, duration: const Duration(minutes: 1)); await tester.pumpAndSettle(const Duration(milliseconds: 1000)); verifyPaintPosition(key1, Offset.zero, false); verifyPaintPosition(key2, Offset.zero, false); verifyPaintPosition(key3, Offset.zero, true); position.animateTo(bigHeight + delegate.maxExtent * 1.9, curve: Curves.linear, duration: const Duration(minutes: 1)); await tester.pumpAndSettle(const Duration(milliseconds: 1000)); verifyPaintPosition(key1, Offset.zero, false); verifyPaintPosition(key2, Offset.zero, false); verifyPaintPosition(key3, Offset.zero, true); }); testWidgets('Sliver appbars - floating - floating behavior when dragging down', (WidgetTester tester) async { final TestDelegate delegate = TestDelegate(); const double bigHeight = 1000.0; GlobalKey key1, key2, key3; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: CustomScrollView( slivers: <Widget>[ BigSliver(key: key1 = GlobalKey(), height: bigHeight), SliverPersistentHeader(key: key2 = GlobalKey(), delegate: delegate, floating: true), BigSliver(key: key3 = GlobalKey(), height: bigHeight), ], ), ), ); final ScrollPositionWithSingleContext position = tester.state<ScrollableState>(find.byType(Scrollable)).position as ScrollPositionWithSingleContext; verifyPaintPosition(key1, Offset.zero, true); verifyPaintPosition(key2, const Offset(0.0, 1000.0), false); verifyPaintPosition(key3, const Offset(0.0, 1200.0), false); position.animateTo(bigHeight + delegate.maxExtent * 2.0, curve: Curves.linear, duration: const Duration(minutes: 1)); await tester.pumpAndSettle(const Duration(milliseconds: 1000)); verifyPaintPosition(key1, Offset.zero, false); verifyPaintPosition(key2, Offset.zero, false); verifyPaintPosition(key3, Offset.zero, true); position.animateTo(bigHeight + delegate.maxExtent * 1.9, curve: Curves.linear, duration: const Duration(minutes: 1)); position.updateUserScrollDirection(ScrollDirection.forward); await tester.pumpAndSettle(const Duration(milliseconds: 1000)); verifyPaintPosition(key1, Offset.zero, false); verifyPaintPosition(key2, Offset.zero, true); verifyActualBoxPosition(tester, find.byType(Container), 0, Rect.fromLTWH(0.0, -delegate.maxExtent * 0.4, 800.0, delegate.maxExtent * 0.5)); verifyPaintPosition(key3, Offset.zero, true); }); testWidgets('Sliver appbars - floating - overscroll gap is below header', (WidgetTester tester) async { await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: <Widget>[ SliverPersistentHeader(delegate: TestDelegate(), floating: true), SliverList( delegate: SliverChildListDelegate(<Widget>[ const SizedBox( height: 300.0, child: Text('X'), ), ]), ), ], ), ), ); expect(tester.getTopLeft(find.byType(Container)), Offset.zero); expect(tester.getTopLeft(find.text('X')), const Offset(0.0, 200.0)); final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position; position.jumpTo(-50.0); await tester.pump(); expect(tester.getTopLeft(find.byType(Container)), Offset.zero); expect(tester.getTopLeft(find.text('X')), const Offset(0.0, 250.0)); }); group('Pointer scrolled floating', () { Widget buildTest(Widget sliver) { return MaterialApp( home: CustomScrollView( slivers: <Widget>[ sliver, SliverFixedExtentList( itemExtent: 50.0, delegate: SliverChildBuilderDelegate( (BuildContext context, int index) => Text('Item $index'), childCount: 30, ) ), ], ), ); } void verifyGeometry({ required GlobalKey key, required bool visible, required double paintExtent }) { final RenderSliver target = key.currentContext!.findRenderObject()! as RenderSliver; final SliverGeometry geometry = target.geometry!; expect(geometry.visible, visible); expect(geometry.paintExtent, paintExtent); } testWidgets('SliverAppBar', (WidgetTester tester) async { final GlobalKey appBarKey = GlobalKey(); await tester.pumpWidget(buildTest(SliverAppBar( key: appBarKey, floating: true, title: const Text('Test Title'), ))); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, visible: true, paintExtent: 56.0); // Pointer scroll the app bar away, we will scroll back less to validate the // app bar floats back in. final Offset point1 = tester.getCenter(find.text('Item 5')); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); testPointer.hover(point1); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); await tester.pump(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); // Scroll back to float in appbar await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -50.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 50.0, visible: true); // Float the rest of the way in. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -250.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); }); testWidgets('SliverPersistentHeader', (WidgetTester tester) async { final GlobalKey headerKey = GlobalKey(); await tester.pumpWidget(buildTest(SliverPersistentHeader( key: headerKey, floating: true, delegate: HeaderDelegate(), ))); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: headerKey, visible: true, paintExtent: 56.0); // Pointer scroll the app bar away, we will scroll back less to validate the // app bar floats back in. final Offset point1 = tester.getCenter(find.text('Item 5')); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); testPointer.hover(point1); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); await tester.pump(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: headerKey, paintExtent: 0.0, visible: false); // Scroll back to float in appbar await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -50.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: headerKey, paintExtent: 50.0, visible: true); // Float the rest of the way in. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -250.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: headerKey, paintExtent: 56.0, visible: true); }); testWidgets('and snapping SliverAppBar', (WidgetTester tester) async { final GlobalKey appBarKey = GlobalKey(); await tester.pumpWidget(buildTest(SliverAppBar( key: appBarKey, floating: true, snap: true, title: const Text('Test Title'), ))); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, visible: true, paintExtent: 56.0); // Pointer scroll the app bar away, we will scroll back less to validate the // app bar floats back in and then snaps to full size. final Offset point1 = tester.getCenter(find.text('Item 5')); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); testPointer.hover(point1); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); await tester.pump(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); // Scroll back to float in appbar await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -30.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 30.0, visible: true); await tester.pumpAndSettle(); // The snap animation should have completed and the app bar should be // fully expanded. expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); // Float back out a bit and trigger snap close animation. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 50.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 6.0, visible: true); await tester.pumpAndSettle(); // The snap animation should have completed and the app bar should no // longer be visible. expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( find.byType(AppBar), findsNothing, ); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); }); }); } class HeaderDelegate extends SliverPersistentHeaderDelegate { @override Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { return Container( height: 56, color: Colors.red, child: const Text('Test Title'), ); } @override double get maxExtent => 56; @override double get minExtent => 56; @override bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => false; } class TestDelegate extends SliverPersistentHeaderDelegate { @override double get maxExtent => 200.0; @override double get minExtent => 100.0; @override Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { return Container(constraints: BoxConstraints(minHeight: minExtent, maxHeight: maxExtent)); } @override bool shouldRebuild(TestDelegate oldDelegate) => false; } class RenderBigSliver extends RenderSliver { RenderBigSliver(double height) : _height = height; double get height => _height; double _height; set height(double value) { if (value == _height) { return; } _height = value; markNeedsLayout(); } double get paintExtent => (height - constraints.scrollOffset).clamp(0.0, constraints.remainingPaintExtent); @override void performLayout() { geometry = SliverGeometry( scrollExtent: height, paintExtent: paintExtent, maxPaintExtent: height, ); } } class BigSliver extends LeafRenderObjectWidget { const BigSliver({ super.key, required this.height }); final double height; @override RenderBigSliver createRenderObject(BuildContext context) { return RenderBigSliver(height); } @override void updateRenderObject(BuildContext context, RenderBigSliver renderObject) { renderObject.height = height; } }