diff --git a/packages/flutter/lib/src/material/app_bar.dart b/packages/flutter/lib/src/material/app_bar.dart index d4d9c3e23fd..712245228da 100644 --- a/packages/flutter/lib/src/material/app_bar.dart +++ b/packages/flutter/lib/src/material/app_bar.dart @@ -817,12 +817,18 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { @required this.topPadding, @required this.floating, @required this.pinned, + @required this.vsync, @required this.snapConfiguration, @required this.stretchConfiguration, + @required this.showOnScreenConfiguration, @required this.shape, @required this.toolbarHeight, @required this.leadingWidth, }) : assert(primary || topPadding == 0.0), + assert( + !floating || (snapConfiguration == null && showOnScreenConfiguration == null) || vsync != null, + 'vsync cannot be null when snapConfiguration or showOnScreenConfiguration is not null, and floating is true', + ), _bottomHeight = bottom?.preferredSize?.height ?? 0.0; final Widget leading; @@ -860,12 +866,18 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { @override double get maxExtent => math.max(topPadding + (expandedHeight ?? (toolbarHeight ?? kToolbarHeight) + _bottomHeight), minExtent); + @override + final TickerProvider vsync; + @override final FloatingHeaderSnapConfiguration snapConfiguration; @override final OverScrollHeaderStretchConfiguration stretchConfiguration; + @override + final PersistentHeaderShowOnScreenConfiguration showOnScreenConfiguration; + @override Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { final double visibleMainHeight = maxExtent - shrinkOffset - topPadding; @@ -935,8 +947,10 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { || topPadding != oldDelegate.topPadding || pinned != oldDelegate.pinned || floating != oldDelegate.floating + || vsync != oldDelegate.vsync || snapConfiguration != oldDelegate.snapConfiguration || stretchConfiguration != oldDelegate.stretchConfiguration + || showOnScreenConfiguration != oldDelegate.showOnScreenConfiguration || forceElevated != oldDelegate.forceElevated || toolbarHeight != oldDelegate.toolbarHeight || leadingWidth != leadingWidth; @@ -1325,9 +1339,14 @@ class SliverAppBar extends StatefulWidget { /// into view. /// /// If [snap] is true then a scroll that exposes the floating app bar will - /// trigger an animation that slides the entire app bar into view. Similarly if - /// a scroll dismisses the app bar, the animation will slide the app bar - /// completely out of view. + /// trigger an animation that slides the entire app bar into view. Similarly + /// if a scroll dismisses the app bar, the animation will slide the app bar + /// completely out of view. Additionally, setting [snap] to true will fully + /// expand the floating app bar when the framework tries to reveal the + /// contents of the app bar by calling [RenderObject.showOnScreen]. For + /// example, when a [TextField] in the floating app bar gains focus, if [snap] + /// is true, the framework will always fully expand the floating app bar, in + /// order to reveal the focused [TextField]. /// /// Snapping only applies when the app bar is floating, not when the app bar /// appears at the top of its scroll view. @@ -1382,17 +1401,21 @@ class SliverAppBar extends StatefulWidget { class _SliverAppBarState extends State with TickerProviderStateMixin { FloatingHeaderSnapConfiguration _snapConfiguration; OverScrollHeaderStretchConfiguration _stretchConfiguration; + PersistentHeaderShowOnScreenConfiguration _showOnScreenConfiguration; void _updateSnapConfiguration() { if (widget.snap && widget.floating) { _snapConfiguration = FloatingHeaderSnapConfiguration( - vsync: this, curve: Curves.easeOut, duration: const Duration(milliseconds: 200), ); } else { _snapConfiguration = null; } + + _showOnScreenConfiguration = widget.floating & widget.snap + ? const PersistentHeaderShowOnScreenConfiguration(minShowOnScreenExtent: double.infinity) + : null; } void _updateStretchConfiguration() { @@ -1438,6 +1461,7 @@ class _SliverAppBarState extends State with TickerProviderStateMix floating: widget.floating, pinned: widget.pinned, delegate: _SliverAppBarDelegate( + vsync: this, leading: widget.leading, automaticallyImplyLeading: widget.automaticallyImplyLeading, title: widget.title, @@ -1464,6 +1488,7 @@ class _SliverAppBarState extends State with TickerProviderStateMix shape: widget.shape, snapConfiguration: _snapConfiguration, stretchConfiguration: _stretchConfiguration, + showOnScreenConfiguration: _showOnScreenConfiguration, toolbarHeight: widget.toolbarHeight, leadingWidth: widget.leadingWidth, ), diff --git a/packages/flutter/lib/src/rendering/sliver_persistent_header.dart b/packages/flutter/lib/src/rendering/sliver_persistent_header.dart index b88c7d66ca5..a3fd0e84a9f 100644 --- a/packages/flutter/lib/src/rendering/sliver_persistent_header.dart +++ b/packages/flutter/lib/src/rendering/sliver_persistent_header.dart @@ -19,6 +19,25 @@ import 'sliver.dart'; import 'viewport.dart'; import 'viewport_offset.dart'; +// Trims the specified edges of the given `Rect` [original], so that they do not +// exceed the given values. +Rect _trim(Rect original, { + double top = -double.infinity, + double right = double.infinity, + double bottom = double.infinity, + double left = -double.infinity, +}) { + if (original == null) + return null; + + return Rect.fromLTRB( + math.max(original.left, left), + math.max(original.top, top), + math.min(original.right, right), + math.min(original.bottom, bottom), + ); +} + /// Specifies how a stretched header is to trigger an [AsyncCallback]. /// /// See also: @@ -41,6 +60,60 @@ class OverScrollHeaderStretchConfiguration { final AsyncCallback onStretchTrigger; } +/// {@template flutter.rendering.persistentHeader.showOnScreenConfiguration} +/// Specifies how a pinned header or a floating header should react to +/// [RenderObject.showOnScreen] calls. +/// {@endtemplate} +@immutable +class PersistentHeaderShowOnScreenConfiguration { + /// Creates an object that specifies how a pinned or floating persistent header + /// should behave in response to [RenderObject.showOnScreen] calls. + const PersistentHeaderShowOnScreenConfiguration({ + this.minShowOnScreenExtent, + this.maxShowOnScreenExtent, + }) : assert(minShowOnScreenExtent == null || maxShowOnScreenExtent == null || minShowOnScreenExtent <= maxShowOnScreenExtent); + + /// The smallest the floating header can expand to in the main axis direction, + /// in response to a [RenderObject.showOnScreen] call, in addition to its + /// [RenderSliverPersistentHeader.minExtent]. + /// + /// When a floating persistent header is told to show a [Rect] on screen, it + /// may expand itself to accomodate the [Rect]. The minimum extent that is + /// allowed for such expansion is either + /// [RenderSliverPersistentHeader.minExtent] or [minShowOnScreenExtent], + /// whichever is larger. If the persistent header's current extent is already + /// larger than that maximum extent, it will remain unchanged. + /// + /// This parameter can be set to the persistent header's `maxExtent` (or + /// `double.infinity`) so the persistent header will always try to expand when + /// [RenderObject.showOnScreen] is called on it. + /// + /// Defaults to null, in which case no additional constraints are applied. + /// Must be less than or equal to [maxShowOnScreenExtent] if it is also not + /// null. Has no effect unless the persistent header is a floating header. + final double minShowOnScreenExtent; + + /// The biggest the floating header can expand to in the main axis direction, + /// in response to a [RenderObject.showOnScreen] call, in addition to its + /// [RenderSliverPersistentHeader.maxExtent]. + /// + /// When a floating persistent header is told to show a [Rect] on screen, it + /// may expand itself to accomodate the [Rect]. The maximum extent that is + /// allowed for such expansion is either + /// [RenderSliverPersistentHeader.maxExtent] or [maxShowOnScreenExtent], + /// whichever is smaller. If the persistent header's current extent is already + /// larger than that maximum extent, it will remain unchanged. + /// + /// This parameter can be set to the persistent header's `minExtent` (or + /// `double.negativeInfinity`) so the persistent header will never try to + /// expand when [RenderObject.showOnScreen] is called on it. + /// + /// Defaults to null, in which case no additional constraints are applied. + /// Must be greater than or equal to [minShowOnScreenExtent] if it is also not + /// null. Has no effect unless the persistent header is a floating header. + final double maxShowOnScreenExtent; +} + /// A base class for slivers that have a [RenderBox] child which scrolls /// normally, except that when it hits the leading edge (typically the top) of /// the viewport, it shrinks to a minimum size ([minExtent]). @@ -348,11 +421,18 @@ abstract class RenderSliverPinnedPersistentHeader extends RenderSliverPersistent RenderSliverPinnedPersistentHeader({ RenderBox child, OverScrollHeaderStretchConfiguration stretchConfiguration, + this.showOnScreenConfiguration = const PersistentHeaderShowOnScreenConfiguration(), }) : super( child: child, stretchConfiguration: stretchConfiguration, ); + /// Specifies the persistent header's behavior when `showOnScreen` is called. + /// + /// If set to null, the persistent header will delegate the `showOnScreen` call + /// to it's parent [RenderObject]. + PersistentHeaderShowOnScreenConfiguration showOnScreenConfiguration; + @override void performLayout() { final SliverConstraints constraints = this.constraints; @@ -378,6 +458,41 @@ abstract class RenderSliverPinnedPersistentHeader extends RenderSliverPersistent @override double childMainAxisPosition(RenderBox child) => 0.0; + + @override + void showOnScreen({ + RenderObject descendant, + Rect rect, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + }) { + final Rect localBounds = descendant != null + ? MatrixUtils.transformRect(descendant.getTransformTo(this), rect ?? descendant.paintBounds) + : rect; + + Rect newRect; + switch (applyGrowthDirectionToAxisDirection(constraints.axisDirection, constraints.growthDirection)) { + case AxisDirection.up: + newRect = _trim(localBounds, bottom: childExtent); + break; + case AxisDirection.right: + newRect = _trim(localBounds, left: 0); + break; + case AxisDirection.down: + newRect = _trim(localBounds, top: 0); + break; + case AxisDirection.left: + newRect = _trim(localBounds, right: childExtent); + break; + } + + super.showOnScreen( + descendant: this, + rect: newRect, + duration: duration, + curve: curve, + ); + } } /// Specifies how a floating header is to be "snapped" (animated) into or out @@ -394,15 +509,22 @@ class FloatingHeaderSnapConfiguration { /// Creates an object that specifies how a floating header is to be "snapped" /// (animated) into or out of view. FloatingHeaderSnapConfiguration({ - @required this.vsync, + @Deprecated( + 'Specify SliverPersistentHeaderDelegate.vsync instead. ' + 'This feature was deprecated after v1.19.0.' + ) + this.vsync, this.curve = Curves.ease, this.duration = const Duration(milliseconds: 300), - }) : assert(vsync != null), - assert(curve != null), + }) : assert(curve != null), assert(duration != null); - /// The [TickerProvider] for the [AnimationController] that causes a - /// floating header to snap in or out of view. + /// The [TickerProvider] for the [AnimationController] that causes a floating + /// header to snap in or out of view. + @Deprecated( + 'Specify SliverPersistentHeaderDelegate.vsync instead. ' + 'This feature was deprecated after v1.19.0.' + ) final TickerProvider vsync; /// The snap animation curve. @@ -426,13 +548,15 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste /// direction. RenderSliverFloatingPersistentHeader({ RenderBox child, - FloatingHeaderSnapConfiguration snapConfiguration, + @required TickerProvider vsync, + this.snapConfiguration, OverScrollHeaderStretchConfiguration stretchConfiguration, - }) : _snapConfiguration = snapConfiguration, + @required this.showOnScreenConfiguration, + }) : _vsync = vsync, super( - child: child, - stretchConfiguration: stretchConfiguration, - ); + child: child, + stretchConfiguration: stretchConfiguration, + ); AnimationController _controller; Animation _animation; @@ -450,6 +574,22 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste super.detach(); } + + /// A [TickerProvider] to use when animating the scroll position. + TickerProvider get vsync => _vsync; + TickerProvider _vsync; + set vsync(TickerProvider value) { + if (value == _vsync) + return; + _vsync = value; + if (value == null) { + _controller?.dispose(); + _controller = null; + } else { + _controller?.resync(_vsync); + } + } + /// Defines the parameters used to snap (animate) the floating header in and /// out of view. /// @@ -462,20 +602,13 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste /// start or stop the floating header's animation. /// * [SliverAppBar], which creates a header that can be pinned, floating, /// and snapped into view via the corresponding parameters. - FloatingHeaderSnapConfiguration get snapConfiguration => _snapConfiguration; - FloatingHeaderSnapConfiguration _snapConfiguration; - set snapConfiguration(FloatingHeaderSnapConfiguration value) { - if (value == _snapConfiguration) - return; - if (value == null) { - _controller?.dispose(); - _controller = null; - } else { - if (_snapConfiguration != null && value.vsync != _snapConfiguration.vsync) - _controller?.resync(value.vsync); - } - _snapConfiguration = value; - } + FloatingHeaderSnapConfiguration snapConfiguration; + + /// {@macro flutter.rendering.persistentHeader.showOnScreenConfiguration} + /// + /// If set to null, the persistent header will delegate the `showOnScreen` call + /// to it's parent [RenderObject]. + PersistentHeaderShowOnScreenConfiguration showOnScreenConfiguration; /// Updates [geometry], and returns the new value for [childMainAxisPosition]. /// @@ -500,6 +633,31 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste return stretchOffset > 0 ? 0.0 : math.min(0.0, paintExtent - childExtent); } + void _updateAnimation(Duration duration, double endValue, Curve curve) { + assert(duration != null); + assert(endValue != null); + assert(curve != null); + if (_controller == null) { + assert( + vsync != null, + 'vsync must not be null if the floating header changes size animatedly.', + ); + _controller = AnimationController(vsync: vsync, duration: duration) + ..addListener(() { + if (_effectiveScrollOffset == _animation.value) + return; + _effectiveScrollOffset = _animation.value; + markNeedsLayout(); + }); + } + _animation = _controller.drive( + Tween( + begin: _effectiveScrollOffset, + end: endValue, + ).chain(CurveTween(curve: curve)), + ); + } + /// If the header isn't already fully exposed, then scroll it into view. void maybeStartSnapAnimation(ScrollDirection direction) { if (snapConfiguration == null) @@ -509,29 +667,16 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste if (direction == ScrollDirection.reverse && _effectiveScrollOffset >= maxExtent) return; - final TickerProvider vsync = snapConfiguration.vsync; - final Duration duration = snapConfiguration.duration; - _controller ??= AnimationController(vsync: vsync, duration: duration) - ..addListener(() { - if (_effectiveScrollOffset == _animation.value) - return; - _effectiveScrollOffset = _animation.value; - markNeedsLayout(); - }); - - _animation = _controller.drive( - Tween( - begin: _effectiveScrollOffset, - end: direction == ScrollDirection.forward ? 0.0 : maxExtent, - ).chain(CurveTween( - curve: snapConfiguration.curve, - )), + _updateAnimation( + snapConfiguration.duration, + direction == ScrollDirection.forward ? 0.0 : maxExtent, + snapConfiguration.curve, ); - _controller.forward(from: 0.0); } - /// If a header snap animation is underway then stop it. + /// If a header snap animation or a [showOnScreen] expand animation is underway + /// then stop it. void maybeStopSnapAnimation(ScrollDirection direction) { _controller?.stop(); } @@ -568,6 +713,78 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste _lastActualScrollOffset = constraints.scrollOffset; } + @override + void showOnScreen({ + RenderObject descendant, + Rect rect, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + }) { + if (showOnScreenConfiguration == null) + return super.showOnScreen(descendant: descendant, rect: rect, duration: duration, curve: curve); + + assert(child != null || descendant == null); + // We prefer the child's coordinate space (instead of the sliver's) because + // it's easier for us to convert the target rect into target extents: when + // the sliver is sitting above the leading edge (not possible with pinned + // headers), the leading edge of the sliver and the leading edge of the child + // will not be aligned. The only exception is when child is null (and thus + // descendant == null). + final Rect childBounds = descendant != null + ? MatrixUtils.transformRect(descendant.getTransformTo(child), rect ?? descendant.paintBounds) + : rect; + + double targetExtent; + Rect targetRect; + switch (applyGrowthDirectionToAxisDirection(constraints.axisDirection, constraints.growthDirection)) { + case AxisDirection.up: + targetExtent = childExtent - (childBounds?.top ?? 0); + targetRect = _trim(childBounds, bottom: childExtent); + break; + case AxisDirection.right: + targetExtent = childBounds?.right ?? childExtent; + targetRect = _trim(childBounds, left: 0); + break; + case AxisDirection.down: + targetExtent = childBounds?.bottom ?? childExtent; + targetRect = _trim(childBounds, top: 0); + break; + case AxisDirection.left: + targetExtent = childExtent - (childBounds?.left ?? 0); + targetRect = _trim(childBounds, right: childExtent); + break; + } + + // A stretch header can have a bigger childExtent than maxExtent. + final double effectiveMaxExtent = math.max(childExtent, maxExtent); + + targetExtent = targetExtent.clamp( + showOnScreenConfiguration.minShowOnScreenExtent ?? double.negativeInfinity, + showOnScreenConfiguration.maxShowOnScreenExtent ?? double.infinity, + ) + // Clamp the value back to the valid range after applying additional + // constriants. Contracting is not allowed. + .clamp(childExtent, effectiveMaxExtent) as double; + + // Expands the header if needed, with animation. + if (targetExtent > childExtent) { + final double targetScrollOffset = maxExtent - targetExtent; + assert( + vsync != null, + 'vsync must not be null if the floating header changes size animatedly.', + ); + _updateAnimation(duration, targetScrollOffset, curve); + _controller.forward(from: 0.0); + } + + super.showOnScreen( + descendant: descendant == null ? this : child, + rect: targetRect, + duration: duration, + curve: curve, + ); + } + @override double childMainAxisPosition(RenderBox child) { assert(child == this.child); @@ -595,12 +812,16 @@ abstract class RenderSliverFloatingPinnedPersistentHeader extends RenderSliverFl /// scroll direction. RenderSliverFloatingPinnedPersistentHeader({ RenderBox child, + @required TickerProvider vsync, FloatingHeaderSnapConfiguration snapConfiguration, OverScrollHeaderStretchConfiguration stretchConfiguration, + @required PersistentHeaderShowOnScreenConfiguration showOnScreenConfiguration, }) : super( child: child, + vsync: vsync, snapConfiguration: snapConfiguration, stretchConfiguration: stretchConfiguration, + showOnScreenConfiguration: showOnScreenConfiguration, ); @override diff --git a/packages/flutter/lib/src/rendering/viewport.dart b/packages/flutter/lib/src/rendering/viewport.dart index 176ffd287ec..540ed491132 100644 --- a/packages/flutter/lib/src/rendering/viewport.dart +++ b/packages/flutter/lib/src/rendering/viewport.dart @@ -727,58 +727,26 @@ abstract class RenderViewportBase 0 + && leadingScrollOffset >= 0; + final double extentOfPinnedSlivers = maxScrollObstructionExtentBefore(sliver); + // The additional scroll offset needed to move the leading edge of the + // `target` to align with the leading edge of the viewport. leadingScrollOffset = scrollOffsetOf(sliver, leadingScrollOffset); + switch (sliver.constraints.growthDirection) { case GrowthDirection.forward: + if (isPinned && alignment <= 0) + return RevealedOffset(offset: double.infinity, rect: targetRect); leadingScrollOffset -= extentOfPinnedSlivers; break; case GrowthDirection.reverse: - // Nothing to do. + if (isPinned && alignment >= 1) + return RevealedOffset(offset: double.negativeInfinity, rect: targetRect); + switch (axis) { + case Axis.vertical: + leadingScrollOffset -= targetRect.height; + break; + case Axis.horizontal: + leadingScrollOffset -= targetRect.width; + break; + } break; } @@ -816,9 +814,6 @@ abstract class RenderViewportBase null; + /// Specifies how floating headers should animate in and out of view. /// /// If the value of this property is null, then floating headers will @@ -81,6 +88,12 @@ abstract class SliverPersistentHeaderDelegate { /// Defaults to null. OverScrollHeaderStretchConfiguration get stretchConfiguration => null; + /// Specifies how floating headers and pinned pinned headers should behave in + /// response to [RenderObject.showOnScreen] calls. + /// + /// Defaults to null. + PersistentHeaderShowOnScreenConfiguration get showOnScreenConfiguration => null; + /// Whether this delegate is meaningfully different from the old delegate. /// /// If this returns false, then the header might not be rebuilt, even though @@ -346,7 +359,8 @@ class _SliverPinnedPersistentHeader extends _SliverPersistentHeaderRenderObjectW @override _RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context) { return _RenderSliverPinnedPersistentHeaderForWidgets( - stretchConfiguration: delegate.stretchConfiguration + stretchConfiguration: delegate.stretchConfiguration, + showOnScreenConfiguration: delegate.showOnScreenConfiguration, ); } } @@ -356,9 +370,11 @@ class _RenderSliverPinnedPersistentHeaderForWidgets extends RenderSliverPinnedPe _RenderSliverPinnedPersistentHeaderForWidgets({ RenderBox child, OverScrollHeaderStretchConfiguration stretchConfiguration, + PersistentHeaderShowOnScreenConfiguration showOnScreenConfiguration, }) : super( child: child, stretchConfiguration: stretchConfiguration, + showOnScreenConfiguration: showOnScreenConfiguration, ); } @@ -374,15 +390,19 @@ class _SliverFloatingPersistentHeader extends _SliverPersistentHeaderRenderObjec @override _RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context) { return _RenderSliverFloatingPersistentHeaderForWidgets( + vsync: delegate.vsync, snapConfiguration: delegate.snapConfiguration, stretchConfiguration: delegate.stretchConfiguration, + showOnScreenConfiguration: delegate.showOnScreenConfiguration, ); } @override void updateRenderObject(BuildContext context, _RenderSliverFloatingPersistentHeaderForWidgets renderObject) { + renderObject.vsync = delegate.vsync; renderObject.snapConfiguration = delegate.snapConfiguration; renderObject.stretchConfiguration = delegate.stretchConfiguration; + renderObject.showOnScreenConfiguration = delegate.showOnScreenConfiguration; } } @@ -390,12 +410,16 @@ class _RenderSliverFloatingPinnedPersistentHeaderForWidgets extends RenderSliver with _RenderSliverPersistentHeaderForWidgetsMixin { _RenderSliverFloatingPinnedPersistentHeaderForWidgets({ RenderBox child, + @required TickerProvider vsync, FloatingHeaderSnapConfiguration snapConfiguration, OverScrollHeaderStretchConfiguration stretchConfiguration, + PersistentHeaderShowOnScreenConfiguration showOnScreenConfiguration, }) : super( child: child, + vsync: vsync, snapConfiguration: snapConfiguration, stretchConfiguration: stretchConfiguration, + showOnScreenConfiguration: showOnScreenConfiguration, ); } @@ -411,15 +435,19 @@ class _SliverFloatingPinnedPersistentHeader extends _SliverPersistentHeaderRende @override _RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context) { return _RenderSliverFloatingPinnedPersistentHeaderForWidgets( + vsync: delegate.vsync, snapConfiguration: delegate.snapConfiguration, stretchConfiguration: delegate.stretchConfiguration, + showOnScreenConfiguration: delegate.showOnScreenConfiguration, ); } @override void updateRenderObject(BuildContext context, _RenderSliverFloatingPinnedPersistentHeaderForWidgets renderObject) { + renderObject.vsync = delegate.vsync; renderObject.snapConfiguration = delegate.snapConfiguration; renderObject.stretchConfiguration = delegate.stretchConfiguration; + renderObject.showOnScreenConfiguration = delegate.showOnScreenConfiguration; } } @@ -427,11 +455,15 @@ class _RenderSliverFloatingPersistentHeaderForWidgets extends RenderSliverFloati with _RenderSliverPersistentHeaderForWidgetsMixin { _RenderSliverFloatingPersistentHeaderForWidgets({ RenderBox child, + @required TickerProvider vsync, FloatingHeaderSnapConfiguration snapConfiguration, OverScrollHeaderStretchConfiguration stretchConfiguration, + PersistentHeaderShowOnScreenConfiguration showOnScreenConfiguration, }) : super( child: child, + vsync: vsync, snapConfiguration: snapConfiguration, stretchConfiguration: stretchConfiguration, + showOnScreenConfiguration: showOnScreenConfiguration, ); } diff --git a/packages/flutter/test/material/app_bar_test.dart b/packages/flutter/test/material/app_bar_test.dart index 93938a43a3a..1f8e4cc7d9c 100644 --- a/packages/flutter/test/material/app_bar_test.dart +++ b/packages/flutter/test/material/app_bar_test.dart @@ -1975,6 +1975,41 @@ void main() { expect(tester.getCenter(appBarTitle).dy, tester.getCenter(toolbar).dy); }); + testWidgets('SliverAppBar configures the delegate properly', (WidgetTester tester) async { + Future buildAndVerifyDelegate({ bool pinned, bool floating, bool snap }) async { + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + slivers: [ + SliverAppBar( + title: const Text('Jumbo'), + pinned: pinned, + floating: floating, + snap: snap, + ), + ], + ), + ), + ); + + final SliverPersistentHeaderDelegate delegate = tester + .widget(find.byType(SliverPersistentHeader)) + .delegate; + + // Ensure we have a non-null vsync when it's needed. + if (!floating || (delegate.snapConfiguration == null && delegate.showOnScreenConfiguration == null)) + expect(delegate.vsync, isNotNull); + + expect(delegate.showOnScreenConfiguration != null, snap && floating); + } + + await buildAndVerifyDelegate(pinned: false, floating: true, snap: false); + await buildAndVerifyDelegate(pinned: false, floating: true, snap: true); + + await buildAndVerifyDelegate(pinned: true, floating: true, snap: false); + await buildAndVerifyDelegate(pinned: true, floating: true, snap: true); + }); + testWidgets('AppBar respects toolbarHeight', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( diff --git a/packages/flutter/test/rendering/sliver_persistent_header_test.dart b/packages/flutter/test/rendering/sliver_persistent_header_test.dart index ab18603c350..bafe3b0dd5f 100644 --- a/packages/flutter/test/rendering/sliver_persistent_header_test.dart +++ b/packages/flutter/test/rendering/sliver_persistent_header_test.dart @@ -49,7 +49,7 @@ void main() { class TestRenderSliverFloatingPersistentHeader extends RenderSliverFloatingPersistentHeader { TestRenderSliverFloatingPersistentHeader({ RenderBox child, - }) : super(child: child); + }) : super(child: child, vsync: null, showOnScreenConfiguration: null); @override double get maxExtent => 200; @@ -61,7 +61,7 @@ class TestRenderSliverFloatingPersistentHeader extends RenderSliverFloatingPersi class TestRenderSliverFloatingPinnedPersistentHeader extends RenderSliverFloatingPinnedPersistentHeader { TestRenderSliverFloatingPinnedPersistentHeader({ RenderBox child, - }) : super(child: child); + }) : super(child: child, vsync: null, showOnScreenConfiguration: null); @override double get maxExtent => 200; diff --git a/packages/flutter/test/rendering/viewport_test.dart b/packages/flutter/test/rendering/viewport_test.dart index cea0dc64a6d..2e876eef12a 100644 --- a/packages/flutter/test/rendering/viewport_test.dart +++ b/packages/flutter/test/rendering/viewport_test.dart @@ -16,6 +16,38 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; +class _TestSliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate { + _TestSliverPersistentHeaderDelegate({ + this.key, + this.minExtent, + this.maxExtent, + this.child, + this.vsync = const TestVSync(), + this.showOnScreenConfiguration = const PersistentHeaderShowOnScreenConfiguration(), + }); + + final Key key; + final Widget child; + + @override + final double maxExtent; + + @override + final double minExtent; + + @override + final TickerProvider vsync; + + @override + final PersistentHeaderShowOnScreenConfiguration showOnScreenConfiguration; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child ?? SizedBox.expand(key: key); + + @override + bool shouldRebuild(_TestSliverPersistentHeaderDelegate oldDelegate) => true; +} + void main() { testWidgets('Viewport getOffsetToReveal - down', (WidgetTester tester) async { List children; @@ -220,7 +252,7 @@ void main() { ); children.add(sliver); return SliverPadding( - padding: const EdgeInsets.all(22.0), + padding: const EdgeInsets.only(top: 22.0, bottom: 23.0), sliver: sliver, ); }), @@ -234,10 +266,10 @@ void main() { final RenderObject target = tester.renderObject(find.byWidget(children[5], skipOffstage: false)); RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); - expect(revealed.offset, 5 * (100 + 22 + 22) + 22); + expect(revealed.offset, 5 * (100 + 22 + 23) + 22); revealed = viewport.getOffsetToReveal(target, 1.0); - expect(revealed.offset, 5 * (100 + 22 + 22) + 22 - 100); + expect(revealed.offset, 5 * (100 + 22 + 23) + 22 - 100); }); testWidgets('Viewport getOffsetToReveal Sliver - right', (WidgetTester tester) async { @@ -261,7 +293,7 @@ void main() { ); children.add(sliver); return SliverPadding( - padding: const EdgeInsets.all(22.0), + padding: const EdgeInsets.only(left: 22.0, right: 23.0), sliver: sliver, ); }), @@ -275,10 +307,10 @@ void main() { final RenderObject target = tester.renderObject(find.byWidget(children[5], skipOffstage: false)); RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); - expect(revealed.offset, 5 * (100 + 22 + 22) + 22); + expect(revealed.offset, 5 * (100 + 22 + 23) + 22); revealed = viewport.getOffsetToReveal(target, 1.0); - expect(revealed.offset, 5 * (100 + 22 + 22) + 22 - 100); + expect(revealed.offset, 5 * (100 + 22 + 23) + 22 - 100); }); testWidgets('Viewport getOffsetToReveal Sliver - up', (WidgetTester tester) async { @@ -302,7 +334,7 @@ void main() { ); children.add(sliver); return SliverPadding( - padding: const EdgeInsets.all(22.0), + padding: const EdgeInsets.only(top: 22.0, bottom: 23.0), sliver: sliver, ); }), @@ -316,17 +348,19 @@ void main() { final RenderObject target = tester.renderObject(find.byWidget(children[5], skipOffstage: false)); RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); - expect(revealed.offset, 5 * (100 + 22 + 22) + 22); + // Does not include the bottom padding of children[5] thus + 23 instead of + 22. + expect(revealed.offset, 5 * (100 + 22 + 23) + 23); revealed = viewport.getOffsetToReveal(target, 1.0); - expect(revealed.offset, 5 * (100 + 22 + 22) + 22 - 100); + expect(revealed.offset, 5 * (100 + 22 + 23) + 23 - 100); }); testWidgets('Viewport getOffsetToReveal Sliver - up - reverse growth', (WidgetTester tester) async { const Key centerKey = ValueKey('center'); + const EdgeInsets padding = EdgeInsets.only(top: 22.0, bottom: 23.0); final Widget centerSliver = SliverPadding( key: centerKey, - padding: const EdgeInsets.all(22.0), + padding: padding, sliver: SliverToBoxAdapter( child: Container( height: 100.0, @@ -339,7 +373,7 @@ void main() { child: const Text('Tile lower'), ); final Widget lowerSliver = SliverPadding( - padding: const EdgeInsets.all(22.0), + padding: padding, sliver: SliverToBoxAdapter( child: lowerItem, ), @@ -374,9 +408,10 @@ void main() { testWidgets('Viewport getOffsetToReveal Sliver - left - reverse growth', (WidgetTester tester) async { const Key centerKey = ValueKey('center'); + const EdgeInsets padding = EdgeInsets.only(left: 22.0, right: 23.0); final Widget centerSliver = SliverPadding( key: centerKey, - padding: const EdgeInsets.all(22.0), + padding: padding, sliver: SliverToBoxAdapter( child: Container( width: 100.0, @@ -389,7 +424,7 @@ void main() { child: const Text('Tile lower'), ); final Widget lowerSliver = SliverPadding( - padding: const EdgeInsets.all(22.0), + padding: padding, sliver: SliverToBoxAdapter( child: lowerItem, ), @@ -445,7 +480,7 @@ void main() { ); children.add(sliver); return SliverPadding( - padding: const EdgeInsets.all(22.0), + padding: const EdgeInsets.only(left: 22.0, right: 23.0), sliver: sliver, ); }), @@ -459,10 +494,10 @@ void main() { final RenderObject target = tester.renderObject(find.byWidget(children[5], skipOffstage: false)); RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); - expect(revealed.offset, 5 * (100 + 22 + 22) + 22); + expect(revealed.offset, 5 * (100 + 22 + 23) + 23); revealed = viewport.getOffsetToReveal(target, 1.0); - expect(revealed.offset, 5 * (100 + 22 + 22) + 22 - 100); + expect(revealed.offset, 5 * (100 + 22 + 23) + 23 - 100); }); testWidgets('Nested Viewports showOnScreen', (WidgetTester tester) async { @@ -988,6 +1023,488 @@ void main() { expect(controller.offset, 300.0); }); + testWidgets( + 'Viewport showOnScreen should not scroll if the rect is already visible, even if it does not scroll linearly', + (WidgetTester tester) async { + List children; + ScrollController controller; + + const Key headerKey = Key('header'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Container( + height: 600.0, + child: CustomScrollView( + controller: controller = ScrollController(initialScrollOffset: 300.0), + slivers: children = List.generate(20, (int i) { + return i == 10 + ? SliverPersistentHeader( + pinned: true, + floating: false, + delegate: _TestSliverPersistentHeaderDelegate( + minExtent: 100, + maxExtent: 300, + key: headerKey, + ), + ) + : SliverToBoxAdapter( + child: Container( + height: 300.0, + child: Text('Tile $i'), + ), + ); + }), + ), + ), + ), + ), + ); + + controller.jumpTo(300.0 * 15); + await tester.pumpAndSettle(); + + final Finder pinnedHeaderContent = find.descendant( + of: find.byWidget(children[10]), + matching: find.byKey(headerKey), + ); + + // The persistent header is pinned to the leading edge thus still visible, + // the viewport should not scroll. + tester.renderObject(pinnedHeaderContent).showOnScreen(); + await tester.pumpAndSettle(); + expect(controller.offset, 300.0 * 15); + + // The 11th child will be partially obstructed by the persistent header, + // the viewport should scroll to reveal it. + controller.jumpTo( + 11 * 300.0 // Preceding headers + + 200.0 // Shrinks the pinned header to minExtent + + 100.0 // Obstructs the leading 100 pixels of the 11th header + ); + await tester.pumpAndSettle(); + + tester.renderObject(find.byWidget(children[11], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controller.offset, lessThan(11 * 300.0 + 200.0 + 100.0)); + }); + + void testFloatingHeaderShowOnScreen({ bool animated = true, Axis axis = Axis.vertical }) { + final TickerProvider vsync = animated ? const TestVSync() : null; + const Key headerKey = Key('header'); + List children; + ScrollController controller; + + Widget buildList({ SliverPersistentHeader floatingHeader, bool reversed = false }) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Container( + height: 400.0, + width: 400.0, + child: CustomScrollView( + scrollDirection: axis, + center: reversed ? const Key('19') : null, + controller: controller = ScrollController(initialScrollOffset: 300.0), + slivers: children = List.generate(20, (int i) { + return i == 10 + ? floatingHeader + : SliverToBoxAdapter( + key: (i == 19) ? const Key('19') : null, + child: Container( + height: 300.0, + width: 300, + child: Text('Tile $i'), + ), + ); + }), + ), + ), + ), + ); + } + + double mainAxisExtent(WidgetTester tester, Finder finder) { + final RenderObject renderObject = tester.renderObject(finder); + if (renderObject is RenderSliver) { + return renderObject.geometry.paintExtent; + } + + final RenderBox renderBox = renderObject as RenderBox; + switch (axis) { + case Axis.horizontal: + return renderBox.size.width; + case Axis.vertical: + return renderBox.size.height; + } + assert(false); + return null; + } + + group('animated: $animated, scrollDirection: $axis', () { + testWidgets( + 'RenderViewportBase.showOnScreen', + (WidgetTester tester) async { + await tester.pumpWidget( + buildList( + floatingHeader: SliverPersistentHeader( + pinned: true, + floating: true, + delegate: _TestSliverPersistentHeaderDelegate(minExtent: 100, maxExtent: 300, key: headerKey, vsync: vsync), + ), + ) + ); + + final Finder pinnedHeaderContent = find.byKey(headerKey, skipOffstage: false); + + controller.jumpTo(300.0 * 15); + await tester.pumpAndSettle(); + expect(mainAxisExtent(tester, pinnedHeaderContent), lessThan(300)); + + // The persistent header is pinned to the leading edge thus still visible, + // the viewport should not scroll. + tester.renderObject(pinnedHeaderContent).showOnScreen( + descendant: tester.renderObject(pinnedHeaderContent), + rect: Offset.zero & const Size(300, 300), + ); + await tester.pumpAndSettle(); + // The header expands but doesn't move. + expect(controller.offset, 300.0 * 15); + expect(mainAxisExtent(tester, pinnedHeaderContent), 300); + + // The rect specifies that the persistent header needs to be 1 pixel away + // from the leading edge of the viewport. Ignore the 1 pixel, the viewport + // should not scroll. + // + // See: https://github.com/flutter/flutter/issues/25507. + tester.renderObject(pinnedHeaderContent).showOnScreen( + descendant: tester.renderObject(pinnedHeaderContent), + rect: const Offset(-1, -1) & const Size(300, 300), + ); + await tester.pumpAndSettle(); + expect(controller.offset, 300.0 * 15); + expect(mainAxisExtent(tester, pinnedHeaderContent), 300); + }); + + testWidgets( + 'RenderViewportBase.showOnScreen but no child', + (WidgetTester tester) async { + await tester.pumpWidget( + buildList( + floatingHeader: SliverPersistentHeader( + key: headerKey, + pinned: true, + floating: true, + delegate: _TestSliverPersistentHeaderDelegate(minExtent: 100, maxExtent: 300, child: null, vsync: vsync), + ), + ) + ); + + final Finder pinnedHeaderContent = find.byKey(headerKey, skipOffstage: false); + + controller.jumpTo(300.0 * 15); + await tester.pumpAndSettle(); + expect(mainAxisExtent(tester, pinnedHeaderContent), lessThan(300)); + + // The persistent header is pinned to the leading edge thus still visible, + // the viewport should not scroll. + tester.renderObject(pinnedHeaderContent).showOnScreen( + rect: Offset.zero & const Size(300, 300), + ); + await tester.pumpAndSettle(); + // The header expands but doesn't move. + expect(controller.offset, 300.0 * 15); + expect(mainAxisExtent(tester, pinnedHeaderContent), 300); + + // The rect specifies that the persistent header needs to be 1 pixel away + // from the leading edge of the viewport. Ignore the 1 pixel, the viewport + // should not scroll. + // + // See: https://github.com/flutter/flutter/issues/25507. + tester.renderObject(pinnedHeaderContent).showOnScreen( + rect: const Offset(-1, -1) & const Size(300, 300), + ); + await tester.pumpAndSettle(); + expect(controller.offset, 300.0 * 15); + expect(mainAxisExtent(tester, pinnedHeaderContent), 300); + }); + + testWidgets( + 'RenderViewportBase.showOnScreen with maxShowOnScreenExtent ', + (WidgetTester tester) async { + await tester.pumpWidget( + buildList( + floatingHeader: SliverPersistentHeader( + pinned: true, + floating: true, + delegate: _TestSliverPersistentHeaderDelegate( + minExtent: 100, + maxExtent: 300, + key: headerKey, + vsync: vsync, + showOnScreenConfiguration: const PersistentHeaderShowOnScreenConfiguration(maxShowOnScreenExtent: 200), + ), + ), + ) + ); + + final Finder pinnedHeaderContent = find.byKey(headerKey, skipOffstage: false); + + controller.jumpTo(300.0 * 15); + await tester.pumpAndSettle(); + // childExtent was initially 100. + expect(mainAxisExtent(tester, pinnedHeaderContent), 100); + + tester.renderObject(pinnedHeaderContent).showOnScreen( + descendant: tester.renderObject(pinnedHeaderContent), + rect: Offset.zero & const Size(300, 300), + ); + await tester.pumpAndSettle(); + // The header doesn't move. It would have expanded to 300 but + // maxShowOnScreenExtent is 200, preventing it from doing so. + expect(controller.offset, 300.0 * 15); + expect(mainAxisExtent(tester, pinnedHeaderContent), 200); + + // ignoreLeading still works. + tester.renderObject(pinnedHeaderContent).showOnScreen( + descendant: tester.renderObject(pinnedHeaderContent), + rect: const Offset(-1, -1) & const Size(300, 300), + ); + await tester.pumpAndSettle(); + expect(controller.offset, 300.0 * 15); + expect(mainAxisExtent(tester, pinnedHeaderContent), 200); + + // Move the viewport so that its childExtent reaches 250. + controller.jumpTo(300.0 * 10 + 50.0); + await tester.pumpAndSettle(); + expect(mainAxisExtent(tester, pinnedHeaderContent), 250); + + // Doesn't move, doesn't expand or shrink, leading still ignored. + tester.renderObject(pinnedHeaderContent).showOnScreen( + descendant: tester.renderObject(pinnedHeaderContent), + rect: const Offset(-1, -1) & const Size(300, 300), + ); + await tester.pumpAndSettle(); + expect(controller.offset, 300.0 * 10 + 50.0); + expect(mainAxisExtent(tester, pinnedHeaderContent), 250); + }); + + testWidgets( + 'RenderViewportBase.showOnScreen with minShowOnScreenExtent ', + (WidgetTester tester) async { + await tester.pumpWidget( + buildList( + floatingHeader: SliverPersistentHeader( + pinned: true, + floating: true, + delegate: _TestSliverPersistentHeaderDelegate( + minExtent: 100, + maxExtent: 300, + key: headerKey, + vsync: vsync, + showOnScreenConfiguration: const PersistentHeaderShowOnScreenConfiguration(minShowOnScreenExtent: 200), + ), + ), + ) + ); + + final Finder pinnedHeaderContent = find.byKey(headerKey, skipOffstage: false); + + controller.jumpTo(300.0 * 15); + await tester.pumpAndSettle(); + // childExtent was initially 100. + expect(mainAxisExtent(tester, pinnedHeaderContent), 100); + + tester.renderObject(pinnedHeaderContent).showOnScreen( + descendant: tester.renderObject(pinnedHeaderContent), + rect: Offset.zero & const Size(110, 110), + ); + await tester.pumpAndSettle(); + // The header doesn't move. It would have expanded to 110 but + // minShowOnScreenExtent is 200, preventing it from doing so. + expect(controller.offset, 300.0 * 15); + expect(mainAxisExtent(tester, pinnedHeaderContent), 200); + + // ignoreLeading still works. + tester.renderObject(pinnedHeaderContent).showOnScreen( + descendant: tester.renderObject(pinnedHeaderContent), + rect: const Offset(-1, -1) & const Size(110, 110), + ); + await tester.pumpAndSettle(); + expect(controller.offset, 300.0 * 15); + expect(mainAxisExtent(tester, pinnedHeaderContent), 200); + + // Move the viewport so that its childExtent reaches 250. + controller.jumpTo(300.0 * 10 + 50.0); + await tester.pumpAndSettle(); + expect(mainAxisExtent(tester, pinnedHeaderContent), 250); + + // Doesn't move, doesn't expand or shrink, leading still ignored. + tester.renderObject(pinnedHeaderContent).showOnScreen( + descendant: tester.renderObject(pinnedHeaderContent), + rect: const Offset(-1, -1) & const Size(110, 110), + ); + await tester.pumpAndSettle(); + expect(controller.offset, 300.0 * 10 + 50.0); + expect(mainAxisExtent(tester, pinnedHeaderContent), 250); + }); + + testWidgets( + 'RenderViewportBase.showOnScreen should not scroll if the rect is already visible, ' + 'even if it does not scroll linearly (reversed order version)', + (WidgetTester tester) async { + await tester.pumpWidget( + buildList( + floatingHeader: SliverPersistentHeader( + pinned: true, + floating: true, + delegate: _TestSliverPersistentHeaderDelegate(minExtent: 100, maxExtent: 300, key: headerKey, vsync: vsync), + ), + reversed: true, + ) + ); + + controller.jumpTo(-300.0 * 15); + await tester.pumpAndSettle(); + + final Finder pinnedHeaderContent = find.byKey(headerKey, skipOffstage: false); + + // The persistent header is pinned to the leading edge thus still visible, + // the viewport should not scroll. + tester.renderObject(pinnedHeaderContent).showOnScreen(); + await tester.pumpAndSettle(); + expect(controller.offset, -300.0 * 15); + + // children[9] will be partially obstructed by the persistent header, + // the viewport should scroll to reveal it. + controller.jumpTo( + - 8 * 300.0 // Preceding headers 11 - 18, children[11]'s top edge is aligned to the leading edge. + - 400.0 // Viewport height. children[10] (the pinned header) becomes pinned at the bottom of the screen. + - 200.0 // Shrinks the pinned header to minExtent (100). + - 100.0 // Obstructs the leading 100 pixels of the 11th header + ); + await tester.pumpAndSettle(); + + tester.renderObject(find.byWidget(children[9], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controller.offset, -8 * 300.0 - 400.0 - 200.0); + }); + }); + } + + group('Floating header showOnScreen', () { + testFloatingHeaderShowOnScreen(animated: true, axis: Axis.vertical); + testFloatingHeaderShowOnScreen(animated: true, axis: Axis.horizontal); + }); + + group('RenderViewport getOffsetToReveal renderBox to sliver coordinates conversion', () { + const EdgeInsets padding = EdgeInsets.fromLTRB(22, 22, 34, 34); + const Key centerKey = Key('5'); + Widget buildList({ Axis axis, bool reverse = false, bool reverseGrowth = false }) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Container( + height: 400.0, + width: 400.0, + child: CustomScrollView( + scrollDirection: axis, + reverse: reverse, + center: reverseGrowth ? centerKey : null, + slivers: List.generate(6, (int i) { + return SliverPadding( + key: i == 5 ? centerKey : null, + padding: padding, + sliver: SliverToBoxAdapter( + child: Container( + padding: padding, + height: 300.0, + width: 300.0, + child: Text('Tile $i'), + ), + ), + ); + }), + ), + ), + ), + ); + } + + testWidgets('up, forward growth', (WidgetTester tester) async { + await tester.pumpWidget(buildList(axis: Axis.vertical, reverse: true, reverseGrowth: false)); + final RenderAbstractViewport viewport = tester.allRenderObjects.whereType().first; + + final RenderObject target = tester.renderObject(find.text('Tile 5', skipOffstage: false)); + final double revealOffset = viewport.getOffsetToReveal(target, 0.0).offset; + expect(revealOffset, (300.0 + padding.horizontal) * 5 + 34.0 * 2); + }); + + testWidgets('up, reverse growth', (WidgetTester tester) async { + await tester.pumpWidget(buildList(axis: Axis.vertical, reverse: true, reverseGrowth: true)); + final RenderAbstractViewport viewport = tester.allRenderObjects.whereType().first; + + final RenderObject target = tester.renderObject(find.text('Tile 0', skipOffstage: false)); + final double revealOffset = viewport.getOffsetToReveal(target, 0.0).offset; + expect(revealOffset, -(300.0 + padding.horizontal) * 5 + 34.0 * 2); + }); + + testWidgets('right, forward growth', (WidgetTester tester) async { + await tester.pumpWidget(buildList(axis: Axis.horizontal, reverse: false, reverseGrowth: false)); + final RenderAbstractViewport viewport = tester.allRenderObjects.whereType().first; + + final RenderObject target = tester.renderObject(find.text('Tile 5', skipOffstage: false)); + final double revealOffset = viewport.getOffsetToReveal(target, 0.0).offset; + expect(revealOffset, (300.0 + padding.horizontal) * 5 + 22.0 * 2); + }); + + testWidgets('right, reverse growth', (WidgetTester tester) async { + await tester.pumpWidget(buildList(axis: Axis.horizontal, reverse: false, reverseGrowth: true)); + final RenderAbstractViewport viewport = tester.allRenderObjects.whereType().first; + + final RenderObject target = tester.renderObject(find.text('Tile 0', skipOffstage: false)); + final double revealOffset = viewport.getOffsetToReveal(target, 0.0).offset; + expect(revealOffset, -(300.0 + padding.horizontal) * 5 + 22.0 * 2); + }); + + testWidgets('down, forward growth', (WidgetTester tester) async { + await tester.pumpWidget(buildList(axis: Axis.vertical, reverse: false, reverseGrowth: false)); + final RenderAbstractViewport viewport = tester.allRenderObjects.whereType().first; + + final RenderObject target = tester.renderObject(find.text('Tile 5', skipOffstage: false)); + final double revealOffset = viewport.getOffsetToReveal(target, 0.0).offset; + expect(revealOffset, (300.0 + padding.horizontal) * 5 + 22.0 * 2); + }); + + testWidgets('down, reverse growth', (WidgetTester tester) async { + await tester.pumpWidget(buildList(axis: Axis.vertical, reverse: false, reverseGrowth: true)); + final RenderAbstractViewport viewport = tester.allRenderObjects.whereType().first; + + final RenderObject target = tester.renderObject(find.text('Tile 0', skipOffstage: false)); + final double revealOffset = viewport.getOffsetToReveal(target, 0.0).offset; + expect(revealOffset, -(300.0 + padding.horizontal) * 5 + 22.0 * 2); + }); + + testWidgets('left, forward growth', (WidgetTester tester) async { + await tester.pumpWidget(buildList(axis: Axis.horizontal, reverse: true, reverseGrowth: false)); + final RenderAbstractViewport viewport = tester.allRenderObjects.whereType().first; + + final RenderObject target = tester.renderObject(find.text('Tile 5', skipOffstage: false)); + final double revealOffset = viewport.getOffsetToReveal(target, 0.0).offset; + expect(revealOffset, (300.0 + padding.horizontal) * 5 + 34.0 * 2); + }); + + testWidgets('left, reverse growth', (WidgetTester tester) async { + await tester.pumpWidget(buildList(axis: Axis.horizontal, reverse: true, reverseGrowth: true)); + final RenderAbstractViewport viewport = tester.allRenderObjects.whereType().first; + + final RenderObject target = tester.renderObject(find.text('Tile 0', skipOffstage: false)); + final double revealOffset = viewport.getOffsetToReveal(target, 0.0).offset; + expect(revealOffset, -(300.0 + padding.horizontal) * 5 + 34.0 * 2); + }); + }); + testWidgets('RenderViewportBase.showOnScreen reports the correct targetRect', (WidgetTester tester) async { final ScrollController innerController = ScrollController(); final ScrollController outerController = ScrollController(); diff --git a/packages/flutter/test/widgets/editable_text_show_on_screen_test.dart b/packages/flutter/test/widgets/editable_text_show_on_screen_test.dart index 7ef32dbb96d..0530c559160 100644 --- a/packages/flutter/test/widgets/editable_text_show_on_screen_test.dart +++ b/packages/flutter/test/widgets/editable_text_show_on_screen_test.dart @@ -10,6 +10,35 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter/services.dart'; +class _TestSliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate { + _TestSliverPersistentHeaderDelegate({ + this.minExtent, + this.maxExtent, + this.child, + this.vsync = const TestVSync(), + this.showOnScreenConfiguration = const PersistentHeaderShowOnScreenConfiguration(), + }); + + final Widget child; + + @override + final double maxExtent; + + @override + final double minExtent; + + @override + final TickerProvider vsync; + + @override + final PersistentHeaderShowOnScreenConfiguration showOnScreenConfiguration; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child; + + @override + bool shouldRebuild(_TestSliverPersistentHeaderDelegate oldDelegate) => true; +} void main() { const TextStyle textStyle = TextStyle(); @@ -339,6 +368,131 @@ void main() { expect(scrollController.offset, greaterThan(0.0)); expect(find.byKey(container), findsNothing); }); + + testWidgets( + 'A pinned persistent header should not scroll when its descendant EditableText gains focus', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/25507. + ScrollController controller; + final TextEditingController textEditingController = TextEditingController(); + final FocusNode focusNode = FocusNode(); + + const Key headerKey = Key('header'); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + height: 600.0, + width: 600.0, + child: CustomScrollView( + controller: controller = ScrollController(initialScrollOffset: 0), + slivers: List.generate(50, (int i) { + return i == 10 + ? SliverPersistentHeader( + pinned: true, + floating: false, + delegate: _TestSliverPersistentHeaderDelegate( + minExtent: 50, + maxExtent: 50, + child: Container( + alignment: Alignment.topCenter, + child: EditableText( + key: headerKey, + backgroundCursorColor: Colors.grey, + controller: textEditingController, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), + ), + ), + ) + : SliverToBoxAdapter( + child: Container( + height: 100.0, + child: Text('Tile $i'), + ), + ); + }), + ), + ), + ), + ), + ); + + // The persistent header should now be pinned at the top. + controller.jumpTo(100.0 * 15); + await tester.pumpAndSettle(); + expect(controller.offset, 100.0 * 15); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + // The scroll offset should remain the same. + expect(controller.offset, 100.0 * 15); + }); + + testWidgets( + 'A pinned persistent header should not scroll when its descendant EditableText gains focus (no animation)', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/25507. + ScrollController controller; + final TextEditingController textEditingController = TextEditingController(); + final FocusNode focusNode = FocusNode(); + + const Key headerKey = Key('header'); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + height: 600.0, + width: 600.0, + child: CustomScrollView( + controller: controller = ScrollController(initialScrollOffset: 0), + slivers: List.generate(50, (int i) { + return i == 10 + ? SliverPersistentHeader( + pinned: true, + floating: false, + delegate: _TestSliverPersistentHeaderDelegate( + minExtent: 50, + maxExtent: 50, + vsync: null, + child: Container( + alignment: Alignment.topCenter, + child: EditableText( + key: headerKey, + backgroundCursorColor: Colors.grey, + controller: textEditingController, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), + ), + ), + ) + : SliverToBoxAdapter( + child: Container( + height: 100.0, + child: Text('Tile $i'), + ), + ); + }), + ), + ), + ), + ), + ); + + // The persistent header should now be pinned at the top. + controller.jumpTo(100.0 * 15); + await tester.pumpAndSettle(); + expect(controller.offset, 100.0 * 15); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + // The scroll offset should remain the same. + expect(controller.offset, 100.0 * 15); + }); } class NoImplicitScrollPhysics extends AlwaysScrollableScrollPhysics {