• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2016 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:math' as math;
6
7import 'package:flutter/animation.dart';
8import 'package:flutter/foundation.dart';
9import 'package:flutter/gestures.dart';
10import 'package:flutter/scheduler.dart';
11import 'package:flutter/semantics.dart';
12import 'package:vector_math/vector_math_64.dart';
13
14import 'box.dart';
15import 'object.dart';
16import 'sliver.dart';
17import 'viewport.dart';
18import 'viewport_offset.dart';
19
20/// A base class for slivers that have a [RenderBox] child which scrolls
21/// normally, except that when it hits the leading edge (typically the top) of
22/// the viewport, it shrinks to a minimum size ([minExtent]).
23///
24/// This class primarily provides helpers for managing the child, in particular:
25///
26///  * [layoutChild], which applies min and max extents and a scroll offset to
27///    lay out the child. This is normally called from [performLayout].
28///
29///  * [childExtent], to convert the child's box layout dimensions to the sliver
30///    geometry model.
31///
32///  * hit testing, painting, and other details of the sliver protocol.
33///
34/// Subclasses must implement [performLayout], [minExtent], and [maxExtent], and
35/// typically also will implement [updateChild].
36abstract class RenderSliverPersistentHeader extends RenderSliver with RenderObjectWithChildMixin<RenderBox>, RenderSliverHelpers {
37  /// Creates a sliver that changes its size when scrolled to the start of the
38  /// viewport.
39  ///
40  /// This is an abstract class; this constructor only initializes the [child].
41  RenderSliverPersistentHeader({ RenderBox child }) {
42    this.child = child;
43  }
44
45  /// The biggest that this render object can become, in the main axis direction.
46  ///
47  /// This value should not be based on the child. If it changes, call
48  /// [markNeedsLayout].
49  double get maxExtent;
50
51  /// The smallest that this render object can become, in the main axis direction.
52  ///
53  /// If this is based on the intrinsic dimensions of the child, the child
54  /// should be measured during [updateChild] and the value cached and returned
55  /// here. The [updateChild] method will automatically be invoked any time the
56  /// child changes its intrinsic dimensions.
57  double get minExtent;
58
59  /// The dimension of the child in the main axis.
60  @protected
61  double get childExtent {
62    if (child == null)
63      return 0.0;
64    assert(child.hasSize);
65    assert(constraints.axis != null);
66    switch (constraints.axis) {
67      case Axis.vertical:
68        return child.size.height;
69      case Axis.horizontal:
70        return child.size.width;
71    }
72    return null;
73  }
74
75  bool _needsUpdateChild = true;
76  double _lastShrinkOffset = 0.0;
77  bool _lastOverlapsContent = false;
78
79  /// Update the child render object if necessary.
80  ///
81  /// Called before the first layout, any time [markNeedsLayout] is called, and
82  /// any time the scroll offset changes. The `shrinkOffset` is the difference
83  /// between the [maxExtent] and the current size. Zero means the header is
84  /// fully expanded, any greater number up to [maxExtent] means that the header
85  /// has been scrolled by that much. The `overlapsContent` argument is true if
86  /// the sliver's leading edge is beyond its normal place in the viewport
87  /// contents, and false otherwise. It may still paint beyond its normal place
88  /// if the [minExtent] after this call is greater than the amount of space that
89  /// would normally be left.
90  ///
91  /// The render object will size itself to the larger of (a) the [maxExtent]
92  /// minus the child's intrinsic height and (b) the [maxExtent] minus the
93  /// shrink offset.
94  ///
95  /// When this method is called by [layoutChild], the [child] can be set,
96  /// mutated, or replaced. (It should not be called outside [layoutChild].)
97  ///
98  /// Any time this method would mutate the child, call [markNeedsLayout].
99  @protected
100  void updateChild(double shrinkOffset, bool overlapsContent) { }
101
102  @override
103  void markNeedsLayout() {
104    // This is automatically called whenever the child's intrinsic dimensions
105    // change, at which point we should remeasure them during the next layout.
106    _needsUpdateChild = true;
107    super.markNeedsLayout();
108  }
109
110  /// Lays out the [child].
111  ///
112  /// This is called by [performLayout]. It applies the given `scrollOffset`
113  /// (which need not match the offset given by the [constraints]) and the
114  /// `maxExtent` (which need not match the value returned by the [maxExtent]
115  /// getter).
116  ///
117  /// The `overlapsContent` argument is passed to [updateChild].
118  @protected
119  void layoutChild(double scrollOffset, double maxExtent, { bool overlapsContent = false }) {
120    assert(maxExtent != null);
121    final double shrinkOffset = math.min(scrollOffset, maxExtent);
122    if (_needsUpdateChild || _lastShrinkOffset != shrinkOffset || _lastOverlapsContent != overlapsContent) {
123      invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
124        assert(constraints == this.constraints);
125        updateChild(shrinkOffset, overlapsContent);
126      });
127      _lastShrinkOffset = shrinkOffset;
128      _lastOverlapsContent = overlapsContent;
129      _needsUpdateChild = false;
130    }
131    assert(minExtent != null);
132    assert(() {
133      if (minExtent <= maxExtent)
134        return true;
135      throw FlutterError(
136        'The maxExtent for this $runtimeType is less than its minExtent.\n'
137        'The specified maxExtent was: ${maxExtent.toStringAsFixed(1)}\n'
138        'The specified minExtent was: ${minExtent.toStringAsFixed(1)}\n'
139      );
140    }());
141    child?.layout(
142      constraints.asBoxConstraints(maxExtent: math.max(minExtent, maxExtent - shrinkOffset)),
143      parentUsesSize: true,
144    );
145  }
146
147  /// Returns the distance from the leading _visible_ edge of the sliver to the
148  /// side of the child closest to that edge, in the scroll axis direction.
149  ///
150  /// For example, if the [constraints] describe this sliver as having an axis
151  /// direction of [AxisDirection.down], then this is the distance from the top
152  /// of the visible portion of the sliver to the top of the child. If the child
153  /// is scrolled partially off the top of the viewport, then this will be
154  /// negative. On the other hand, if the [constraints] describe this sliver as
155  /// having an axis direction of [AxisDirection.up], then this is the distance
156  /// from the bottom of the visible portion of the sliver to the bottom of the
157  /// child. In both cases, this is the direction of increasing
158  /// [SliverConstraints.scrollOffset].
159  ///
160  /// Calling this when the child is not visible is not valid.
161  ///
162  /// The argument must be the value of the [child] property.
163  ///
164  /// This must be implemented by [RenderSliverPersistentHeader] subclasses.
165  ///
166  /// If there is no child, this should return 0.0.
167  @override
168  double childMainAxisPosition(covariant RenderObject child) => super.childMainAxisPosition(child);
169
170  @override
171  bool hitTestChildren(SliverHitTestResult result, { @required double mainAxisPosition, @required double crossAxisPosition }) {
172    assert(geometry.hitTestExtent > 0.0);
173    if (child != null)
174      return hitTestBoxChild(BoxHitTestResult.wrap(result), child, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition);
175    return false;
176  }
177
178  @override
179  void applyPaintTransform(RenderObject child, Matrix4 transform) {
180    assert(child != null);
181    assert(child == this.child);
182    applyPaintTransformForBoxChild(child, transform);
183  }
184
185  @override
186  void paint(PaintingContext context, Offset offset) {
187    if (child != null && geometry.visible) {
188      assert(constraints.axisDirection != null);
189      switch (applyGrowthDirectionToAxisDirection(constraints.axisDirection, constraints.growthDirection)) {
190        case AxisDirection.up:
191          offset += Offset(0.0, geometry.paintExtent - childMainAxisPosition(child) - childExtent);
192          break;
193        case AxisDirection.down:
194          offset += Offset(0.0, childMainAxisPosition(child));
195          break;
196        case AxisDirection.left:
197          offset += Offset(geometry.paintExtent - childMainAxisPosition(child) - childExtent, 0.0);
198          break;
199        case AxisDirection.right:
200          offset += Offset(childMainAxisPosition(child), 0.0);
201          break;
202      }
203      context.paintChild(child, offset);
204    }
205  }
206
207  /// Whether the [SemanticsNode]s associated with this [RenderSliver] should
208  /// be excluded from the semantic scrolling area.
209  ///
210  /// [RenderSliver]s that stay on the screen even though the user has scrolled
211  /// past them (e.g. a pinned app bar) should set this to true.
212  @protected
213  bool get excludeFromSemanticsScrolling => _excludeFromSemanticsScrolling;
214  bool _excludeFromSemanticsScrolling = false;
215  set excludeFromSemanticsScrolling(bool value) {
216    if (_excludeFromSemanticsScrolling == value)
217      return;
218    _excludeFromSemanticsScrolling = value;
219    markNeedsSemanticsUpdate();
220  }
221
222  @override
223  void describeSemanticsConfiguration(SemanticsConfiguration config) {
224    super.describeSemanticsConfiguration(config);
225
226    if (_excludeFromSemanticsScrolling)
227      config.addTagForChildren(RenderViewport.excludeFromScrolling);
228  }
229
230  @override
231  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
232    super.debugFillProperties(properties);
233    properties.add(DoubleProperty.lazy('maxExtent', () => maxExtent));
234    properties.add(DoubleProperty.lazy('child position', () => childMainAxisPosition(child)));
235  }
236}
237
238/// A sliver with a [RenderBox] child which scrolls normally, except that when
239/// it hits the leading edge (typically the top) of the viewport, it shrinks to
240/// a minimum size before continuing to scroll.
241///
242/// This sliver makes no effort to avoid overlapping other content.
243abstract class RenderSliverScrollingPersistentHeader extends RenderSliverPersistentHeader {
244  /// Creates a sliver that shrinks when it hits the start of the viewport, then
245  /// scrolls off.
246  RenderSliverScrollingPersistentHeader({
247    RenderBox child,
248  }) : super(child: child);
249
250  // Distance from our leading edge to the child's leading edge, in the axis
251  // direction. Negative if we're scrolled off the top.
252  double _childPosition;
253
254  @override
255  void performLayout() {
256    final double maxExtent = this.maxExtent;
257    layoutChild(constraints.scrollOffset, maxExtent);
258    final double paintExtent = maxExtent - constraints.scrollOffset;
259    geometry = SliverGeometry(
260      scrollExtent: maxExtent,
261      paintOrigin: math.min(constraints.overlap, 0.0),
262      paintExtent: paintExtent.clamp(0.0, constraints.remainingPaintExtent),
263      maxPaintExtent: maxExtent,
264      hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
265    );
266    _childPosition = math.min(0.0, paintExtent - childExtent);
267  }
268
269  @override
270  double childMainAxisPosition(RenderBox child) {
271    assert(child == this.child);
272    return _childPosition;
273  }
274}
275
276/// A sliver with a [RenderBox] child which never scrolls off the viewport in
277/// the positive scroll direction, and which first scrolls on at a full size but
278/// then shrinks as the viewport continues to scroll.
279///
280/// This sliver avoids overlapping other earlier slivers where possible.
281abstract class RenderSliverPinnedPersistentHeader extends RenderSliverPersistentHeader {
282  /// Creates a sliver that shrinks when it hits the start of the viewport, then
283  /// stays pinned there.
284  RenderSliverPinnedPersistentHeader({
285    RenderBox child,
286  }) : super(child: child);
287
288  @override
289  void performLayout() {
290    final double maxExtent = this.maxExtent;
291    final bool overlapsContent = constraints.overlap > 0.0;
292    excludeFromSemanticsScrolling = overlapsContent || (constraints.scrollOffset > maxExtent - minExtent);
293    layoutChild(constraints.scrollOffset, maxExtent, overlapsContent: overlapsContent);
294    final double layoutExtent = (maxExtent - constraints.scrollOffset).clamp(0.0, constraints.remainingPaintExtent);
295    geometry = SliverGeometry(
296      scrollExtent: maxExtent,
297      paintOrigin: constraints.overlap,
298      paintExtent: math.min(childExtent, constraints.remainingPaintExtent),
299      layoutExtent: layoutExtent,
300      maxPaintExtent: maxExtent,
301      maxScrollObstructionExtent: minExtent,
302      cacheExtent: layoutExtent > 0.0 ? -constraints.cacheOrigin + layoutExtent : layoutExtent,
303      hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
304    );
305  }
306
307  @override
308  double childMainAxisPosition(RenderBox child) => 0.0;
309}
310
311/// Specifies how a floating header is to be "snapped" (animated) into or out
312/// of view.
313///
314/// See also:
315///
316///  * [RenderSliverFloatingPersistentHeader.maybeStartSnapAnimation] and
317///    [RenderSliverFloatingPersistentHeader.maybeStopSnapAnimation], which
318///    start or stop the floating header's animation.
319///  * [SliverAppBar], which creates a header that can be pinned, floating,
320///    and snapped into view via the corresponding parameters.
321class FloatingHeaderSnapConfiguration {
322  /// Creates an object that specifies how a floating header is to be "snapped"
323  /// (animated) into or out of view.
324  FloatingHeaderSnapConfiguration({
325    @required this.vsync,
326    this.curve = Curves.ease,
327    this.duration = const Duration(milliseconds: 300),
328  }) : assert(vsync != null),
329       assert(curve != null),
330       assert(duration != null);
331
332  /// The [TickerProvider] for the [AnimationController] that causes a
333  /// floating header to snap in or out of view.
334  final TickerProvider vsync;
335
336  /// The snap animation curve.
337  final Curve curve;
338
339  /// The snap animation's duration.
340  final Duration duration;
341}
342
343/// A sliver with a [RenderBox] child which shrinks and scrolls like a
344/// [RenderSliverScrollingPersistentHeader], but immediately comes back when the
345/// user scrolls in the reverse direction.
346///
347/// See also:
348///
349///  * [RenderSliverFloatingPinnedPersistentHeader], which is similar but sticks
350///    to the start of the viewport rather than scrolling off.
351abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersistentHeader {
352  /// Creates a sliver that shrinks when it hits the start of the viewport, then
353  /// scrolls off, and comes back immediately when the user reverses the scroll
354  /// direction.
355  RenderSliverFloatingPersistentHeader({
356    RenderBox child,
357    FloatingHeaderSnapConfiguration snapConfiguration,
358  }) : _snapConfiguration = snapConfiguration,
359       super(child: child);
360
361  AnimationController _controller;
362  Animation<double> _animation;
363  double _lastActualScrollOffset;
364  double _effectiveScrollOffset;
365
366  // Distance from our leading edge to the child's leading edge, in the axis
367  // direction. Negative if we're scrolled off the top.
368  double _childPosition;
369
370  @override
371  void detach() {
372    _controller?.dispose();
373    _controller = null; // lazily recreated if we're reattached.
374    super.detach();
375  }
376
377  /// Defines the parameters used to snap (animate) the floating header in and
378  /// out of view.
379  ///
380  /// If [snapConfiguration] is null then the floating header does not snap.
381  ///
382  /// See also:
383  ///
384  ///  * [RenderSliverFloatingPersistentHeader.maybeStartSnapAnimation] and
385  ///    [RenderSliverFloatingPersistentHeader.maybeStopSnapAnimation], which
386  ///    start or stop the floating header's animation.
387  ///  * [SliverAppBar], which creates a header that can be pinned, floating,
388  ///    and snapped into view via the corresponding parameters.
389  FloatingHeaderSnapConfiguration get snapConfiguration => _snapConfiguration;
390  FloatingHeaderSnapConfiguration _snapConfiguration;
391  set snapConfiguration(FloatingHeaderSnapConfiguration value) {
392    if (value == _snapConfiguration)
393      return;
394    if (value == null) {
395      _controller?.dispose();
396      _controller = null;
397    } else {
398      if (_snapConfiguration != null && value.vsync != _snapConfiguration.vsync)
399        _controller?.resync(value.vsync);
400    }
401    _snapConfiguration = value;
402  }
403
404  /// Updates [geometry], and returns the new value for [childMainAxisPosition].
405  ///
406  /// This is used by [performLayout].
407  @protected
408  double updateGeometry() {
409    final double maxExtent = this.maxExtent;
410    final double paintExtent = maxExtent - _effectiveScrollOffset;
411    final double layoutExtent = maxExtent - constraints.scrollOffset;
412    geometry = SliverGeometry(
413      scrollExtent: maxExtent,
414      paintOrigin: math.min(constraints.overlap, 0.0),
415      paintExtent: paintExtent.clamp(0.0, constraints.remainingPaintExtent),
416      layoutExtent: layoutExtent.clamp(0.0, constraints.remainingPaintExtent),
417      maxPaintExtent: maxExtent,
418      maxScrollObstructionExtent: maxExtent,
419      hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
420    );
421    return math.min(0.0, paintExtent - childExtent);
422  }
423
424  /// If the header isn't already fully exposed, then scroll it into view.
425  void maybeStartSnapAnimation(ScrollDirection direction) {
426    if (snapConfiguration == null)
427      return;
428    if (direction == ScrollDirection.forward && _effectiveScrollOffset <= 0.0)
429      return;
430    if (direction == ScrollDirection.reverse && _effectiveScrollOffset >= maxExtent)
431      return;
432
433    final TickerProvider vsync = snapConfiguration.vsync;
434    final Duration duration = snapConfiguration.duration;
435    _controller ??= AnimationController(vsync: vsync, duration: duration)
436      ..addListener(() {
437        if (_effectiveScrollOffset == _animation.value)
438          return;
439        _effectiveScrollOffset = _animation.value;
440        markNeedsLayout();
441      });
442
443    _animation = _controller.drive(
444      Tween<double>(
445        begin: _effectiveScrollOffset,
446        end: direction == ScrollDirection.forward ? 0.0 : maxExtent,
447      ).chain(CurveTween(
448        curve: snapConfiguration.curve,
449      )),
450    );
451
452    _controller.forward(from: 0.0);
453  }
454
455  /// If a header snap animation is underway then stop it.
456  void maybeStopSnapAnimation(ScrollDirection direction) {
457    _controller?.stop();
458  }
459
460  @override
461  void performLayout() {
462    final double maxExtent = this.maxExtent;
463    if (_lastActualScrollOffset != null && // We've laid out at least once to get an initial position, and either
464        ((constraints.scrollOffset < _lastActualScrollOffset) || // we are scrolling back, so should reveal, or
465         (_effectiveScrollOffset < maxExtent))) { // some part of it is visible, so should shrink or reveal as appropriate.
466      double delta = _lastActualScrollOffset - constraints.scrollOffset;
467      final bool allowFloatingExpansion = constraints.userScrollDirection == ScrollDirection.forward;
468      if (allowFloatingExpansion) {
469        if (_effectiveScrollOffset > maxExtent) // We're scrolled off-screen, but should reveal, so
470          _effectiveScrollOffset = maxExtent; // pretend we're just at the limit.
471      } else {
472        if (delta > 0.0) // If we are trying to expand when allowFloatingExpansion is false,
473          delta = 0.0; // disallow the expansion. (But allow shrinking, i.e. delta < 0.0 is fine.)
474      }
475      _effectiveScrollOffset = (_effectiveScrollOffset - delta).clamp(0.0, constraints.scrollOffset);
476    } else {
477      _effectiveScrollOffset = constraints.scrollOffset;
478    }
479    excludeFromSemanticsScrolling = _effectiveScrollOffset <= constraints.scrollOffset;
480    final bool overlapsContent = _effectiveScrollOffset < constraints.scrollOffset;
481    layoutChild(_effectiveScrollOffset, maxExtent, overlapsContent: overlapsContent);
482    _childPosition = updateGeometry();
483    _lastActualScrollOffset = constraints.scrollOffset;
484  }
485
486  @override
487  double childMainAxisPosition(RenderBox child) {
488    assert(child == this.child);
489    return _childPosition;
490  }
491
492  @override
493  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
494    super.debugFillProperties(properties);
495    properties.add(DoubleProperty('effective scroll offset', _effectiveScrollOffset));
496  }
497}
498
499/// A sliver with a [RenderBox] child which shrinks and then remains pinned to
500/// the start of the viewport like a [RenderSliverPinnedPersistentHeader], but
501/// immediately grows when the user scrolls in the reverse direction.
502///
503/// See also:
504///
505///  * [RenderSliverFloatingPersistentHeader], which is similar but scrolls off
506///    the top rather than sticking to it.
507abstract class RenderSliverFloatingPinnedPersistentHeader extends RenderSliverFloatingPersistentHeader {
508  /// Creates a sliver that shrinks when it hits the start of the viewport, then
509  /// stays pinned there, and grows immediately when the user reverses the
510  /// scroll direction.
511  RenderSliverFloatingPinnedPersistentHeader({
512    RenderBox child,
513    FloatingHeaderSnapConfiguration snapConfiguration,
514  }) : super(child: child, snapConfiguration: snapConfiguration);
515
516  @override
517  double updateGeometry() {
518    final double minExtent = this.minExtent;
519    final double minAllowedExtent = constraints.remainingPaintExtent > minExtent ? minExtent : constraints.remainingPaintExtent;
520    final double maxExtent = this.maxExtent;
521    final double paintExtent = maxExtent - _effectiveScrollOffset;
522    final double clampedPaintExtent = paintExtent.clamp(minAllowedExtent, constraints.remainingPaintExtent);
523    final double layoutExtent = maxExtent - constraints.scrollOffset;
524    geometry = SliverGeometry(
525      scrollExtent: maxExtent,
526      paintOrigin: math.min(constraints.overlap, 0.0),
527      paintExtent: clampedPaintExtent,
528      layoutExtent: layoutExtent.clamp(0.0, clampedPaintExtent),
529      maxPaintExtent: maxExtent,
530      maxScrollObstructionExtent: maxExtent,
531      hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
532    );
533    return 0.0;
534  }
535}
536