Reland "Prevent viewport.showOnScreen from scrolling the viewport if the specified Rect is already visible. (#56413)" reverted in #64091 (#64513)
diff --git a/packages/flutter/lib/src/material/app_bar.dart b/packages/flutter/lib/src/material/app_bar.dart
index d4d9c3e..7122452 100644
--- a/packages/flutter/lib/src/material/app_bar.dart
+++ b/packages/flutter/lib/src/material/app_bar.dart
@@ -817,12 +817,18 @@
@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;
@@ -861,12 +867,18 @@
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;
final double extraToolbarHeight = math.max(minExtent - _bottomHeight - topPadding - (toolbarHeight ?? kToolbarHeight), 0.0);
@@ -935,8 +947,10 @@
|| 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 @@
/// 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 _SliverAppBarState extends State<SliverAppBar> 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 @@
floating: widget.floating,
pinned: widget.pinned,
delegate: _SliverAppBarDelegate(
+ vsync: this,
leading: widget.leading,
automaticallyImplyLeading: widget.automaticallyImplyLeading,
title: widget.title,
@@ -1464,6 +1488,7 @@
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 54d9348..4913b2a 100644
--- a/packages/flutter/lib/src/rendering/sliver_persistent_header.dart
+++ b/packages/flutter/lib/src/rendering/sliver_persistent_header.dart
@@ -17,6 +17,15 @@
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,
+}) => original?.intersect(Rect.fromLTRB(left, top, right, bottom));
+
/// Specifies how a stretched header is to trigger an [AsyncCallback].
///
/// See also:
@@ -39,6 +48,60 @@
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 = double.negativeInfinity,
+ this.maxShowOnScreenExtent = double.infinity,
+ }) : assert(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 [double.negativeInfinity], must be less than or equal to
+ /// [maxShowOnScreenExtent]. 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 [double.infinity], must be greater than or equal to
+ /// [minShowOnScreenExtent]. 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]).
@@ -347,11 +410,18 @@
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;
@@ -377,6 +447,41 @@
@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
@@ -393,16 +498,23 @@
/// 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.
- final TickerProvider vsync;
+ /// 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.
final Curve curve;
@@ -425,13 +537,15 @@
/// direction.
RenderSliverFloatingPersistentHeader({
RenderBox? child,
- FloatingHeaderSnapConfiguration? snapConfiguration,
+ TickerProvider? vsync,
+ this.snapConfiguration,
OverScrollHeaderStretchConfiguration? stretchConfiguration,
- }) : _snapConfiguration = snapConfiguration,
+ required this.showOnScreenConfiguration,
+ }) : _vsync = vsync,
super(
- child: child,
- stretchConfiguration: stretchConfiguration,
- );
+ child: child,
+ stretchConfiguration: stretchConfiguration,
+ );
AnimationController? _controller;
late Animation<double> _animation;
@@ -449,6 +563,22 @@
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(value);
+ }
+ }
+
/// Defines the parameters used to snap (animate) the floating header in and
/// out of view.
///
@@ -461,20 +591,13 @@
/// 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].
///
@@ -499,38 +622,52 @@
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);
+ assert(
+ vsync != null,
+ 'vsync must not be null if the floating header changes size animatedly.',
+ );
+
+ final AnimationController effectiveController =
+ _controller ??= AnimationController(vsync: vsync!, duration: duration)
+ ..addListener(() {
+ if (_effectiveScrollOffset == _animation.value)
+ return;
+ _effectiveScrollOffset = _animation.value;
+ markNeedsLayout();
+ });
+
+ _animation = effectiveController.drive(
+ Tween<double>(
+ 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)
+ final FloatingHeaderSnapConfiguration? snap = snapConfiguration;
+ if (snap == null)
return;
if (direction == ScrollDirection.forward && _effectiveScrollOffset! <= 0.0)
return;
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<double>(
- begin: _effectiveScrollOffset,
- end: direction == ScrollDirection.forward ? 0.0 : maxExtent,
- ).chain(CurveTween(
- curve: snapConfiguration!.curve,
- )),
+ _updateAnimation(
+ snap.duration,
+ direction == ScrollDirection.forward ? 0.0 : maxExtent,
+ snap.curve,
);
-
- _controller!.forward(from: 0.0);
+ _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 +705,79 @@
}
@override
+ void showOnScreen({
+ RenderObject? descendant,
+ Rect? rect,
+ Duration duration = Duration.zero,
+ Curve curve = Curves.ease,
+ }) {
+ final PersistentHeaderShowOnScreenConfiguration? showOnScreen = showOnScreenConfiguration;
+ if (showOnScreen == 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(
+ showOnScreen.minShowOnScreenExtent,
+ showOnScreen.maxShowOnScreenExtent,
+ )
+ // Clamp the value back to the valid range after applying additional
+ // constriants. Contracting is not allowed.
+ .clamp(childExtent, effectiveMaxExtent);
+
+ // 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);
return _childPosition ?? 0.0;
@@ -594,12 +804,16 @@
/// scroll direction.
RenderSliverFloatingPinnedPersistentHeader({
RenderBox? child,
+ TickerProvider? vsync,
FloatingHeaderSnapConfiguration? snapConfiguration,
OverScrollHeaderStretchConfiguration? stretchConfiguration,
+ 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 4497ddc..f47d40b 100644
--- a/packages/flutter/lib/src/rendering/viewport.dart
+++ b/packages/flutter/lib/src/rendering/viewport.dart
@@ -816,7 +816,7 @@
final RenderSliver sliver = child as RenderSliver;
double targetMainAxisExtent;
- // The scroll offset within `child` to `rect`.
+ // The scroll offset of `rect` within `child`.
switch (applyGrowthDirectionToAxisDirection(axisDirection, growthDirection)) {
case AxisDirection.up:
leadingScrollOffset += pivotExtent - rectLocal.bottom;
@@ -836,6 +836,14 @@
break;
}
+ // So far leadingScrollOffset is the scroll offset of `rect` in the `child`
+ // sliver's sliver coordinate system. The sign of this value indicates
+ // whether the `rect` protrudes the leading edge of the `child` sliver. When
+ // this value is non-negative and `child`'s `maxScrollObstructionExtent` is
+ // greater than 0, we assume `rect` can't be obstructed by the leading edge
+ // of the viewport (i.e. its pinned to the leading edge).
+ final bool isPinned = sliver.geometry!.maxScrollObstructionExtent > 0 && leadingScrollOffset >= 0;
+
// The scroll offset in the viewport to `rect`.
leadingScrollOffset = scrollOffsetOf(sliver, leadingScrollOffset);
@@ -845,11 +853,16 @@
final Matrix4 transform = target.getTransformTo(this);
Rect targetRect = MatrixUtils.transformRect(transform, rect);
final double extentOfPinnedSlivers = maxScrollObstructionExtentBefore(sliver);
+
switch (sliver.constraints.growthDirection) {
case GrowthDirection.forward:
+ if (isPinned && alignment <= 0)
+ return RevealedOffset(offset: double.infinity, rect: targetRect);
leadingScrollOffset -= extentOfPinnedSlivers;
break;
case GrowthDirection.reverse:
+ if (isPinned && alignment >= 1)
+ return RevealedOffset(offset: double.negativeInfinity, rect: targetRect);
// If child's growth direction is reverse, when viewport.offset is
// `leadingScrollOffset`, it is positioned just outside of the leading
// edge of the viewport.
diff --git a/packages/flutter/lib/src/widgets/sliver_persistent_header.dart b/packages/flutter/lib/src/widgets/sliver_persistent_header.dart
index 673e1cb..4f70ba5 100644
--- a/packages/flutter/lib/src/widgets/sliver_persistent_header.dart
+++ b/packages/flutter/lib/src/widgets/sliver_persistent_header.dart
@@ -6,6 +6,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
+import 'package:flutter/scheduler.dart' show TickerProvider;
import 'framework.dart';
@@ -59,6 +60,12 @@
/// different value.
double get maxExtent;
+ /// A [TickerProvider] to use when animating the header's size changes.
+ ///
+ /// Must not be null if the persistent header is a floating header, and
+ /// [snapConfiguration] or [showOnScreenConfiguration] is not null.
+ TickerProvider get vsync => 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 @@
/// 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 @@
@override
_RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context) {
return _RenderSliverPinnedPersistentHeaderForWidgets(
- stretchConfiguration: delegate.stretchConfiguration
+ stretchConfiguration: delegate.stretchConfiguration,
+ showOnScreenConfiguration: delegate.showOnScreenConfiguration,
);
}
}
@@ -356,9 +370,11 @@
_RenderSliverPinnedPersistentHeaderForWidgets({
RenderBox child,
OverScrollHeaderStretchConfiguration stretchConfiguration,
+ PersistentHeaderShowOnScreenConfiguration showOnScreenConfiguration,
}) : super(
child: child,
stretchConfiguration: stretchConfiguration,
+ showOnScreenConfiguration: showOnScreenConfiguration,
);
}
@@ -374,15 +390,19 @@
@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 @@
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 @@
@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 @@
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 93938a4..1f8e4cc 100644
--- a/packages/flutter/test/material/app_bar_test.dart
+++ b/packages/flutter/test/material/app_bar_test.dart
@@ -1975,6 +1975,41 @@
expect(tester.getCenter(appBarTitle).dy, tester.getCenter(toolbar).dy);
});
+ testWidgets('SliverAppBar configures the delegate properly', (WidgetTester tester) async {
+ Future<void> buildAndVerifyDelegate({ bool pinned, bool floating, bool snap }) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: CustomScrollView(
+ slivers: <Widget>[
+ SliverAppBar(
+ title: const Text('Jumbo'),
+ pinned: pinned,
+ floating: floating,
+ snap: snap,
+ ),
+ ],
+ ),
+ ),
+ );
+
+ final SliverPersistentHeaderDelegate delegate = tester
+ .widget<SliverPersistentHeader>(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 ab18603..bafe3b0 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 @@
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 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 f4c65a6..eed1589 100644
--- a/packages/flutter/test/rendering/viewport_test.dart
+++ b/packages/flutter/test/rendering/viewport_test.dart
@@ -16,6 +16,38 @@
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<Widget> children;
@@ -1027,6 +1059,488 @@
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<Widget> 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<Widget>.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<Widget> 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<Widget>.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<Widget>.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<RenderAbstractViewport>().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<RenderAbstractViewport>().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<RenderAbstractViewport>().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<RenderAbstractViewport>().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<RenderAbstractViewport>().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<RenderAbstractViewport>().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<RenderAbstractViewport>().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<RenderAbstractViewport>().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 7ef32db..0530c55 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/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 @@
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<Widget>.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<Widget>.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 {