sliver_persistent_header.dart 18.3 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1 2 3 4 5 6
// Copyright 2016 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;

7
import 'package:flutter/animation.dart';
Ian Hickson's avatar
Ian Hickson committed
8 9
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
10
import 'package:flutter/scheduler.dart';
Ian Hickson's avatar
Ian Hickson committed
11 12 13
import 'package:vector_math/vector_math_64.dart';

import 'binding.dart';
14
import 'box.dart';
Ian Hickson's avatar
Ian Hickson committed
15 16
import 'object.dart';
import 'sliver.dart';
17
import 'viewport_offset.dart';
Ian Hickson's avatar
Ian Hickson committed
18

19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
/// A base class for slivers that have a [RenderBox] child which scrolls
/// normally, except that when it hits the leading edge (typically the top) of
/// the viewport, it shrinks to a minimum size ([minExtent]).
///
/// This class primarily provides helpers for managing the child, in particular:
///
///  * [layoutChild], which applies min and max extents and a scroll offset to
///    lay out the child. This is normally called from [performLayout].
///
///  * [childExtent], to convert the child's box layout dimensions to the sliver
///    geometry model.
///
///  * hit testing, painting, and other details of the sliver protocol.
///
/// Subclasses must implement [performLayout], [minExtent], and [maxExtent].
34 35
abstract class RenderSliverPersistentHeader extends RenderSliver with RenderObjectWithChildMixin<RenderBox>, RenderSliverHelpers {
  RenderSliverPersistentHeader({ RenderBox child }) {
Ian Hickson's avatar
Ian Hickson committed
36 37 38
    this.child = child;
  }

39 40 41 42
  /// The biggest that this render object can become, in the main axis direction.
  ///
  /// This value should not be based on the child. If it changes, call
  /// [markNeedsLayout].
Ian Hickson's avatar
Ian Hickson committed
43 44
  double get maxExtent;

45
  /// The smallest that this render object can become, in the main axis direction.
Ian Hickson's avatar
Ian Hickson committed
46
  ///
47 48 49 50 51
  /// If this is based on the intrinsic dimensions of the child, the child
  /// should be measured during [updateChild] and the value cached and returned
  /// here. The [updateChild] method will automatically be invoked any time the
  /// child changes its intrinsic dimensions.
  double get minExtent;
Ian Hickson's avatar
Ian Hickson committed
52

53
  /// The dimension of the child in the main axis.
Ian Hickson's avatar
Ian Hickson committed
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
  @protected
  double get childExtent {
    if (child == null)
      return 0.0;
    assert(child.hasSize);
    assert(constraints.axis != null);
    switch (constraints.axis) {
      case Axis.vertical:
        return child.size.height;
      case Axis.horizontal:
        return child.size.width;
    }
    return null;
  }

69 70 71
  bool _needsUpdateChild = true;
  double _lastShrinkOffset = 0.0;
  bool _lastOverlapsContent = false;
Ian Hickson's avatar
Ian Hickson committed
72

73
  /// Update the child render object if necessary.
Ian Hickson's avatar
Ian Hickson committed
74
  ///
75 76 77 78 79 80 81 82 83 84 85 86 87
  /// Called before the first layout, any time [markNeedsLayout] is called, and
  /// any time the scroll offset changes. The `shrinkOffset` is the difference
  /// between the [maxExtent] and the current size. Zero means the header is
  /// fully expanded, any greater number up to [maxExtent] means that the header
  /// has been scrolled by that much. The `overlapsContent` argument is true if
  /// the sliver's leading edge is beyond its normal place in the viewport
  /// contents, and false otherwise. It may still paint beyond its normal place
  /// if the [minExtent] after this call is greater than the amount of space that
  /// would normally be left.
  ///
  /// The render object will size itself to the larger of (a) the [maxExtent]
  /// minus the child's intrinsic height and (b) the [maxExtent] minus the
  /// shrink offset.
Ian Hickson's avatar
Ian Hickson committed
88
  ///
89 90
  /// When this method is called by [layoutChild], the [child] can be set,
  /// mutated, or replaced. (It should not be called outside [layoutChild].)
Ian Hickson's avatar
Ian Hickson committed
91
  ///
92
  /// Any time this method would mutate the child, call [markNeedsLayout].
Ian Hickson's avatar
Ian Hickson committed
93
  @protected
94 95 96 97 98 99 100 101
  void updateChild(double shrinkOffset, bool overlapsContent) { }

  @override
  void markNeedsLayout() {
    // This is automatically called whenever the child's intrinsic dimensions
    // change, at which point we should remeasure them during the next layout.
    _needsUpdateChild = true;
    super.markNeedsLayout();
Ian Hickson's avatar
Ian Hickson committed
102 103
  }

104
  void layoutChild(double scrollOffset, double maxExtent, { bool overlapsContent: false }) {
Ian Hickson's avatar
Ian Hickson committed
105 106
    assert(maxExtent != null);
    final double shrinkOffset = math.min(scrollOffset, maxExtent);
107
    if (_needsUpdateChild || _lastShrinkOffset != shrinkOffset || _lastOverlapsContent != overlapsContent) {
Ian Hickson's avatar
Ian Hickson committed
108 109
      invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
        assert(constraints == this.constraints);
110
        updateChild(shrinkOffset, overlapsContent);
Ian Hickson's avatar
Ian Hickson committed
111 112
      });
      _lastShrinkOffset = shrinkOffset;
113 114
      _lastOverlapsContent = overlapsContent;
      _needsUpdateChild = false;
Ian Hickson's avatar
Ian Hickson committed
115
    }
116
    assert(minExtent != null);
Ian Hickson's avatar
Ian Hickson committed
117
    assert(() {
118
      if (minExtent <= maxExtent)
Ian Hickson's avatar
Ian Hickson committed
119 120
        return true;
      throw new FlutterError(
121
        'The maxExtent for this $runtimeType is less than its minExtent.\n'
Ian Hickson's avatar
Ian Hickson committed
122
        'The specified maxExtent was: ${maxExtent.toStringAsFixed(1)}\n'
123
        'The specified minExtent was: ${minExtent.toStringAsFixed(1)}\n'
Ian Hickson's avatar
Ian Hickson committed
124 125 126
      );
    });
    child?.layout(
127
      constraints.asBoxConstraints(maxExtent: math.max(minExtent, maxExtent - shrinkOffset)),
Ian Hickson's avatar
Ian Hickson committed
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148
      parentUsesSize: true,
    );
  }

  /// Returns the distance from the leading _visible_ edge of the sliver to the
  /// side of the child closest to that edge, in the scroll axis direction.
  ///
  /// For example, if the [constraints] describe this sliver as having an axis
  /// direction of [AxisDirection.down], then this is the distance from the top
  /// of the visible portion of the sliver to the top of the child. If the child
  /// is scrolled partially off the top of the viewport, then this will be
  /// negative. On the other hand, if the [constraints] describe this sliver as
  /// having an axis direction of [AxisDirection.up], then this is the distance
  /// from the bottom of the visible portion of the sliver to the bottom of the
  /// child. In both cases, this is the direction of increasing
  /// [SliverConstraints.scrollOffset].
  ///
  /// Calling this when the child is not visible is not valid.
  ///
  /// The argument must be the value of the [child] property.
  ///
149
  /// This must be implemented by [RenderSliverPersistentHeader] subclasses.
Ian Hickson's avatar
Ian Hickson committed
150 151 152
  ///
  /// If there is no child, this should return 0.0.
  @override
153
  double childMainAxisPosition(covariant RenderObject child) => super.childMainAxisPosition(child);
Ian Hickson's avatar
Ian Hickson committed
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175

  @override
  bool hitTestChildren(HitTestResult result, { @required double mainAxisPosition, @required double crossAxisPosition }) {
    assert(geometry.hitTestExtent > 0.0);
    if (child != null)
      return hitTestBoxChild(result, child, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition);
    return false;
  }

  @override
  void applyPaintTransform(RenderObject child, Matrix4 transform) {
    assert(child != null);
    assert(child == this.child);
    applyPaintTransformForBoxChild(child, transform);
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null && geometry.visible) {
      assert(constraints.axisDirection != null);
      switch (applyGrowthDirectionToAxisDirection(constraints.axisDirection, constraints.growthDirection)) {
        case AxisDirection.up:
176
          offset += new Offset(0.0, geometry.paintExtent - childMainAxisPosition(child) - childExtent);
Ian Hickson's avatar
Ian Hickson committed
177 178
          break;
        case AxisDirection.down:
179
          offset += new Offset(0.0, childMainAxisPosition(child));
Ian Hickson's avatar
Ian Hickson committed
180 181
          break;
        case AxisDirection.left:
182
          offset += new Offset(geometry.paintExtent - childMainAxisPosition(child) - childExtent, 0.0);
Ian Hickson's avatar
Ian Hickson committed
183 184
          break;
        case AxisDirection.right:
185
          offset += new Offset(childMainAxisPosition(child), 0.0);
Ian Hickson's avatar
Ian Hickson committed
186 187 188 189 190 191 192 193 194 195 196 197
          break;
      }
      context.paintChild(child, offset);
    }
  }

  @override
  void debugFillDescription(List<String> description) {
    super.debugFillDescription(description);
    try {
      description.add('maxExtent: ${maxExtent.toStringAsFixed(1)}');
    } catch (e) {
198
      description.add('maxExtent: EXCEPTION (${e.runtimeType})');
Ian Hickson's avatar
Ian Hickson committed
199 200
    }
    try {
201
      description.add('child position: ${childMainAxisPosition(child).toStringAsFixed(1)}');
Ian Hickson's avatar
Ian Hickson committed
202
    } catch (e) {
203
      description.add('child position: EXCEPTION (${e.runtimeType})');
Ian Hickson's avatar
Ian Hickson committed
204 205 206 207 208 209 210 211 212
    }
  }
}

/// A sliver with a [RenderBox] child which scrolls normally, except that when
/// it hits the leading edge (typically the top) of the viewport, it shrinks to
/// a minimum size before continuing to scroll.
///
/// This sliver makes no effort to avoid overlapping other content.
213 214
abstract class RenderSliverScrollingPersistentHeader extends RenderSliverPersistentHeader {
  RenderSliverScrollingPersistentHeader({
Ian Hickson's avatar
Ian Hickson committed
215 216 217 218 219 220 221 222 223 224 225 226 227 228
    RenderBox child,
  }) : super(child: child);

  // Distance from our leading edge to the child's leading edge, in the axis
  // direction. Negative if we're scrolled off the top.
  double _childPosition;

  @override
  void performLayout() {
    final double maxExtent = this.maxExtent;
    layoutChild(constraints.scrollOffset, maxExtent);
    final double paintExtent = maxExtent - constraints.scrollOffset;
    geometry = new SliverGeometry(
      scrollExtent: maxExtent,
229
      paintOrigin: math.min(constraints.overlap, 0.0),
Ian Hickson's avatar
Ian Hickson committed
230 231
      paintExtent: paintExtent.clamp(0.0, constraints.remainingPaintExtent),
      maxPaintExtent: maxExtent,
232
      hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
Ian Hickson's avatar
Ian Hickson committed
233 234 235 236 237
    );
    _childPosition = math.min(0.0, paintExtent - childExtent);
  }

  @override
238
  double childMainAxisPosition(RenderBox child) {
Ian Hickson's avatar
Ian Hickson committed
239 240 241 242 243 244 245 246 247 248
    assert(child == this.child);
    return _childPosition;
  }
}

/// A sliver with a [RenderBox] child which never scrolls off the viewport in
/// the positive scroll direction, and which first scrolls on at a full size but
/// then shrinks as the viewport continues to scroll.
///
/// This sliver avoids overlapping other earlier slivers where possible.
249 250
abstract class RenderSliverPinnedPersistentHeader extends RenderSliverPersistentHeader {
  RenderSliverPinnedPersistentHeader({
Ian Hickson's avatar
Ian Hickson committed
251 252 253 254 255 256
    RenderBox child,
  }) : super(child: child);

  @override
  void performLayout() {
    final double maxExtent = this.maxExtent;
257
    layoutChild(constraints.scrollOffset, maxExtent, overlapsContent: constraints.overlap > 0.0);
Ian Hickson's avatar
Ian Hickson committed
258 259
    geometry = new SliverGeometry(
      scrollExtent: maxExtent,
260 261
      paintOrigin: constraints.overlap,
      paintExtent: math.min(childExtent, constraints.remainingPaintExtent),
Ian Hickson's avatar
Ian Hickson committed
262
      layoutExtent: (maxExtent - constraints.scrollOffset).clamp(0.0, constraints.remainingPaintExtent),
263
      maxPaintExtent: maxExtent,
264
      hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
Ian Hickson's avatar
Ian Hickson committed
265 266 267 268
    );
  }

  @override
269
  double childMainAxisPosition(RenderBox child) => 0.0;
Ian Hickson's avatar
Ian Hickson committed
270 271
}

272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288
/// Specifies how a floating header is to be "snapped" (animated) into or out
/// of view.
///
/// See also:
///
///  * [RenderSliverFloatingPersistentHeader.maybeStartSnapAnimation] and
///    [RenderSliverFloatingPersistentHeader.maybeStopSnapAnimation], which
///    start or stop the floating header's animation.
///  * [SliverAppBar], which creates a header that can be pinned, floating,
///    and snapped into view via the corresponding parameters.
class FloatingHeaderSnapConfiguration {
  /// Creates an object that specifies how a floating header is to be "snapped"
  /// (animated) into or out of view.
  FloatingHeaderSnapConfiguration({
    @required this.vsync,
    this.curve: Curves.ease,
    this.duration: const Duration(milliseconds: 300),
289 290 291
  }) : assert(vsync != null),
       assert(curve != null),
       assert(duration != null);
292 293 294 295 296 297 298 299 300 301 302 303

  /// The [TickerProvider] for the [AnimationController] that causes a
  /// floating header to snap in or out of view.
  final TickerProvider vsync;

  /// The snap animation curve.
  final Curve curve;

  /// The snap animation's duration.
  final Duration duration;
}

304 305 306
/// A sliver with a [RenderBox] child which shrinks and scrolls like a
/// [RenderSliverScrollingPersistentHeader], but immediately comes back when the
/// user scrolls in the reverse direction.
307 308
abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersistentHeader {
  RenderSliverFloatingPersistentHeader({
Ian Hickson's avatar
Ian Hickson committed
309
    RenderBox child,
310 311
    FloatingHeaderSnapConfiguration snapConfiguration,
  }) : _snapConfiguration = snapConfiguration, super(child: child);
Ian Hickson's avatar
Ian Hickson committed
312

313 314
  AnimationController _controller;
  Animation<double> _animation;
Ian Hickson's avatar
Ian Hickson committed
315 316 317 318 319 320 321
  double _lastActualScrollOffset;
  double _effectiveScrollOffset;

  // Distance from our leading edge to the child's leading edge, in the axis
  // direction. Negative if we're scrolled off the top.
  double _childPosition;

322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354
  @override
  void detach() {
    _controller?.dispose();
    _controller = null; // lazily recreated if we're reattached.
    super.detach();
  }

  /// Defines the parameters used to snap (animate) the floating header in and
  /// out of view.
  ///
  /// If [snapConfiguration] is null then the floating header does not snap.
  ///
  /// See also:
  ///
  ///  * [RenderSliverFloatingPersistentHeader.maybeStartSnapAnimation] and
  ///    [RenderSliverFloatingPersistentHeader.maybeStopSnapAnimation], which
  ///    start or stop the floating header's animation.
  ///  * [SliverAppBar], which creates a header that can be pinned, floating,
  ///    and snapped into view via the corresponding parameters.
  FloatingHeaderSnapConfiguration get snapConfiguration => _snapConfiguration;
  FloatingHeaderSnapConfiguration _snapConfiguration;
  set snapConfiguration(FloatingHeaderSnapConfiguration value) {
    if (value == _snapConfiguration)
      return;
    if (value == null) {
      _controller?.dispose();
    } else {
      if (_snapConfiguration != null && value.vsync != _snapConfiguration.vsync)
        _controller?.resync(value.vsync);
    }
    _snapConfiguration = value;
  }

355 356 357 358 359 360 361 362
  // Update [geometry] and return the new value for [childMainAxisPosition].
  @protected
  double updateGeometry() {
    final double maxExtent = this.maxExtent;
    final double paintExtent = maxExtent - _effectiveScrollOffset;
    final double layoutExtent = maxExtent - constraints.scrollOffset;
    geometry = new SliverGeometry(
      scrollExtent: maxExtent,
363
      paintOrigin: math.min(constraints.overlap, 0.0),
364 365 366 367 368 369 370 371
      paintExtent: paintExtent.clamp(0.0, constraints.remainingPaintExtent),
      layoutExtent: layoutExtent.clamp(0.0, constraints.remainingPaintExtent),
      maxPaintExtent: maxExtent,
      hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
    );
    return math.min(0.0, paintExtent - childExtent);
  }

372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407
  /// If the header isn't already fully exposed, then scroll it into view.
  void maybeStartSnapAnimation(ScrollDirection direction) {
    if (snapConfiguration == null)
      return;
    if (direction == ScrollDirection.forward && _effectiveScrollOffset <= 0.0)
      return;
    if (direction == ScrollDirection.reverse && _effectiveScrollOffset >= maxExtent)
      return;

    final TickerProvider vsync = snapConfiguration.vsync;
    final Duration duration = snapConfiguration.duration;
    _controller ??= new AnimationController(vsync: vsync, duration: duration)
      ..addListener(() {
        if (_effectiveScrollOffset == _animation.value)
          return;
        _effectiveScrollOffset = _animation.value;
        markNeedsLayout();
      });

    // Recreating the animation rather than updating a cached value, only
    // to avoid the extra complexity of managing the animation's lifetime.
    _animation = new Tween<double>(
      begin: _effectiveScrollOffset,
      end: direction == ScrollDirection.forward ? 0.0 : maxExtent,
    ).animate(new CurvedAnimation(
      parent: _controller,
      curve: snapConfiguration.curve,
    ));

    _controller.forward(from: 0.0);
  }

  /// If a header snap animation is underway then stop it.
  void maybeStopSnapAnimation(ScrollDirection direction) {
    _controller?.stop();
  }
408

Ian Hickson's avatar
Ian Hickson committed
409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427
  @override
  void performLayout() {
    final double maxExtent = this.maxExtent;
    if (_lastActualScrollOffset != null && // We've laid out at least once to get an initial position, and either
        ((constraints.scrollOffset < _lastActualScrollOffset) || // we are scrolling back, so should reveal, or
         (_effectiveScrollOffset < maxExtent))) { // some part of it is visible, so should shrink or reveal as appropriate.
      double delta = _lastActualScrollOffset - constraints.scrollOffset;
      final bool allowFloatingExpansion = constraints.userScrollDirection == ScrollDirection.forward;
      if (allowFloatingExpansion) {
        if (_effectiveScrollOffset > maxExtent) // We're scrolled off-screen, but should reveal, so
          _effectiveScrollOffset = maxExtent; // pretend we're just at the limit.
      } else {
        if (delta > 0.0) // If we are trying to expand when allowFloatingExpansion is false,
          delta = 0.0; // disallow the expansion. (But allow shrinking, i.e. delta < 0.0 is fine.)
      }
      _effectiveScrollOffset = (_effectiveScrollOffset - delta).clamp(0.0, constraints.scrollOffset);
    } else {
      _effectiveScrollOffset = constraints.scrollOffset;
    }
428
    layoutChild(_effectiveScrollOffset, maxExtent, overlapsContent: _effectiveScrollOffset < constraints.scrollOffset);
429
    _childPosition = updateGeometry();
Ian Hickson's avatar
Ian Hickson committed
430 431 432 433
    _lastActualScrollOffset = constraints.scrollOffset;
  }

  @override
434
  double childMainAxisPosition(RenderBox child) {
Ian Hickson's avatar
Ian Hickson committed
435 436 437 438 439 440 441 442 443 444
    assert(child == this.child);
    return _childPosition;
  }

  @override
  void debugFillDescription(List<String> description) {
    super.debugFillDescription(description);
    description.add('effective scroll offset: ${_effectiveScrollOffset?.toStringAsFixed(1)}');
  }
}
445 446 447 448

abstract class RenderSliverFloatingPinnedPersistentHeader extends RenderSliverFloatingPersistentHeader {
  RenderSliverFloatingPinnedPersistentHeader({
    RenderBox child,
449 450
    FloatingHeaderSnapConfiguration snapConfiguration,
  }) : super(child: child, snapConfiguration: snapConfiguration);
451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467

  @override
  double updateGeometry() {
    final double minExtent = this.maxExtent;
    final double maxExtent = this.maxExtent;
    final double paintExtent = (maxExtent - _effectiveScrollOffset);
    final double layoutExtent = (maxExtent - constraints.scrollOffset);
    geometry = new SliverGeometry(
      scrollExtent: maxExtent,
      paintExtent: paintExtent.clamp(minExtent, constraints.remainingPaintExtent),
      layoutExtent: layoutExtent.clamp(0.0, constraints.remainingPaintExtent - minExtent),
      maxPaintExtent: maxExtent,
      hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
    );
    return 0.0;
  }
}