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