Unverified Commit 3ac9449a authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Fix the confusing-zero case with NestedScrollView. (#14133)

* Fix the confusing-zero case with NestedScrollView.

* Update mock_canvas.dart

* Update tabs_demo.dart

* more tweaks
parent c5cbc0df
...@@ -13,6 +13,8 @@ class _Page { ...@@ -13,6 +13,8 @@ class _Page {
_Page({ this.label }); _Page({ this.label });
final String label; final String label;
String get id => label[0]; String get id => label[0];
@override
String toString() => '$runtimeType("$label")';
} }
class _CardData { class _CardData {
...@@ -69,6 +71,13 @@ final Map<_Page, List<_CardData>> _allPages = <_Page, List<_CardData>>{ ...@@ -69,6 +71,13 @@ final Map<_Page, List<_CardData>> _allPages = <_Page, List<_CardData>>{
imageAsset: 'shrine/products/chucks.png', imageAsset: 'shrine/products/chucks.png',
imageAssetPackage: _kGalleryAssetsPackage, imageAssetPackage: _kGalleryAssetsPackage,
), ),
],
new _Page(label: 'RIGHT'): <_CardData>[
const _CardData(
title: 'Beachball',
imageAsset: 'shrine/products/beachball.png',
imageAssetPackage: _kGalleryAssetsPackage,
),
const _CardData( const _CardData(
title: 'Dipped Brush', title: 'Dipped Brush',
imageAsset: 'shrine/products/brush.png', imageAsset: 'shrine/products/brush.png',
...@@ -80,13 +89,6 @@ final Map<_Page, List<_CardData>> _allPages = <_Page, List<_CardData>>{ ...@@ -80,13 +89,6 @@ final Map<_Page, List<_CardData>> _allPages = <_Page, List<_CardData>>{
imageAssetPackage: _kGalleryAssetsPackage, imageAssetPackage: _kGalleryAssetsPackage,
), ),
], ],
new _Page(label: 'RIGHT'): <_CardData>[
const _CardData(
title: 'Beachball',
imageAsset: 'shrine/products/beachball.png',
imageAssetPackage: _kGalleryAssetsPackage,
),
],
}; };
class _CardDataItem extends StatelessWidget { class _CardDataItem extends StatelessWidget {
...@@ -121,7 +123,10 @@ class _CardDataItem extends StatelessWidget { ...@@ -121,7 +123,10 @@ class _CardDataItem extends StatelessWidget {
), ),
), ),
new Center( new Center(
child: new Text(data.title, style: Theme.of(context).textTheme.title), child: new Text(
data.title,
style: Theme.of(context).textTheme.title,
),
), ),
], ],
), ),
...@@ -141,13 +146,18 @@ class TabsDemo extends StatelessWidget { ...@@ -141,13 +146,18 @@ class TabsDemo extends StatelessWidget {
body: new NestedScrollView( body: new NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[ return <Widget>[
new SliverAppBar( new SliverOverlapAbsorber(
title: const Text('Tabs and scrolling'), handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
pinned: true, child: new SliverAppBar(
expandedHeight: 150.0, title: const Text('Tabs and scrolling'),
forceElevated: innerBoxIsScrolled, pinned: true,
bottom: new TabBar( expandedHeight: 150.0,
tabs: _allPages.keys.map((_Page page) => new Tab(text: page.label)).toList(), forceElevated: innerBoxIsScrolled,
bottom: new TabBar(
tabs: _allPages.keys.map(
(_Page page) => new Tab(text: page.label),
).toList(),
),
), ),
), ),
]; ];
...@@ -157,15 +167,41 @@ class TabsDemo extends StatelessWidget { ...@@ -157,15 +167,41 @@ class TabsDemo extends StatelessWidget {
return new SafeArea( return new SafeArea(
top: false, top: false,
bottom: false, bottom: false,
child: new ListView( child: new Builder(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), builder: (BuildContext context) {
itemExtent: _CardDataItem.height, return new CustomScrollView(
children: _allPages[page].map((_CardData data) { key: new PageStorageKey<_Page>(page),
return new Padding( slivers: <Widget>[
padding: const EdgeInsets.symmetric(vertical: 8.0), new SliverOverlapInjector(
child: new _CardDataItem(page: page, data: data), handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
new SliverPadding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
sliver: new SliverFixedExtentList(
itemExtent: _CardDataItem.height,
delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
final _CardData data = _allPages[page][index];
return new Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
),
child: new _CardDataItem(
page: page,
data: data,
),
);
},
childCount: _allPages[page].length,
),
),
),
],
); );
}).toList(), },
), ),
); );
}).toList(), }).toList(),
......
...@@ -41,6 +41,7 @@ export 'src/painting/image_provider.dart'; ...@@ -41,6 +41,7 @@ export 'src/painting/image_provider.dart';
export 'src/painting/image_resolution.dart'; export 'src/painting/image_resolution.dart';
export 'src/painting/image_stream.dart'; export 'src/painting/image_stream.dart';
export 'src/painting/matrix_utils.dart'; export 'src/painting/matrix_utils.dart';
export 'src/painting/paint_utilities.dart';
export 'src/painting/rounded_rectangle_border.dart'; export 'src/painting/rounded_rectangle_border.dart';
export 'src/painting/shape_decoration.dart'; export 'src/painting/shape_decoration.dart';
export 'src/painting/stadium_border.dart'; export 'src/painting/stadium_border.dart';
......
...@@ -38,7 +38,7 @@ typedef void AnimationStatusListener(AnimationStatus status); ...@@ -38,7 +38,7 @@ typedef void AnimationStatusListener(AnimationStatus status);
/// ///
/// To create a new animation that you can run forward and backward, consider /// To create a new animation that you can run forward and backward, consider
/// using [AnimationController]. /// using [AnimationController].
abstract class Animation<T> extends Listenable { abstract class Animation<T> extends Listenable implements ValueListenable<T> {
/// Abstract const constructor. This constructor enables subclasses to provide /// Abstract const constructor. This constructor enables subclasses to provide
/// const constructors so that they can be used in const expressions. /// const constructors so that they can be used in const expressions.
const Animation(); const Animation();
...@@ -71,6 +71,7 @@ abstract class Animation<T> extends Listenable { ...@@ -71,6 +71,7 @@ abstract class Animation<T> extends Listenable {
AnimationStatus get status; AnimationStatus get status;
/// The current value of the animation. /// The current value of the animation.
@override
T get value; T get value;
/// Whether this animation is stopped at the beginning. /// Whether this animation is stopped at the beginning.
......
...@@ -32,6 +32,16 @@ abstract class Listenable { ...@@ -32,6 +32,16 @@ abstract class Listenable {
void removeListener(VoidCallback listener); void removeListener(VoidCallback listener);
} }
/// An interface for subclasses of [Listenable] that expose a [value].
///
/// This interface is implemented by [ValueNotifier<T>] and [Animation<T>], and
/// allows other APIs to accept either of those implementations interchangeably.
abstract class ValueListenable<T> extends Listenable {
/// The current value of the object. When the value changes, the callbacks
/// registered with [addListener] will be invoked.
T get value;
}
/// A class that can be extended or mixed in that provides a change notification /// A class that can be extended or mixed in that provides a change notification
/// API using [VoidCallback] for notifications. /// API using [VoidCallback] for notifications.
/// ///
...@@ -169,13 +179,14 @@ class _MergingListenable extends ChangeNotifier { ...@@ -169,13 +179,14 @@ class _MergingListenable extends ChangeNotifier {
/// A [ChangeNotifier] that holds a single value. /// A [ChangeNotifier] that holds a single value.
/// ///
/// When [value] is replaced, this class notifies its listeners. /// When [value] is replaced, this class notifies its listeners.
class ValueNotifier<T> extends ChangeNotifier { class ValueNotifier<T> extends ChangeNotifier implements ValueListenable<T> {
/// Creates a [ChangeNotifier] that wraps this value. /// Creates a [ChangeNotifier] that wraps this value.
ValueNotifier(this._value); ValueNotifier(this._value);
/// The current value stored in this notifier. /// The current value stored in this notifier.
/// ///
/// When the value is replaced, this class notifies its listeners. /// When the value is replaced, this class notifies its listeners.
@override
T get value => _value; T get value => _value;
T _value; T _value;
set value(T newValue) { set value(T newValue) {
......
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'basic_types.dart';
/// Draw a line between two points, which cuts diagonally back and forth across
/// the line that connects the two points.
///
/// The line will cross the line `zigs - 1` times.
///
/// If `zigs` is 1, then this will draw two sides of a triangle from `start` to
/// `end`, with the third point being `width` away from the line, as measured
/// perpendicular to that line.
///
/// If `width` is positive, the first `zig` will be to the left of the `start`
/// point when facing the `end` point. To reverse the zigging polarity, provide
/// a negative `width`.
///
/// The line is drawn using the provided `paint` on the provided `canvas`.
void paintZigZag(Canvas canvas, Paint paint, Offset start, Offset end, int zigs, double width) {
assert(zigs.isFinite);
assert(zigs > 0);
canvas.save();
canvas.translate(start.dx, start.dy);
end = end - start;
canvas.rotate(math.atan2(end.dy, end.dx));
final double length = end.distance;
final double spacing = length / (zigs * 2.0);
final Path path = new Path()
..moveTo(0.0, 0.0);
for (int index = 0; index < zigs; index += 1) {
final double x = (index * 2.0 + 1.0) * spacing;
final double y = width * ((index % 2.0) * 2.0 - 1.0);
path.lineTo(x, y);
}
path.lineTo(length, 0.0);
canvas.drawPath(path, paint);
canvas.restore();
}
\ No newline at end of file
...@@ -242,7 +242,7 @@ class RenderSliverPadding extends RenderSliver with RenderObjectWithChildMixin<R ...@@ -242,7 +242,7 @@ class RenderSliverPadding extends RenderSliver with RenderObjectWithChildMixin<R
@override @override
bool hitTestChildren(HitTestResult result, { @required double mainAxisPosition, @required double crossAxisPosition }) { bool hitTestChildren(HitTestResult result, { @required double mainAxisPosition, @required double crossAxisPosition }) {
if (child.geometry.hitTestExtent > 0.0) if (child != null && child.geometry.hitTestExtent > 0.0)
return child.hitTest(result, mainAxisPosition: mainAxisPosition - childMainAxisPosition(child), crossAxisPosition: crossAxisPosition - childCrossAxisPosition(child)); return child.hitTest(result, mainAxisPosition: mainAxisPosition - childMainAxisPosition(child), crossAxisPosition: crossAxisPosition - childCrossAxisPosition(child));
return false; return false;
} }
......
...@@ -580,12 +580,27 @@ abstract class SchedulerBinding extends BindingBase with ServicesBinding { ...@@ -580,12 +580,27 @@ abstract class SchedulerBinding extends BindingBase with ServicesBinding {
/// Schedules a new frame using [scheduleFrame] if this object is not /// Schedules a new frame using [scheduleFrame] if this object is not
/// currently producing a frame. /// currently producing a frame.
/// ///
/// After this is called, the framework ensures that the end of the /// Calling this method ensures that [handleDrawFrame] will eventually be
/// [handleBeginFrame] function will (eventually) be reached. /// called, unless it's already in progress.
///
/// This has no effect if [schedulerPhase] is
/// [SchedulerPhase.transientCallbacks] or [SchedulerPhase.midFrameMicrotasks]
/// (because a frame is already being prepared in that case), or
/// [SchedulerPhase.persistentCallbacks] (because a frame is actively being
/// rendered in that case). It will schedule a frame if the [schedulerPhase]
/// is [SchedulerPhase.idle] (in between frames) or
/// [SchedulerPhase.postFrameCallbacks] (after a frame).
void ensureVisualUpdate() { void ensureVisualUpdate() {
if (schedulerPhase != SchedulerPhase.idle) switch (schedulerPhase) {
return; case SchedulerPhase.idle:
scheduleFrame(); case SchedulerPhase.postFrameCallbacks:
scheduleFrame();
return;
case SchedulerPhase.transientCallbacks:
case SchedulerPhase.midFrameMicrotasks:
case SchedulerPhase.persistentCallbacks:
return;
}
} }
/// If necessary, schedules a new frame by calling /// If necessary, schedules a new frame by calling
......
...@@ -508,7 +508,7 @@ abstract class WidgetsBinding extends BindingBase with SchedulerBinding, Gesture ...@@ -508,7 +508,7 @@ abstract class WidgetsBinding extends BindingBase with SchedulerBinding, Gesture
_deferFirstFrameReportCount -= 1; _deferFirstFrameReportCount -= 1;
} }
void _handleBuildScheduled() { void _handleBuildScheduled() {
// If we're in the process of building dirty elements, then changes // If we're in the process of building dirty elements, then changes
// should not trigger a new frame. // should not trigger a new frame.
assert(() { assert(() {
......
...@@ -7,6 +7,7 @@ import 'dart:math' as math; ...@@ -7,6 +7,7 @@ import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/physics.dart'; import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
...@@ -23,6 +24,10 @@ import 'scroll_position.dart'; ...@@ -23,6 +24,10 @@ import 'scroll_position.dart';
import 'scroll_view.dart'; import 'scroll_view.dart';
import 'sliver.dart'; import 'sliver.dart';
import 'ticker_provider.dart'; import 'ticker_provider.dart';
import 'viewport.dart';
// Examples can assume:
// List<String> _tabs;
/// Signature used by [NestedScrollView] for building its header. /// Signature used by [NestedScrollView] for building its header.
/// ///
...@@ -32,10 +37,149 @@ import 'ticker_provider.dart'; ...@@ -32,10 +37,149 @@ import 'ticker_provider.dart';
/// content ostensibly below it. /// content ostensibly below it.
typedef List<Widget> NestedScrollViewHeaderSliversBuilder(BuildContext context, bool innerBoxIsScrolled); typedef List<Widget> NestedScrollViewHeaderSliversBuilder(BuildContext context, bool innerBoxIsScrolled);
/// A scrolling view inside of which can be nested other scrolling views, with
/// their scroll positions being intrinsically linked.
///
/// The most common use case for this widget is a scrollable view with a
/// flexible [SliverAppBar] containing a [TabBar] in the header (build by
/// [headerSliverBuilder], and with a [TabBarView] in the [body], such that the
/// scrollable view's contents vary based on which tab is visible.
///
/// ## Motivation
///
/// In a normal [ScrollView], there is one set of slivers (the components of the
/// scrolling view). If one of those slivers hosted a [TabBarView] which scrolls
/// in the opposite direction (e.g. allowing the user to swipe horizontally
/// between the pages represented by the tabs, while the list scrolls
/// vertically), then any list inside that [TabBarView] would not interact with
/// the outer [ScrollView]. For example, flinging the inner list to scroll to
/// the top would not cause a collapsed [SliverAppBar] in the outer [ScrollView]
/// to expand.
///
/// [NestedScrollView] solves this problem by providing custom
/// [ScrollController]s for the outer [ScrollView] and the inner [ScrollView]s
/// (those inside the [TabBarView], hooking them together so that they appear,
/// to the user, as one coherent scroll view.
///
/// ## Sample code
///
/// This example shows a [NestedScrollView] whose header is the combination of a
/// [TabBar] in a [SliverAppBar] and whose body is a [TabBarView]. It uses a
/// [SliverOverlapAbsorber]/[SliverOverlapInjector] pair to make the inner lists
/// align correctly, and it uses [SafeArea] to avoid any horizontal disturbances
/// (e.g. the "notch" on iOS when the phone is horizontal). In addition,
/// [PageStorageKey]s are used to remember the scroll position of each tab's
/// list.
///
/// In the example below, `_tabs` is a list of strings, one for each tab, giving
/// the tab labels. In a real application, it would be replaced by the actual
/// data model being represented.
///
/// ```dart
/// new DefaultTabController(
/// length: _tabs.length, // This is the number of tabs.
/// child: new NestedScrollView(
/// headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
/// // These are the slivers that show up in the "outer" scroll view.
/// return <Widget>[
/// new SliverOverlapAbsorber(
/// // This widget takes the overlapping behavior of the SliverAppBar,
/// // and redirects it to the SliverOverlapInjector below. If it is
/// // missing, then it is possible for the nested "inner" scroll view
/// // below to end up under the SliverAppBar even when the inner
/// // scroll view thinks it has not been scrolled.
/// // This is not necessary if the "headerSliverBuilder" only builds
/// // widgets that do not overlap the next sliver.
/// handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
/// child: new SliverAppBar(
/// title: const Text('Books'), // This is the title in the app bar.
/// pinned: true,
/// expandedHeight: 150.0,
/// // The "forceElevated" property causes the SliverAppBar to show
/// // a shadow. The "innerBoxIsScrolled" parameter is true when the
/// // inner scroll view is scrolled beyond its "zero" point, i.e.
/// // when it appears to be scrolled below the SliverAppBar.
/// // Without this, there are cases where the shadow would appear
/// // or not appear inappropriately, because the SliverAppBar is
/// // not actually aware of the precise position of the inner
/// // scroll views.
/// forceElevated: innerBoxIsScrolled,
/// bottom: new TabBar(
/// // These are the widgets to put in each tab in the tab bar.
/// tabs: _tabs.map((String name) => new Tab(text: name)).toList(),
/// ),
/// ),
/// ),
/// ];
/// },
/// body: new TabBarView(
/// // These are the contents of the tab views, below the tabs.
/// children: _tabs.map((String name) {
/// return new SafeArea(
/// top: false,
/// bottom: false,
/// child: new Builder(
/// // This Builder is needed to provide a BuildContext that is "inside"
/// // the NestedScrollView, so that sliverOverlapAbsorberHandleFor() can
/// // find the NestedScrollView.
/// builder: (BuildContext context) {
/// return new CustomScrollView(
/// // The "controller" and "primary" members should be left
/// // unset, so that the NestedScrollView can control this
/// // inner scroll view.
/// // If the "controller" property is set, then this scroll
/// // view will not be associated with the NestedScrollView.
/// // The PageStorageKey should be unique to this ScrollView;
/// // it allows the list to remember its scroll position when
/// // the tab view is not on the screen.
/// key: new PageStorageKey<String>(name),
/// slivers: <Widget>[
/// new SliverOverlapInjector(
/// // This is the flip side of the SliverOverlapAbsorber above.
/// handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
/// ),
/// new SliverPadding(
/// padding: const EdgeInsets.all(8.0),
/// // In this example, the inner scroll view has
/// // fixed-height list items, hence the use of
/// // SliverFixedExtentList. However, one could use any
/// // sliver widget here, e.g. SliverList or SliverGrid.
/// sliver: new SliverFixedExtentList(
/// // The items in this example are fixed to 48 pixels
/// // high. This matches the Material Design spec for
/// // ListTile widgets.
/// itemExtent: 48.0,
/// delegate: new SliverChildBuilderDelegate(
/// (BuildContext context, int index) {
/// // This builder is called for each child.
/// // In this example, we just number each list item.
/// return new ListTile(
/// title: new Text('Item $index'),
/// );
/// },
/// // The childCount of the SliverChildBuilderDelegate
/// // specifies how many children this inner list
/// // has. In this example, each tab has a list of
/// // exactly 30 items, but this is arbitrary.
/// childCount: 30,
/// ),
/// ),
/// ),
/// ],
/// );
/// },
/// ),
/// );
/// }).toList(),
/// ),
/// ),
/// )
/// ```
class NestedScrollView extends StatefulWidget { class NestedScrollView extends StatefulWidget {
/// Creates a nested scroll view. /// Creates a nested scroll view.
/// ///
/// The [reverse], [headerSliverBuilder], and [body] arguments must not be null. /// The [reverse], [headerSliverBuilder], and [body] arguments must not be
/// null.
const NestedScrollView({ const NestedScrollView({
Key key, Key key,
this.controller, this.controller,
...@@ -101,9 +245,19 @@ class NestedScrollView extends StatefulWidget { ...@@ -101,9 +245,19 @@ class NestedScrollView extends StatefulWidget {
/// Typically this will be [TabBarView]. /// Typically this will be [TabBarView].
/// ///
/// The [body] is built in a context that provides a [PrimaryScrollController] /// The [body] is built in a context that provides a [PrimaryScrollController]
/// that interacts with the [NestedScrollView]'s scroll controller. /// that interacts with the [NestedScrollView]'s scroll controller. Any
/// [ListView] or other [Scrollable]-based widget inside the [body] that is
/// intended to scroll with the [NestedScrollView] should therefore not be
/// given an explicit [ScrollController], instead allowing it to default to
/// the [PrimaryScrollController] provided by the [NestedScrollView].
final Widget body; final Widget body;
static SliverOverlapAbsorberHandle sliverOverlapAbsorberHandleFor(BuildContext context) {
final _InheritedNestedScrollView target = context.inheritFromWidgetOfExactType(_InheritedNestedScrollView);
assert(target != null, 'NestedScrollView.sliverOverlapAbsorberHandleFor must be called with a context that contains a NestedScrollView.');
return target.state._absorberHandle;
}
List<Widget> _buildSlivers(BuildContext context, ScrollController innerController, bool bodyIsScrolled) { List<Widget> _buildSlivers(BuildContext context, ScrollController innerController, bool bodyIsScrolled) {
final List<Widget> slivers = <Widget>[]; final List<Widget> slivers = <Widget>[];
slivers.addAll(headerSliverBuilder(context, bodyIsScrolled)); slivers.addAll(headerSliverBuilder(context, bodyIsScrolled));
...@@ -121,18 +275,27 @@ class NestedScrollView extends StatefulWidget { ...@@ -121,18 +275,27 @@ class NestedScrollView extends StatefulWidget {
} }
class _NestedScrollViewState extends State<NestedScrollView> { class _NestedScrollViewState extends State<NestedScrollView> {
final SliverOverlapAbsorberHandle _absorberHandle = new SliverOverlapAbsorberHandle();
_NestedScrollCoordinator _coordinator; _NestedScrollCoordinator _coordinator;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_coordinator = new _NestedScrollCoordinator(context, widget.controller); _coordinator = new _NestedScrollCoordinator(this, widget.controller, _handleHasScrolledBodyChanged);
} }
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
_coordinator.updateParent(); _coordinator.setParent(widget.controller);
}
@override
void didUpdateWidget(NestedScrollView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller)
_coordinator.setParent(widget.controller);
} }
@override @override
...@@ -142,20 +305,88 @@ class _NestedScrollViewState extends State<NestedScrollView> { ...@@ -142,20 +305,88 @@ class _NestedScrollViewState extends State<NestedScrollView> {
super.dispose(); super.dispose();
} }
void _handleHasScrolledBodyChanged() {
if (!mounted)
return;
setState(() { /* _coordinator.hasScrolledBody changed (we use it in the build method) */ });
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return new CustomScrollView( return new _InheritedNestedScrollView(
scrollDirection: widget.scrollDirection, state: this,
reverse: widget.reverse, child: new Builder(
physics: widget.physics != null builder: (BuildContext context) {
? widget.physics.applyTo(const ClampingScrollPhysics()) return new _NestedScrollViewCustomScrollView(
: const ClampingScrollPhysics(), scrollDirection: widget.scrollDirection,
controller: _coordinator._outerController, reverse: widget.reverse,
slivers: widget._buildSlivers(context, _coordinator._innerController, _coordinator.hasScrolledBody), physics: widget.physics != null
? widget.physics.applyTo(const ClampingScrollPhysics())
: const ClampingScrollPhysics(),
controller: _coordinator._outerController,
slivers: widget._buildSlivers(
context,
_coordinator._innerController,
_coordinator.hasScrolledBody,
),
handle: _absorberHandle,
);
},
),
); );
} }
} }
class _NestedScrollViewCustomScrollView extends CustomScrollView {
_NestedScrollViewCustomScrollView({
@required Axis scrollDirection,
@required bool reverse,
@required ScrollPhysics physics,
@required ScrollController controller,
@required List<Widget> slivers,
@required this.handle,
}) : super(
scrollDirection: scrollDirection,
reverse: reverse,
physics: physics,
controller: controller,
slivers: slivers,
);
final SliverOverlapAbsorberHandle handle;
@override
Widget buildViewport(
BuildContext context,
ViewportOffset offset,
AxisDirection axisDirection,
List<Widget> slivers,
) {
assert(!shrinkWrap);
return new NestedScrollViewViewport(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
handle: handle,
);
}
}
class _InheritedNestedScrollView extends InheritedWidget {
const _InheritedNestedScrollView({
Key key,
@required this.state,
@required Widget child,
}) : assert(state != null),
assert(child != null),
super(key: key, child: child);
final _NestedScrollViewState state;
@override
bool updateShouldNotify(_InheritedNestedScrollView old) => state != old.state;
}
class _NestedScrollMetrics extends FixedScrollMetrics { class _NestedScrollMetrics extends FixedScrollMetrics {
_NestedScrollMetrics({ _NestedScrollMetrics({
@required double minScrollExtent, @required double minScrollExtent,
...@@ -184,14 +415,16 @@ class _NestedScrollMetrics extends FixedScrollMetrics { ...@@ -184,14 +415,16 @@ class _NestedScrollMetrics extends FixedScrollMetrics {
typedef ScrollActivity _NestedScrollActivityGetter(_NestedScrollPosition position); typedef ScrollActivity _NestedScrollActivityGetter(_NestedScrollPosition position);
class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController { class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController {
_NestedScrollCoordinator(this._context, this._parent) { _NestedScrollCoordinator(this._state, this._parent, this._onHasScrolledBodyChanged) {
final double initialScrollOffset = _parent?.initialScrollOffset ?? 0.0; final double initialScrollOffset = _parent?.initialScrollOffset ?? 0.0;
_outerController = new _NestedScrollController(this, initialScrollOffset: initialScrollOffset, debugLabel: 'outer'); _outerController = new _NestedScrollController(this, initialScrollOffset: initialScrollOffset, debugLabel: 'outer');
_innerController = new _NestedScrollController(this, initialScrollOffset: 0.0, debugLabel: 'inner'); _innerController = new _NestedScrollController(this, initialScrollOffset: 0.0, debugLabel: 'inner');
} }
final BuildContext _context; final _NestedScrollViewState _state;
final ScrollController _parent; ScrollController _parent;
final VoidCallback _onHasScrolledBodyChanged;
_NestedScrollController _outerController; _NestedScrollController _outerController;
_NestedScrollController _innerController; _NestedScrollController _innerController;
...@@ -205,6 +438,13 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont ...@@ -205,6 +438,13 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
return _innerController.nestedPositions; return _innerController.nestedPositions;
} }
bool get canScrollBody {
final _NestedScrollPosition outer = _outerPosition;
if (outer == null)
return true;
return outer.haveDimensions && outer.extentAfter == 0.0;
}
bool get hasScrolledBody { bool get hasScrolledBody {
for (_NestedScrollPosition position in _innerPositions) { for (_NestedScrollPosition position in _innerPositions) {
if (position.pixels > position.minScrollExtent) if (position.pixels > position.minScrollExtent)
...@@ -213,6 +453,15 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont ...@@ -213,6 +453,15 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
return false; return false;
} }
bool _lastHasScrolledBody;
void updateShadow() {
final bool newHasScrolledBody = hasScrolledBody;
if (_lastHasScrolledBody != newHasScrolledBody) {
if (_onHasScrolledBodyChanged != null)
_onHasScrolledBodyChanged();
}
}
ScrollDirection get userScrollDirection => _userScrollDirection; ScrollDirection get userScrollDirection => _userScrollDirection;
ScrollDirection _userScrollDirection = ScrollDirection.idle; ScrollDirection _userScrollDirection = ScrollDirection.idle;
...@@ -263,9 +512,6 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont ...@@ -263,9 +512,6 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
} }
ScrollActivity createOuterBallisticScrollActivity(double velocity) { ScrollActivity createOuterBallisticScrollActivity(double velocity) {
// TODO(ianh): Refactor so this doesn't need to poke at the internals of the
// other classes here (e.g. calling through _outerPosition.physics)
// This function creates a ballistic scroll for the outer scrollable. // This function creates a ballistic scroll for the outer scrollable.
// //
// It assumes that the outer scrollable can't be overscrolled, and sets up a // It assumes that the outer scrollable can't be overscrolled, and sets up a
...@@ -528,8 +774,13 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont ...@@ -528,8 +774,13 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
} }
} }
void setParent(ScrollController value) {
_parent = value;
updateParent();
}
void updateParent() { void updateParent() {
_outerPosition?.setParent(_parent ?? PrimaryScrollController.of(_context)); _outerPosition?.setParent(_parent ?? PrimaryScrollController.of(_state.context));
} }
@mustCallSuper @mustCallSuper
...@@ -539,6 +790,9 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont ...@@ -539,6 +790,9 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
_outerController.dispose(); _outerController.dispose();
_innerController.dispose(); _innerController.dispose();
} }
@override
String toString() => '$runtimeType(outer=$_outerController; inner=$_innerController)';
} }
class _NestedScrollController extends ScrollController { class _NestedScrollController extends ScrollController {
...@@ -571,6 +825,27 @@ class _NestedScrollController extends ScrollController { ...@@ -571,6 +825,27 @@ class _NestedScrollController extends ScrollController {
super.attach(position); super.attach(position);
coordinator.updateParent(); coordinator.updateParent();
coordinator.updateCanDrag(); coordinator.updateCanDrag();
_scheduleUpdateShadow();
}
@override
void detach(ScrollPosition position) {
assert(position is _NestedScrollPosition);
super.detach(position);
_scheduleUpdateShadow();
}
void _scheduleUpdateShadow() {
// We do this asynchronously for attach() so that the new position has had
// time to be initialized, and we do it asynchronously for detach() because
// that happens synchronously during a frame, at a time where it's too late
// to call setState. Since the result is usually animated, the lag incurred
// is no big deal.
SchedulerBinding.instance.addPostFrameCallback(
(Duration timeStamp) {
coordinator.updateShadow();
}
);
} }
Iterable<_NestedScrollPosition> get nestedPositions sync* { Iterable<_NestedScrollPosition> get nestedPositions sync* {
...@@ -597,6 +872,7 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele ...@@ -597,6 +872,7 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele
if (activity == null) if (activity == null)
goIdle(); goIdle();
assert(activity != null); assert(activity != null);
saveScrollOffset(); // in case we didn't restore but could, so that we don't restore it later
} }
final _NestedScrollCoordinator coordinator; final _NestedScrollCoordinator coordinator;
...@@ -620,6 +896,12 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele ...@@ -620,6 +896,12 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele
activity.updateDelegate(this); activity.updateDelegate(this);
} }
@override
void restoreScrollOffset() {
if (coordinator.canScrollBody)
super.restoreScrollOffset();
}
// Returns the amount of delta that was not used. // Returns the amount of delta that was not used.
double applyClampedDragUpdate(double delta) { double applyClampedDragUpdate(double delta) {
assert(delta != 0.0); assert(delta != 0.0);
...@@ -863,3 +1145,503 @@ class _NestedOuterBallisticScrollActivity extends BallisticScrollActivity { ...@@ -863,3 +1145,503 @@ class _NestedOuterBallisticScrollActivity extends BallisticScrollActivity {
return '$runtimeType(${metrics.minRange} .. ${metrics.maxRange}; correcting by ${metrics.correctionOffset})'; return '$runtimeType(${metrics.minRange} .. ${metrics.maxRange}; correcting by ${metrics.correctionOffset})';
} }
} }
/// Handle to provide to a [SliverOverlapAbsorber], a [SliverOverlapInjector],
/// and an [NestedScrollViewViewport], to shift overlap in a [NestedScrollView].
///
/// A particular [SliverOverlapAbsorberHandle] can only be assigned to a single
/// [SliverOverlapAbsorber] at a time. It can also be (and normally is) assigned
/// to one or more [SliverOverlapInjector]s, which must be later descendants of
/// the same [NestedScrollViewViewport] as the [SliverOverlapAbsorber]. The
/// [SliverOverlapAbsorber] must be a direct descendant of the
/// [NestedScrollViewViewport], taking part in the same sliver layout. (The
/// [SliverOverlapInjector] can be a descendant that takes part in a nested
/// scroll view's sliver layout.)
///
/// Whenever the [NestedScrollViewViewport] is marked dirty for layout, it will
/// cause its assigned [SliverOverlapAbsorberHandle] to fire notifications. It
/// is the responsibility of the [SliverOverlapInjector]s (and any other
/// clients) to mark themselves dirty when this happens, in case the geometry
/// subsequently changes during layout.
///
/// See also:
///
/// * [NestedScrollView], which uses a [NestedScrollViewViewport] and a
/// [SliverOverlapAbsorber] to align its children, and which shows sample
/// usage for this class.
class SliverOverlapAbsorberHandle extends ChangeNotifier {
// Incremented when a RenderSliverOverlapAbsorber takes ownership of this
// object, decremented when it releases it. This allows us to find cases where
// the same handle is being passed to two render objects.
int _writers = 0;
/// The current amount of overlap being absorbed by the
/// [SliverOverlapAbsorber].
///
/// This corresponds to the [SliverGeometry.layoutExtent] of the child of the
/// [SliverOverlapAbsorber].
///
/// This is updated during the layout of the [SliverOverlapAbsorber]. It
/// should not change at any other time. No notifications are sent when it
/// changes; clients (e.g. [SliverOverlapInjector]s) are responsible for
/// marking themselves dirty whenever this object sends notifications, which
/// happens any time the [SliverOverlapAbsorber] might subsequently change the
/// value during that layout.
double get layoutExtent => _layoutExtent;
double _layoutExtent;
/// The total scroll extent of the gap being absorbed by the
/// [SliverOverlapAbsorber].
///
/// This corresponds to the [SliverGeometry.scrollExtent] of the child of the
/// [SliverOverlapAbsorber].
///
/// This is updated during the layout of the [SliverOverlapAbsorber]. It
/// should not change at any other time. No notifications are sent when it
/// changes; clients (e.g. [SliverOverlapInjector]s) are responsible for
/// marking themselves dirty whenever this object sends notifications, which
/// happens any time the [SliverOverlapAbsorber] might subsequently change the
/// value during that layout.
double get scrollExtent => _scrollExtent;
double _scrollExtent;
void _setExtents(double layoutValue, double scrollValue) {
assert(_writers == 1, 'Multiple RenderSliverOverlapAbsorbers have been provided the same SliverOverlapAbsorberHandle.');
_layoutExtent = layoutValue;
_scrollExtent = scrollValue;
}
void _markNeedsLayout() => notifyListeners();
@override
String toString() {
String extra;
switch (_writers) {
case 0:
extra = ', orphan';
break;
case 1:
// normal case
break;
default:
extra = ', $_writers WRITERS ASSIGNED';
break;
}
return '$runtimeType($layoutExtent$extra)';
}
}
/// A sliver that wraps another, forcing its layout extent to be treated as
/// overlap.
///
/// The difference between the overlap requested by the [child] sliver and the
/// overlap reported by this widget, called the _absorbed overlap_, is reported
/// to the [SliverOverlapAbsorberHandle], which is typically passed to a
/// [SliverOverlapInjector].
///
/// See also:
///
/// * [NestedScrollView], whose documentation has sample code showing how to
/// use this widget.
class SliverOverlapAbsorber extends SingleChildRenderObjectWidget {
/// Creates a sliver that absorbs overlap and reports it to a
/// [SliverOverlapAbsorberHandle].
///
/// The [handle] must not be null.
///
/// The [child] must be a sliver.
const SliverOverlapAbsorber({
Key key,
@required this.handle,
Widget child,
}) : assert(handle != null),
super(key: key, child: child);
/// The object in which the absorbed overlap is recorded.
///
/// A particular [SliverOverlapAbsorberHandle] can only be assigned to a
/// single [SliverOverlapAbsorber] at a time.
final SliverOverlapAbsorberHandle handle;
@override
RenderSliverOverlapAbsorber createRenderObject(BuildContext context) {
return new RenderSliverOverlapAbsorber(
handle: handle,
);
}
@override
void updateRenderObject(BuildContext context, RenderSliverOverlapAbsorber renderObject) {
renderObject
..handle = handle;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(new DiagnosticsProperty<SliverOverlapAbsorberHandle>('handle', handle));
}
}
/// A sliver that wraps another, forcing its layout extent to be treated as
/// overlap.
///
/// The difference between the overlap requested by the [child] sliver and the
/// overlap reported by this widget, called the _absorbed overlap_, is reported
/// to the [SliverOverlapAbsorberHandle], which is typically passed to a
/// [RenderSliverOverlapInjector].
class RenderSliverOverlapAbsorber extends RenderSliver with RenderObjectWithChildMixin<RenderSliver> {
/// Create a sliver that absorbs overlap and reports it to a
/// [SliverOverlapAbsorberHandle].
///
/// The [handle] must not be null.
///
/// The [child] must be a [RenderSliver].
RenderSliverOverlapAbsorber({
@required SliverOverlapAbsorberHandle handle,
RenderSliver child,
}) : assert(handle != null), _handle = handle {
this.child = child;
}
SliverOverlapAbsorberHandle get handle => _handle;
SliverOverlapAbsorberHandle _handle;
set handle(SliverOverlapAbsorberHandle value) {
assert(value != null);
if (handle == value)
return;
if (attached) {
handle._writers -= 1;
value._writers += 1;
value._setExtents(handle.layoutExtent, handle.scrollExtent);
}
_handle = value;
}
@override
void attach(PipelineOwner owner) {
super.attach(owner);
handle._writers += 1;
}
@override
void detach() {
handle._writers -= 1;
super.detach();
}
@override
void performLayout() {
assert(handle._writers == 1, 'A SliverOverlapAbsorberHandle cannot be passed to multiple RenderSliverOverlapAbsorber objects at the same time.');
if (child == null) {
geometry = const SliverGeometry();
return;
}
child.layout(constraints, parentUsesSize: true);
final SliverGeometry childLayoutGeometry = child.geometry;
geometry = new SliverGeometry(
scrollExtent: childLayoutGeometry.scrollExtent - childLayoutGeometry.maxScrollObstructionExtent,
paintExtent: childLayoutGeometry.paintExtent,
paintOrigin: childLayoutGeometry.paintOrigin,
layoutExtent: childLayoutGeometry.paintExtent - childLayoutGeometry.maxScrollObstructionExtent,
maxPaintExtent: childLayoutGeometry.maxPaintExtent,
maxScrollObstructionExtent: childLayoutGeometry.maxScrollObstructionExtent,
hitTestExtent: childLayoutGeometry.hitTestExtent,
visible: childLayoutGeometry.visible,
hasVisualOverflow: childLayoutGeometry.hasVisualOverflow,
scrollOffsetCorrection: childLayoutGeometry.scrollOffsetCorrection,
);
handle._setExtents(childLayoutGeometry.maxScrollObstructionExtent, childLayoutGeometry.maxScrollObstructionExtent);
}
@override
void applyPaintTransform(RenderObject child, Matrix4 transform) {
// child is always at our origin
}
@override
bool hitTestChildren(HitTestResult result, { @required double mainAxisPosition, @required double crossAxisPosition }) {
if (child != null)
return child.hitTest(result, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition);
return false;
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null)
context.paintChild(child, offset);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(new DiagnosticsProperty<SliverOverlapAbsorberHandle>('handle', handle));
}
}
/// A sliver that has a sliver geometry based on the values stored in a
/// [SliverOverlapAbsorberHandle].
///
/// The [SliverOverlapAbsorber] must be an earlier descendant of a common
/// ancestor [Viewport], so that it will always be laid out before the
/// [SliverOverlapInjector] during a particular frame.
///
/// See also:
///
/// * [NestedScrollView], which uses a [SliverOverlapAbsorber] to align its
/// children, and which shows sample usage for this class.
class SliverOverlapInjector extends SingleChildRenderObjectWidget {
/// Creates a sliver that is as tall as the value of the given [handle]'s
/// layout extent.
///
/// The [handle] must not be null.
const SliverOverlapInjector({
Key key,
@required this.handle,
Widget child,
}) : assert(handle != null),
super(key: key, child: child);
/// The handle to the [SliverOverlapAbsorber] that is feeding this injector.
///
/// This should be a handle owned by a [SliverOverlapAbsorber] and a
/// [NestedScrollViewViewport].
final SliverOverlapAbsorberHandle handle;
@override
RenderSliverOverlapInjector createRenderObject(BuildContext context) {
return new RenderSliverOverlapInjector(
handle: handle,
);
}
@override
void updateRenderObject(BuildContext context, RenderSliverOverlapInjector renderObject) {
renderObject
..handle = handle;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(new DiagnosticsProperty<SliverOverlapAbsorberHandle>('handle', handle));
}
}
/// A sliver that has a sliver geometry based on the values stored in a
/// [SliverOverlapAbsorberHandle].
///
/// The [RenderSliverOverlapAbsorber] must be an earlier descendant of a common
/// ancestor [RenderViewport] (probably a [RenderNestedScrollViewViewport]), so
/// that it will always be laid out before the [RenderSliverOverlapInjector]
/// during a particular frame.
class RenderSliverOverlapInjector extends RenderSliver {
/// Creates a sliver that is as tall as the value of the given [handle]'s extent.
///
/// The [handle] must not be null.
RenderSliverOverlapInjector({
@required SliverOverlapAbsorberHandle handle,
RenderSliver child,
}) : assert(handle != null), _handle = handle;
double _currentLayoutExtent;
double _currentMaxExtent;
/// The object that specifies how wide to make the gap injected by this render
/// object.
///
/// This should be a handle owned by a [RenderSliverOverlapAbsorber] and a
/// [RenderNestedScrollViewViewport].
SliverOverlapAbsorberHandle get handle => _handle;
SliverOverlapAbsorberHandle _handle;
set handle(SliverOverlapAbsorberHandle value) {
assert(value != null);
if (handle == value)
return;
if (attached) {
handle.removeListener(markNeedsLayout);
}
_handle = value;
if (attached) {
handle.addListener(markNeedsLayout);
if (handle.layoutExtent != _currentLayoutExtent ||
handle.scrollExtent != _currentMaxExtent)
markNeedsLayout();
}
}
@override
void attach(PipelineOwner owner) {
super.attach(owner);
handle.addListener(markNeedsLayout);
if (handle.layoutExtent != _currentLayoutExtent ||
handle.scrollExtent != _currentMaxExtent)
markNeedsLayout();
}
@override
void detach() {
handle.removeListener(markNeedsLayout);
super.detach();
}
@override
void performLayout() {
_currentLayoutExtent = handle.layoutExtent;
_currentMaxExtent = handle.layoutExtent;
final double clampedLayoutExtent = math.min(_currentLayoutExtent - constraints.scrollOffset, constraints.remainingPaintExtent);
geometry = new SliverGeometry(
scrollExtent: _currentLayoutExtent,
paintExtent: math.max(0.0, clampedLayoutExtent),
maxPaintExtent: _currentMaxExtent,
);
}
@override
void debugPaint(PaintingContext context, Offset offset) {
assert(() {
if (debugPaintSizeEnabled) {
final Paint paint = new Paint()
..color = const Color(0xFFCC9933)
..strokeWidth = 3.0
..style = PaintingStyle.stroke;
Offset start, end, delta;
switch (constraints.axis) {
case Axis.vertical:
final double x = offset.dx + constraints.crossAxisExtent / 2.0;
start = new Offset(x, offset.dy);
end = new Offset(x, offset.dy + geometry.paintExtent);
delta = new Offset(constraints.crossAxisExtent / 5.0, 0.0);
break;
case Axis.horizontal:
final double y = offset.dy + constraints.crossAxisExtent / 2.0;
start = new Offset(offset.dx, y);
end = new Offset(offset.dy + geometry.paintExtent, y);
delta = new Offset(0.0, constraints.crossAxisExtent / 5.0);
break;
}
for (int index = -2; index <= 2; index += 1) {
paintZigZag(context.canvas, paint, start - delta * index.toDouble(), end - delta * index.toDouble(), 10, 10.0);
}
}
return true;
}());
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(new DiagnosticsProperty<SliverOverlapAbsorberHandle>('handle', handle));
}
}
/// The [Viewport] variant used by [NestedScrollView].
///
/// This viewport takes a [SliverOverlapAbsorberHandle] and notifies it any time
/// the viewport needs to recompute its layout (e.g. when it is scrolled).
class NestedScrollViewViewport extends Viewport {
/// Creates a variant of [Viewport] that has a [SliverOverlapAbsorberHandle].
///
/// The [handle] must not be null.
NestedScrollViewViewport({
Key key,
AxisDirection axisDirection: AxisDirection.down,
AxisDirection crossAxisDirection,
double anchor: 0.0,
@required ViewportOffset offset,
Key center,
List<Widget> slivers: const <Widget>[],
@required this.handle,
}) : assert(handle != null),
super(
key: key,
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection,
anchor: anchor,
offset: offset,
center: center,
slivers: slivers,
);
/// The handle to the [SliverOverlapAbsorber] that is feeding this injector.
final SliverOverlapAbsorberHandle handle;
@override
RenderNestedScrollViewViewport createRenderObject(BuildContext context) {
return new RenderNestedScrollViewViewport(
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection),
anchor: anchor,
offset: offset,
handle: handle,
);
}
@override
void updateRenderObject(BuildContext context, RenderNestedScrollViewViewport renderObject) {
renderObject
..axisDirection = axisDirection
..crossAxisDirection = crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection)
..anchor = anchor
..offset = offset
..handle = handle;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(new DiagnosticsProperty<SliverOverlapAbsorberHandle>('handle', handle));
}
}
/// The [RenderViewport] variant used by [NestedScrollView].
///
/// This viewport takes a [SliverOverlapAbsorberHandle] and notifies it any time
/// the viewport needs to recompute its layout (e.g. when it is scrolled).
class RenderNestedScrollViewViewport extends RenderViewport {
/// Create a variant of [RenderViewport] that has a [SliverOverlapAbsorberHandle].
///
/// The [handle] must not be null.
RenderNestedScrollViewViewport({
AxisDirection axisDirection: AxisDirection.down,
@required AxisDirection crossAxisDirection,
@required ViewportOffset offset,
double anchor: 0.0,
List<RenderSliver> children,
RenderSliver center,
@required SliverOverlapAbsorberHandle handle,
}) : assert(handle != null),
_handle = handle,
super(
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection,
offset: offset,
anchor: anchor,
children: children,
center: center,
);
/// The object to notify when [markNeedsLayout] is called.
SliverOverlapAbsorberHandle get handle => _handle;
SliverOverlapAbsorberHandle _handle;
/// Setting this will trigger notifications on the new object.
set handle(SliverOverlapAbsorberHandle value) {
assert(value != null);
if (handle == value)
return;
_handle = value;
handle._markNeedsLayout();
}
@override
void markNeedsLayout() {
handle._markNeedsLayout();
super.markNeedsLayout();
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(new DiagnosticsProperty<SliverOverlapAbsorberHandle>('handle', handle));
}
}
...@@ -26,7 +26,8 @@ import 'scroll_position_with_single_context.dart'; ...@@ -26,7 +26,8 @@ import 'scroll_position_with_single_context.dart';
/// ///
/// A [ScrollController] is a [Listenable]. It notifies its listeners whenever /// A [ScrollController] is a [Listenable]. It notifies its listeners whenever
/// any of the attached [ScrollPosition]s notify _their_ listeners (i.e. /// any of the attached [ScrollPosition]s notify _their_ listeners (i.e.
/// whenever any of them scroll). /// whenever any of them scroll). It does not notify its listeners when the list
/// of attached [ScrollPosition]s changes.
/// ///
/// Typically used with [ListView], [GridView], [CustomScrollView]. /// Typically used with [ListView], [GridView], [CustomScrollView].
/// ///
......
...@@ -479,7 +479,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { ...@@ -479,7 +479,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
/// This notifier's value is true if a scroll is underway and false if the scroll /// This notifier's value is true if a scroll is underway and false if the scroll
/// position is idle. /// position is idle.
/// ///
/// Listeners added by stateful widgets should be in the widget's /// Listeners added by stateful widgets should be removed in the widget's
/// [State.dispose] method. /// [State.dispose] method.
final ValueNotifier<bool> isScrollingNotifier = new ValueNotifier<bool>(false); final ValueNotifier<bool> isScrollingNotifier = new ValueNotifier<bool>(false);
......
...@@ -182,11 +182,39 @@ abstract class ScrollView extends StatelessWidget { ...@@ -182,11 +182,39 @@ abstract class ScrollView extends StatelessWidget {
return getAxisDirectionFromAxisReverseAndDirectionality(context, scrollDirection, reverse); return getAxisDirectionFromAxisReverseAndDirectionality(context, scrollDirection, reverse);
} }
/// Build the list of widgets to place inside the viewport.
///
/// Subclasses should override this method to build the slivers for the inside /// Subclasses should override this method to build the slivers for the inside
/// of the viewport. /// of the viewport.
@protected @protected
List<Widget> buildSlivers(BuildContext context); List<Widget> buildSlivers(BuildContext context);
/// Build the viewport.
///
/// Subclasses may override this method to change how the viewport is built.
/// The default implementation uses a [ShrinkWrappingViewport] if [shrinkWrap]
/// is true, and a regular [Viewport] otherwise.
@protected
Widget buildViewport(
BuildContext context,
ViewportOffset offset,
AxisDirection axisDirection,
List<Widget> slivers,
) {
if (shrinkWrap) {
return new ShrinkWrappingViewport(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
);
}
return new Viewport(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final List<Widget> slivers = buildSlivers(context); final List<Widget> slivers = buildSlivers(context);
...@@ -200,20 +228,8 @@ abstract class ScrollView extends StatelessWidget { ...@@ -200,20 +228,8 @@ abstract class ScrollView extends StatelessWidget {
controller: scrollController, controller: scrollController,
physics: physics, physics: physics,
viewportBuilder: (BuildContext context, ViewportOffset offset) { viewportBuilder: (BuildContext context, ViewportOffset offset) {
if (shrinkWrap) { return buildViewport(context, offset, axisDirection, slivers);
return new ShrinkWrappingViewport( },
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
);
} else {
return new Viewport(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
);
}
}
); );
return primary && scrollController != null return primary && scrollController != null
? new PrimaryScrollController.none(child: scrollable) ? new PrimaryScrollController.none(child: scrollable)
......
...@@ -12,21 +12,6 @@ export 'package:flutter/rendering.dart' show ...@@ -12,21 +12,6 @@ export 'package:flutter/rendering.dart' show
AxisDirection, AxisDirection,
GrowthDirection; GrowthDirection;
AxisDirection _getDefaultCrossAxisDirection(BuildContext context, AxisDirection axisDirection) {
assert(axisDirection != null);
switch (axisDirection) {
case AxisDirection.up:
return textDirectionToAxisDirection(Directionality.of(context));
case AxisDirection.right:
return AxisDirection.down;
case AxisDirection.down:
return textDirectionToAxisDirection(Directionality.of(context));
case AxisDirection.left:
return AxisDirection.down;
}
return null;
}
/// A widget that is bigger on the inside. /// A widget that is bigger on the inside.
/// ///
/// [Viewport] is the visual workhorse of the scrolling machinery. It displays a /// [Viewport] is the visual workhorse of the scrolling machinery. It displays a
...@@ -124,11 +109,31 @@ class Viewport extends MultiChildRenderObjectWidget { ...@@ -124,11 +109,31 @@ class Viewport extends MultiChildRenderObjectWidget {
/// The [center] must be the key of a child of the viewport. /// The [center] must be the key of a child of the viewport.
final Key center; final Key center;
/// Given a [BuildContext] and an [AxisDirection], determine the correct cross
/// axis direction.
///
/// This depends on the [Directionality] if the `axisDirection` is vertical;
/// otherwise, the default cross axis direction is downwards.
static AxisDirection getDefaultCrossAxisDirection(BuildContext context, AxisDirection axisDirection) {
assert(axisDirection != null);
switch (axisDirection) {
case AxisDirection.up:
return textDirectionToAxisDirection(Directionality.of(context));
case AxisDirection.right:
return AxisDirection.down;
case AxisDirection.down:
return textDirectionToAxisDirection(Directionality.of(context));
case AxisDirection.left:
return AxisDirection.down;
}
return null;
}
@override @override
RenderViewport createRenderObject(BuildContext context) { RenderViewport createRenderObject(BuildContext context) {
return new RenderViewport( return new RenderViewport(
axisDirection: axisDirection, axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection ?? _getDefaultCrossAxisDirection(context, axisDirection), crossAxisDirection: crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection),
anchor: anchor, anchor: anchor,
offset: offset, offset: offset,
); );
...@@ -138,7 +143,7 @@ class Viewport extends MultiChildRenderObjectWidget { ...@@ -138,7 +143,7 @@ class Viewport extends MultiChildRenderObjectWidget {
void updateRenderObject(BuildContext context, RenderViewport renderObject) { void updateRenderObject(BuildContext context, RenderViewport renderObject) {
renderObject renderObject
..axisDirection = axisDirection ..axisDirection = axisDirection
..crossAxisDirection = crossAxisDirection ?? _getDefaultCrossAxisDirection(context, axisDirection) ..crossAxisDirection = crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection)
..anchor = anchor ..anchor = anchor
..offset = offset; ..offset = offset;
} }
...@@ -271,7 +276,7 @@ class ShrinkWrappingViewport extends MultiChildRenderObjectWidget { ...@@ -271,7 +276,7 @@ class ShrinkWrappingViewport extends MultiChildRenderObjectWidget {
RenderShrinkWrappingViewport createRenderObject(BuildContext context) { RenderShrinkWrappingViewport createRenderObject(BuildContext context) {
return new RenderShrinkWrappingViewport( return new RenderShrinkWrappingViewport(
axisDirection: axisDirection, axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection ?? _getDefaultCrossAxisDirection(context, axisDirection), crossAxisDirection: crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection),
offset: offset, offset: offset,
); );
} }
...@@ -280,7 +285,7 @@ class ShrinkWrappingViewport extends MultiChildRenderObjectWidget { ...@@ -280,7 +285,7 @@ class ShrinkWrappingViewport extends MultiChildRenderObjectWidget {
void updateRenderObject(BuildContext context, RenderShrinkWrappingViewport renderObject) { void updateRenderObject(BuildContext context, RenderShrinkWrappingViewport renderObject) {
renderObject renderObject
..axisDirection = axisDirection ..axisDirection = axisDirection
..crossAxisDirection = crossAxisDirection ?? _getDefaultCrossAxisDirection(context, axisDirection) ..crossAxisDirection = crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection)
..offset = offset; ..offset = offset;
} }
......
...@@ -292,6 +292,23 @@ abstract class PaintPattern { ...@@ -292,6 +292,23 @@ abstract class PaintPattern {
/// If no call to [Canvas.drawParagraph] was made, then this results in failure. /// If no call to [Canvas.drawParagraph] was made, then this results in failure.
void paragraph({ ui.Paragraph paragraph, dynamic offset }); void paragraph({ ui.Paragraph paragraph, dynamic offset });
/// Indicates that a shadow is expected next.
///
/// The next shadow is examined. Any arguments that are passed to this method
/// are compared to the actual [Canvas.drawShadow] call's `paint` argument,
/// and any mismatches result in failure.
///
/// To introspect the Path object (as it stands after the painting has
/// completed), the `includes` and `excludes` arguments can be provided to
/// specify points that should be considered inside or outside the path
/// (respectively).
///
/// If no call to [Canvas.drawShadow] was made, then this results in failure.
///
/// Any calls made between the last matched call (if any) and the
/// [Canvas.drawShadow] call are ignored.
void shadow({ Iterable<Offset> includes, Iterable<Offset> excludes, Color color, double elevation, bool transparentOccluder });
/// Indicates that an image is expected next. /// Indicates that an image is expected next.
/// ///
/// The next call to [Canvas.drawImage] is examined, and its arguments /// The next call to [Canvas.drawImage] is examined, and its arguments
...@@ -637,6 +654,11 @@ class _TestRecordingCanvasPatternMatcher extends _TestRecordingCanvasMatcher imp ...@@ -637,6 +654,11 @@ class _TestRecordingCanvasPatternMatcher extends _TestRecordingCanvasMatcher imp
_predicates.add(new _FunctionPaintPredicate(#drawParagraph, <dynamic>[paragraph, offset])); _predicates.add(new _FunctionPaintPredicate(#drawParagraph, <dynamic>[paragraph, offset]));
} }
@override
void shadow({ Iterable<Offset> includes, Iterable<Offset> excludes, Color color, double elevation, bool transparentOccluder }) {
_predicates.add(new _ShadowPredicate(includes: includes, excludes: excludes, color: color, elevation: elevation, transparentOccluder: transparentOccluder));
}
@override @override
void image({ ui.Image image, double x, double y, Color color, double strokeWidth, bool hasMaskFilter, PaintingStyle style }) { void image({ ui.Image image, double x, double y, Color color, double strokeWidth, bool hasMaskFilter, PaintingStyle style }) {
_predicates.add(new _DrawImagePaintPredicate(image: image, x: x, y: y, color: color, strokeWidth: strokeWidth, hasMaskFilter: hasMaskFilter, style: style)); _predicates.add(new _DrawImagePaintPredicate(image: image, x: x, y: y, color: color, strokeWidth: strokeWidth, hasMaskFilter: hasMaskFilter, style: style));
...@@ -709,6 +731,24 @@ class _TestRecordingCanvasPatternMatcher extends _TestRecordingCanvasMatcher imp ...@@ -709,6 +731,24 @@ class _TestRecordingCanvasPatternMatcher extends _TestRecordingCanvasMatcher imp
abstract class _PaintPredicate { abstract class _PaintPredicate {
void match(Iterator<RecordedInvocation> call); void match(Iterator<RecordedInvocation> call);
@protected
void checkMethod(Iterator<RecordedInvocation> call, Symbol symbol) {
int others = 0;
final RecordedInvocation firstCall = call.current;
while (!call.current.invocation.isMethod || call.current.invocation.memberName != symbol) {
others += 1;
if (!call.moveNext())
throw new _MismatchedCall(
'It called $others other method${ others == 1 ? "" : "s" } on the canvas, '
'the first of which was $firstCall, but did not '
'call ${_symbolName(symbol)}() at the time where $this was expected.',
'The first method that was called when the call to ${_symbolName(symbol)}() '
'was expected, $firstCall, was called with the following stack:',
firstCall,
);
}
}
@override @override
String toString() { String toString() {
throw new FlutterError('$runtimeType does not implement toString.'); throw new FlutterError('$runtimeType does not implement toString.');
...@@ -734,19 +774,7 @@ abstract class _DrawCommandPaintPredicate extends _PaintPredicate { ...@@ -734,19 +774,7 @@ abstract class _DrawCommandPaintPredicate extends _PaintPredicate {
@override @override
void match(Iterator<RecordedInvocation> call) { void match(Iterator<RecordedInvocation> call) {
int others = 0; checkMethod(call, symbol);
final RecordedInvocation firstCall = call.current;
while (!call.current.invocation.isMethod || call.current.invocation.memberName != symbol) {
others += 1;
if (!call.moveNext())
throw new _MismatchedCall(
'It called $others other method${ others == 1 ? "" : "s" } on the canvas, '
'the first of which was $firstCall, but did not '
'call $methodName at the time where $this was expected.',
'The stack for the call to $firstCall was:',
firstCall,
);
}
final int actualArgumentCount = call.current.invocation.positionalArguments.length; final int actualArgumentCount = call.current.invocation.positionalArguments.length;
if (actualArgumentCount != argumentCount) if (actualArgumentCount != argumentCount)
throw 'It called $methodName with $actualArgumentCount argument${actualArgumentCount == 1 ? "" : "s"}; expected $argumentCount.'; throw 'It called $methodName with $actualArgumentCount argument${actualArgumentCount == 1 ? "" : "s"}; expected $argumentCount.';
...@@ -1008,6 +1036,81 @@ class _ArcPaintPredicate extends _DrawCommandPaintPredicate { ...@@ -1008,6 +1036,81 @@ class _ArcPaintPredicate extends _DrawCommandPaintPredicate {
); );
} }
class _ShadowPredicate extends _PaintPredicate {
_ShadowPredicate({ this.includes, this.excludes, this.color, this.elevation, this.transparentOccluder });
final Iterable<Offset> includes;
final Iterable<Offset> excludes;
final Color color;
final double elevation;
final bool transparentOccluder;
static const Symbol symbol = #drawShadow;
String get methodName => _symbolName(symbol);
@protected
void verifyArguments(List<dynamic> arguments) {
if (arguments.length != 4)
throw 'It called $methodName with ${arguments.length} arguments; expected 4.';
final Path pathArgument = arguments[0];
if (includes != null) {
for (Offset offset in includes) {
if (!pathArgument.contains(offset))
throw 'It called $methodName with a path that unexpectedly did not contain $offset.';
}
}
if (excludes != null) {
for (Offset offset in excludes) {
if (pathArgument.contains(offset))
throw 'It called $methodName with a path that unexpectedly contained $offset.';
}
}
final Color actualColor = arguments[1];
if (color != null && actualColor != color)
throw 'It called $methodName with a color, $actualColor, which was not exactly the expected color ($color).';
final double actualElevation = arguments[2];
if (elevation != null && actualElevation != elevation)
throw 'It called $methodName with an elevation, $actualElevation, which was not exactly the expected value ($elevation).';
final bool actualTransparentOccluder = arguments[3];
if (transparentOccluder != null && actualTransparentOccluder != transparentOccluder)
throw 'It called $methodName with a transparentOccluder value, $actualTransparentOccluder, which was not exactly the expected value ($transparentOccluder).';
}
@override
void match(Iterator<RecordedInvocation> call) {
checkMethod(call, symbol);
verifyArguments(call.current.invocation.positionalArguments);
call.moveNext();
}
@protected
void debugFillDescription(List<String> description) {
if (includes != null && excludes != null) {
description.add('that contains $includes and does not contain $excludes');
} else if (includes != null) {
description.add('that contains $includes');
} else if (excludes != null) {
description.add('that does not contain $excludes');
}
if (color != null)
description.add('$color');
if (elevation != null)
description.add('elevation: $elevation');
if (transparentOccluder != null)
description.add('transparentOccluder: $transparentOccluder');
}
@override
String toString() {
final List<String> description = <String>[];
debugFillDescription(description);
String result = methodName;
if (description.isNotEmpty)
result += ' with ${description.join(", ")}';
return result;
}
}
class _DrawImagePaintPredicate extends _DrawCommandPaintPredicate { class _DrawImagePaintPredicate extends _DrawCommandPaintPredicate {
_DrawImagePaintPredicate({ this.image, this.x, this.y, Color color, double strokeWidth, bool hasMaskFilter, PaintingStyle style }) : super( _DrawImagePaintPredicate({ this.image, this.x, this.y, Color color, double strokeWidth, bool hasMaskFilter, PaintingStyle style }) : super(
#drawImage, 'an image', 3, 2, color: color, strokeWidth: strokeWidth, hasMaskFilter: hasMaskFilter, style: style #drawImage, 'an image', 3, 2, color: color, strokeWidth: strokeWidth, hasMaskFilter: hasMaskFilter, style: style
...@@ -1128,20 +1231,7 @@ class _FunctionPaintPredicate extends _PaintPredicate { ...@@ -1128,20 +1231,7 @@ class _FunctionPaintPredicate extends _PaintPredicate {
@override @override
void match(Iterator<RecordedInvocation> call) { void match(Iterator<RecordedInvocation> call) {
int others = 0; checkMethod(call, symbol);
final RecordedInvocation firstCall = call.current;
while (!call.current.invocation.isMethod || call.current.invocation.memberName != symbol) {
others += 1;
if (!call.moveNext())
throw new _MismatchedCall(
'It called $others other method${ others == 1 ? "" : "s" } on the canvas, '
'the first of which was $firstCall, but did not '
'call ${_symbolName(symbol)}() at the time where $this was expected.',
'The first method that was called when the call to ${_symbolName(symbol)}() '
'was expected, $firstCall, was called with the following stack:',
firstCall,
);
}
if (call.current.invocation.positionalArguments.length != arguments.length) if (call.current.invocation.positionalArguments.length != arguments.length)
throw 'It called ${_symbolName(symbol)} with ${call.current.invocation.positionalArguments.length} arguments; expected ${arguments.length}.'; throw 'It called ${_symbolName(symbol)} with ${call.current.invocation.positionalArguments.length} arguments; expected ${arguments.length}.';
for (int index = 0; index < arguments.length; index += 1) { for (int index = 0; index < arguments.length; index += 1) {
...@@ -1169,20 +1259,7 @@ class _FunctionPaintPredicate extends _PaintPredicate { ...@@ -1169,20 +1259,7 @@ class _FunctionPaintPredicate extends _PaintPredicate {
class _SaveRestorePairPaintPredicate extends _PaintPredicate { class _SaveRestorePairPaintPredicate extends _PaintPredicate {
@override @override
void match(Iterator<RecordedInvocation> call) { void match(Iterator<RecordedInvocation> call) {
int others = 0; checkMethod(call, #save);
final RecordedInvocation firstCall = call.current;
while (!call.current.invocation.isMethod || call.current.invocation.memberName != #save) {
others += 1;
if (!call.moveNext())
throw new _MismatchedCall(
'It called $others other method${ others == 1 ? "" : "s" } on the canvas, '
'the first of which was $firstCall, but did not '
'call save() at the time where $this was expected.',
'The first method that was called when the call to save() '
'was expected, $firstCall, was called with the following stack:',
firstCall,
);
}
int depth = 1; int depth = 1;
while (depth > 0) { while (depth > 0) {
if (!call.moveNext()) if (!call.moveNext())
......
...@@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart'; ...@@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart';
class _CustomPhysics extends ClampingScrollPhysics { class _CustomPhysics extends ClampingScrollPhysics {
const _CustomPhysics({ ScrollPhysics parent }) : super(parent: parent); const _CustomPhysics({ ScrollPhysics parent }) : super(parent: parent);
...@@ -341,4 +343,181 @@ void main() { ...@@ -341,4 +343,181 @@ void main() {
expect(point1.dy, greaterThan(point2.dy)); expect(point1.dy, greaterThan(point2.dy));
}); });
testWidgets('NestedScrollView and internal scrolling', (WidgetTester tester) async {
final List<String> _tabs = <String>['Hello', 'World'];
await tester.pumpWidget(
new MaterialApp(home: new Material(child:
// THE FOLLOWING SECTION IS FROM THE NestedScrollView DOCUMENTATION
new DefaultTabController(
length: _tabs.length, // This is the number of tabs.
child: new NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
// These are the slivers that show up in the "outer" scroll view.
return <Widget>[
new SliverOverlapAbsorber(
// This widget takes the overlapping behavior of the SliverAppBar,
// and redirects it to the SliverOverlapInjector below. If it is
// missing, then it is possible for the nested "inner" scroll view
// below to end up under the SliverAppBar even when the inner
// scroll view thinks it has not been scrolled.
// This is not necessary if the "headerSliverBuilder" only builds
// widgets that do not overlap the next sliver.
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
child: new SliverAppBar(
title: const Text('Books'), // This is the title in the app bar.
pinned: true,
expandedHeight: 150.0,
// The "forceElevated" property causes the SliverAppBar to show
// a shadow. The "innerBoxIsScrolled" parameter is true when the
// inner scroll view is scrolled beyond its "zero" point, i.e.
// when it appears to be scrolled below the SliverAppBar.
// Without this, there are cases where the shadow would appear
// or not appear inappropriately, because the SliverAppBar is
// not actually aware of the precise position of the inner
// scroll views.
forceElevated: innerBoxIsScrolled,
bottom: new TabBar(
// These are the widgets to put in each tab in the tab bar.
tabs: _tabs.map((String name) => new Tab(text: name)).toList(),
),
),
),
];
},
body: new TabBarView(
// These are the contents of the tab views, below the tabs.
children: _tabs.map((String name) {
return new SafeArea(
top: false,
bottom: false,
child: new Builder(
// This Builder is needed to provide a BuildContext that is "inside"
// the NestedScrollView, so that sliverOverlapAbsorberHandleFor() can
// find the NestedScrollView.
builder: (BuildContext context) {
return new CustomScrollView(
// The "controller" and "primary" members should be left
// unset, so that the NestedScrollView can control this
// inner scroll view.
// If the "controller" property is set, then this scroll
// view will not be associated with the NestedScrollView.
// The PageStorageKey should be unique to this ScrollView;
// it allows the list to remember its scroll position when
// the tab view is not on the screen.
key: new PageStorageKey<String>(name),
slivers: <Widget>[
new SliverOverlapInjector(
// This is the flip side of the SliverOverlapAbsorber above.
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
new SliverPadding(
padding: const EdgeInsets.all(8.0),
// In this example, the inner scroll view has
// fixed-height list items, hence the use of
// SliverFixedExtentList. However, one could use any
// sliver widget here, e.g. SliverList or SliverGrid.
sliver: new SliverFixedExtentList(
// The items in this example are fixed to 48 pixels
// high. This matches the Material Design spec for
// ListTile widgets.
itemExtent: 48.0,
delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
// This builder is called for each child.
// In this example, we just number each list item.
return new ListTile(
title: new Text('Item $index'),
);
},
// The childCount of the SliverChildBuilderDelegate
// specifies how many children this inner list
// has. In this example, each tab has a list of
// exactly 30 items, but this is arbitrary.
childCount: 30,
),
),
),
],
);
},
),
);
}).toList(),
),
),
)
// END
)),
);
expect(find.text('Item 2'), findsOneWidget);
expect(find.byType(NestedScrollView), isNot(paints..shadow()));
// scroll down
final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.text('Item 2')));
await gesture1.moveBy(const Offset(0.0, -800.0));
await tester.pump(); // start shadow animation
expect(find.text('Item 2'), findsNothing);
expect(find.text('Item 18'), findsOneWidget);
await gesture1.up();
await tester.pump(const Duration(seconds: 1)); // end shadow animation
expect(find.byType(NestedScrollView), paints..shadow());
// swipe left to bring in tap on the right
final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.byType(NestedScrollView)));
await gesture2.moveBy(const Offset(-400.0, 0.0));
await tester.pump();
expect(find.text('Item 18'), findsOneWidget);
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Item 0'), findsOneWidget);
expect(tester.getTopLeft(find.ancestor(of: find.text('Item 0'), matching: find.byType(ListTile))).dy,
tester.getBottomLeft(find.byType(AppBar)).dy + 8.0);
expect(find.byType(NestedScrollView), paints..shadow());
await gesture2.up();
await tester.pump(); // start sideways scroll
await tester.pump(const Duration(seconds: 1)); // end sideways scroll
await tester.pump(); // start shadow going away
await tester.pump(const Duration(seconds: 1)); // end shadow going away
expect(find.text('Item 18'), findsNothing);
expect(find.text('Item 2'), findsOneWidget);
expect(find.byType(NestedScrollView), isNot(paints..shadow()));
// peek left to see it's still in the right place
final TestGesture gesture3 = await tester.startGesture(tester.getCenter(find.byType(NestedScrollView)));
await gesture3.moveBy(const Offset(400.0, 0.0));
await tester.pump(); // bring the left page into view
await tester.pump(); // shadow comes back starting here
expect(find.text('Item 18'), findsOneWidget);
expect(find.text('Item 2'), findsOneWidget);
expect(find.byType(NestedScrollView), isNot(paints..shadow()));
await tester.pump(const Duration(seconds: 1)); // shadow finishes coming back
expect(find.byType(NestedScrollView), paints..shadow());
await gesture3.moveBy(const Offset(-400.0, 0.0));
await gesture3.up();
await tester.pump(); // left tab view goes away
await tester.pump(); // shadow goes away starting here
expect(find.byType(NestedScrollView), paints..shadow());
await tester.pump(const Duration(seconds: 1)); // shadow finishes going away
expect(find.byType(NestedScrollView), isNot(paints..shadow()));
// scroll back up
final TestGesture gesture4 = await tester.startGesture(tester.getCenter(find.byType(NestedScrollView)));
await gesture4.moveBy(const Offset(0.0, 200.0)); // expands the appbar again
await tester.pump();
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Item 18'), findsNothing);
expect(find.byType(NestedScrollView), isNot(paints..shadow()));
await gesture4.up();
await tester.pump(const Duration(seconds: 1));
expect(find.byType(NestedScrollView), isNot(paints..shadow()));
// peek left to see it's now back at zero
final TestGesture gesture5 = await tester.startGesture(tester.getCenter(find.byType(NestedScrollView)));
await gesture5.moveBy(const Offset(400.0, 0.0));
await tester.pump(); // bring the left page into view
await tester.pump(); // shadow would come back starting here, but there's no shadow to show
expect(find.text('Item 18'), findsNothing);
expect(find.text('Item 2'), findsNWidgets(2));
expect(find.byType(NestedScrollView), isNot(paints..shadow()));
await tester.pump(const Duration(seconds: 1)); // shadow would be finished coming back
expect(find.byType(NestedScrollView), isNot(paints..shadow()));
await gesture5.up();
await tester.pump(); // right tab view goes away
await tester.pumpAndSettle();
expect(find.byType(NestedScrollView), isNot(paints..shadow()));
});
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment