Prevent viewport.showOnScreen from scrolling the viewport if the specified Rect is already visible. (#56413)

This commit is contained in:
LongCatIsLooong 2020-08-14 16:41:03 -07:00 committed by GitHub
parent 6f26e806ab
commit 64d76f2fb7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 1089 additions and 110 deletions

View file

@ -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<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 @@ class _SliverAppBarState extends State<SliverAppBar> 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<SliverAppBar> with TickerProviderStateMix
shape: widget.shape,
snapConfiguration: _snapConfiguration,
stretchConfiguration: _stretchConfiguration,
showOnScreenConfiguration: _showOnScreenConfiguration,
toolbarHeight: widget.toolbarHeight,
leadingWidth: widget.leadingWidth,
),

View file

@ -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<double> _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<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)
@ -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<double>(
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

View file

@ -727,58 +727,26 @@ abstract class RenderViewportBase<ParentDataClass extends ContainerParentDataMix
final Matrix4 transform = target.getTransformTo(pivot);
final Rect bounds = MatrixUtils.transformRect(transform, rect);
// Convert `rect`'s leading edge from `pivot`'s RenderBox coordinate
// system to the scrollOffset within `pivot.parent`. For `up` and `left`
// AxisDirections here, the leading edge of the render box is the
// bottom/right edge.
final GrowthDirection growthDirection = pivotParent.constraints.growthDirection;
switch (applyGrowthDirectionToAxisDirection(axisDirection, growthDirection)) {
case AxisDirection.up:
double offset;
switch (growthDirection) {
case GrowthDirection.forward:
offset = bounds.bottom;
break;
case GrowthDirection.reverse:
offset = bounds.top;
break;
}
leadingScrollOffset += pivot.size.height - offset;
leadingScrollOffset += pivot.size.height - bounds.bottom;
targetMainAxisExtent = bounds.height;
break;
case AxisDirection.right:
double offset;
switch (growthDirection) {
case GrowthDirection.forward:
offset = bounds.left;
break;
case GrowthDirection.reverse:
offset = bounds.right;
break;
}
leadingScrollOffset += offset;
leadingScrollOffset += bounds.left;
targetMainAxisExtent = bounds.width;
break;
case AxisDirection.down:
double offset;
switch (growthDirection) {
case GrowthDirection.forward:
offset = bounds.top;
break;
case GrowthDirection.reverse:
offset = bounds.bottom;
break;
}
leadingScrollOffset += offset;
leadingScrollOffset += bounds.top;
targetMainAxisExtent = bounds.height;
break;
case AxisDirection.left:
double offset;
switch (growthDirection) {
case GrowthDirection.forward:
offset = bounds.right;
break;
case GrowthDirection.reverse:
offset = bounds.left;
break;
}
leadingScrollOffset += pivot.size.width - offset;
leadingScrollOffset += pivot.size.width - bounds.right;
targetMainAxisExtent = bounds.width;
break;
}
@ -792,14 +760,44 @@ abstract class RenderViewportBase<ParentDataClass extends ContainerParentDataMix
assert(child.parent == this);
assert(child is RenderSliver);
final RenderSliver sliver = child as RenderSliver;
// This step assumes the viewport's layout is up-to-date, i.e., if
// offset.pixels is changed after the last performLayout, the new scroll
// position will not be accounted for.
final Matrix4 transform = target.getTransformTo(this);
Rect targetRect = MatrixUtils.transformRect(transform, rect);
// 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;
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<ParentDataClass extends ContainerParentDataMix
final double targetOffset = leadingScrollOffset - (mainAxisExtent - targetMainAxisExtent) * alignment;
final double offsetDifference = offset.pixels - targetOffset;
final Matrix4 transform = target.getTransformTo(this);
Rect targetRect = MatrixUtils.transformRect(transform, rect);
switch (axisDirection) {
case AxisDirection.down:
targetRect = targetRect.translate(0.0, offsetDifference);

View file

@ -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 @@ abstract class SliverPersistentHeaderDelegate {
/// 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 @@ 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,
);
}

View file

@ -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<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(

View file

@ -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;

View file

@ -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<Widget> 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<String>('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<String>('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<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();

View file

@ -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<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 {