1// Copyright 2017 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 5// Based on https://material.uplabs.com/posts/google-newsstand-navigation-pattern 6// See also: https://material-motion.github.io/material-motion/documentation/ 7 8import 'dart:math' as math; 9 10import 'package:flutter/material.dart'; 11import 'package:flutter/rendering.dart'; 12 13import 'sections.dart'; 14import 'widgets.dart'; 15 16const Color _kAppBackgroundColor = Color(0xFF353662); 17const Duration _kScrollDuration = Duration(milliseconds: 400); 18const Curve _kScrollCurve = Curves.fastOutSlowIn; 19 20// This app's contents start out at _kHeadingMaxHeight and they function like 21// an appbar. Initially the appbar occupies most of the screen and its section 22// headings are laid out in a column. By the time its height has been 23// reduced to _kAppBarMidHeight, its layout is horizontal, only one section 24// heading is visible, and the section's list of details is visible below the 25// heading. The appbar's height can be reduced to no more than _kAppBarMinHeight. 26const double _kAppBarMinHeight = 90.0; 27const double _kAppBarMidHeight = 256.0; 28// The AppBar's max height depends on the screen, see _AnimationDemoHomeState._buildBody() 29 30// Initially occupies the same space as the status bar and gets smaller as 31// the primary scrollable scrolls upwards. 32// TODO(hansmuller): it would be worth adding something like this to the framework. 33class _RenderStatusBarPaddingSliver extends RenderSliver { 34 _RenderStatusBarPaddingSliver({ 35 @required double maxHeight, 36 @required double scrollFactor, 37 }) : assert(maxHeight != null && maxHeight >= 0.0), 38 assert(scrollFactor != null && scrollFactor >= 1.0), 39 _maxHeight = maxHeight, 40 _scrollFactor = scrollFactor; 41 42 // The height of the status bar 43 double get maxHeight => _maxHeight; 44 double _maxHeight; 45 set maxHeight(double value) { 46 assert(maxHeight != null && maxHeight >= 0.0); 47 if (_maxHeight == value) 48 return; 49 _maxHeight = value; 50 markNeedsLayout(); 51 } 52 53 // That rate at which this renderer's height shrinks when the scroll 54 // offset changes. 55 double get scrollFactor => _scrollFactor; 56 double _scrollFactor; 57 set scrollFactor(double value) { 58 assert(scrollFactor != null && scrollFactor >= 1.0); 59 if (_scrollFactor == value) 60 return; 61 _scrollFactor = value; 62 markNeedsLayout(); 63 } 64 65 @override 66 void performLayout() { 67 final double height = (maxHeight - constraints.scrollOffset / scrollFactor).clamp(0.0, maxHeight); 68 geometry = SliverGeometry( 69 paintExtent: math.min(height, constraints.remainingPaintExtent), 70 scrollExtent: maxHeight, 71 maxPaintExtent: maxHeight, 72 ); 73 } 74} 75 76class _StatusBarPaddingSliver extends SingleChildRenderObjectWidget { 77 const _StatusBarPaddingSliver({ 78 Key key, 79 @required this.maxHeight, 80 this.scrollFactor = 5.0, 81 }) : assert(maxHeight != null && maxHeight >= 0.0), 82 assert(scrollFactor != null && scrollFactor >= 1.0), 83 super(key: key); 84 85 final double maxHeight; 86 final double scrollFactor; 87 88 @override 89 _RenderStatusBarPaddingSliver createRenderObject(BuildContext context) { 90 return _RenderStatusBarPaddingSliver( 91 maxHeight: maxHeight, 92 scrollFactor: scrollFactor, 93 ); 94 } 95 96 @override 97 void updateRenderObject(BuildContext context, _RenderStatusBarPaddingSliver renderObject) { 98 renderObject 99 ..maxHeight = maxHeight 100 ..scrollFactor = scrollFactor; 101 } 102 103 @override 104 void debugFillProperties(DiagnosticPropertiesBuilder description) { 105 super.debugFillProperties(description); 106 description.add(DoubleProperty('maxHeight', maxHeight)); 107 description.add(DoubleProperty('scrollFactor', scrollFactor)); 108 } 109} 110 111class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { 112 _SliverAppBarDelegate({ 113 @required this.minHeight, 114 @required this.maxHeight, 115 @required this.child, 116 }); 117 118 final double minHeight; 119 final double maxHeight; 120 final Widget child; 121 122 @override 123 double get minExtent => minHeight; 124 @override 125 double get maxExtent => math.max(maxHeight, minHeight); 126 127 @override 128 Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { 129 return SizedBox.expand(child: child); 130 } 131 132 @override 133 bool shouldRebuild(_SliverAppBarDelegate oldDelegate) { 134 return maxHeight != oldDelegate.maxHeight 135 || minHeight != oldDelegate.minHeight 136 || child != oldDelegate.child; 137 } 138 139 @override 140 String toString() => '_SliverAppBarDelegate'; 141} 142 143// Arrange the section titles, indicators, and cards. The cards are only included when 144// the layout is transitioning between vertical and horizontal. Once the layout is 145// horizontal the cards are laid out by a PageView. 146// 147// The layout of the section cards, titles, and indicators is defined by the 148// two 0.0-1.0 "t" parameters, both of which are based on the layout's height: 149// - tColumnToRow 150// 0.0 when height is maxHeight and the layout is a column 151// 1.0 when the height is midHeight and the layout is a row 152// - tCollapsed 153// 0.0 when height is midHeight and the layout is a row 154// 1.0 when height is minHeight and the layout is a (still) row 155// 156// minHeight < midHeight < maxHeight 157// 158// The general approach here is to compute the column layout and row size 159// and position of each element and then interpolate between them using 160// tColumnToRow. Once tColumnToRow reaches 1.0, the layout changes are 161// defined by tCollapsed. As tCollapsed increases the titles spread out 162// until only one title is visible and the indicators cluster together 163// until they're all visible. 164class _AllSectionsLayout extends MultiChildLayoutDelegate { 165 _AllSectionsLayout({ 166 this.translation, 167 this.tColumnToRow, 168 this.tCollapsed, 169 this.cardCount, 170 this.selectedIndex, 171 }); 172 173 final Alignment translation; 174 final double tColumnToRow; 175 final double tCollapsed; 176 final int cardCount; 177 final double selectedIndex; 178 179 Rect _interpolateRect(Rect begin, Rect end) { 180 return Rect.lerp(begin, end, tColumnToRow); 181 } 182 183 Offset _interpolatePoint(Offset begin, Offset end) { 184 return Offset.lerp(begin, end, tColumnToRow); 185 } 186 187 @override 188 void performLayout(Size size) { 189 final double columnCardX = size.width / 5.0; 190 final double columnCardWidth = size.width - columnCardX; 191 final double columnCardHeight = size.height / cardCount; 192 final double rowCardWidth = size.width; 193 final Offset offset = translation.alongSize(size); 194 double columnCardY = 0.0; 195 double rowCardX = -(selectedIndex * rowCardWidth); 196 197 // When tCollapsed > 0 the titles spread apart 198 final double columnTitleX = size.width / 10.0; 199 final double rowTitleWidth = size.width * ((1 + tCollapsed) / 2.25); 200 double rowTitleX = (size.width - rowTitleWidth) / 2.0 - selectedIndex * rowTitleWidth; 201 202 // When tCollapsed > 0, the indicators move closer together 203 //final double rowIndicatorWidth = 48.0 + (1.0 - tCollapsed) * (rowTitleWidth - 48.0); 204 const double paddedSectionIndicatorWidth = kSectionIndicatorWidth + 8.0; 205 final double rowIndicatorWidth = paddedSectionIndicatorWidth + 206 (1.0 - tCollapsed) * (rowTitleWidth - paddedSectionIndicatorWidth); 207 double rowIndicatorX = (size.width - rowIndicatorWidth) / 2.0 - selectedIndex * rowIndicatorWidth; 208 209 // Compute the size and origin of each card, title, and indicator for the maxHeight 210 // "column" layout, and the midHeight "row" layout. The actual layout is just the 211 // interpolated value between the column and row layouts for t. 212 for (int index = 0; index < cardCount; index++) { 213 214 // Layout the card for index. 215 final Rect columnCardRect = Rect.fromLTWH(columnCardX, columnCardY, columnCardWidth, columnCardHeight); 216 final Rect rowCardRect = Rect.fromLTWH(rowCardX, 0.0, rowCardWidth, size.height); 217 final Rect cardRect = _interpolateRect(columnCardRect, rowCardRect).shift(offset); 218 final String cardId = 'card$index'; 219 if (hasChild(cardId)) { 220 layoutChild(cardId, BoxConstraints.tight(cardRect.size)); 221 positionChild(cardId, cardRect.topLeft); 222 } 223 224 // Layout the title for index. 225 final Size titleSize = layoutChild('title$index', BoxConstraints.loose(cardRect.size)); 226 final double columnTitleY = columnCardRect.centerLeft.dy - titleSize.height / 2.0; 227 final double rowTitleY = rowCardRect.centerLeft.dy - titleSize.height / 2.0; 228 final double centeredRowTitleX = rowTitleX + (rowTitleWidth - titleSize.width) / 2.0; 229 final Offset columnTitleOrigin = Offset(columnTitleX, columnTitleY); 230 final Offset rowTitleOrigin = Offset(centeredRowTitleX, rowTitleY); 231 final Offset titleOrigin = _interpolatePoint(columnTitleOrigin, rowTitleOrigin); 232 positionChild('title$index', titleOrigin + offset); 233 234 // Layout the selection indicator for index. 235 final Size indicatorSize = layoutChild('indicator$index', BoxConstraints.loose(cardRect.size)); 236 final double columnIndicatorX = cardRect.centerRight.dx - indicatorSize.width - 16.0; 237 final double columnIndicatorY = cardRect.bottomRight.dy - indicatorSize.height - 16.0; 238 final Offset columnIndicatorOrigin = Offset(columnIndicatorX, columnIndicatorY); 239 final Rect titleRect = Rect.fromPoints(titleOrigin, titleSize.bottomRight(titleOrigin)); 240 final double centeredRowIndicatorX = rowIndicatorX + (rowIndicatorWidth - indicatorSize.width) / 2.0; 241 final double rowIndicatorY = titleRect.bottomCenter.dy + 16.0; 242 final Offset rowIndicatorOrigin = Offset(centeredRowIndicatorX, rowIndicatorY); 243 final Offset indicatorOrigin = _interpolatePoint(columnIndicatorOrigin, rowIndicatorOrigin); 244 positionChild('indicator$index', indicatorOrigin + offset); 245 246 columnCardY += columnCardHeight; 247 rowCardX += rowCardWidth; 248 rowTitleX += rowTitleWidth; 249 rowIndicatorX += rowIndicatorWidth; 250 } 251 } 252 253 @override 254 bool shouldRelayout(_AllSectionsLayout oldDelegate) { 255 return tColumnToRow != oldDelegate.tColumnToRow 256 || cardCount != oldDelegate.cardCount 257 || selectedIndex != oldDelegate.selectedIndex; 258 } 259} 260 261class _AllSectionsView extends AnimatedWidget { 262 _AllSectionsView({ 263 Key key, 264 this.sectionIndex, 265 @required this.sections, 266 @required this.selectedIndex, 267 this.minHeight, 268 this.midHeight, 269 this.maxHeight, 270 this.sectionCards = const <Widget>[], 271 }) : assert(sections != null), 272 assert(sectionCards != null), 273 assert(sectionCards.length == sections.length), 274 assert(sectionIndex >= 0 && sectionIndex < sections.length), 275 assert(selectedIndex != null), 276 assert(selectedIndex.value >= 0.0 && selectedIndex.value < sections.length.toDouble()), 277 super(key: key, listenable: selectedIndex); 278 279 final int sectionIndex; 280 final List<Section> sections; 281 final ValueNotifier<double> selectedIndex; 282 final double minHeight; 283 final double midHeight; 284 final double maxHeight; 285 final List<Widget> sectionCards; 286 287 double _selectedIndexDelta(int index) { 288 return (index.toDouble() - selectedIndex.value).abs().clamp(0.0, 1.0); 289 } 290 291 Widget _build(BuildContext context, BoxConstraints constraints) { 292 final Size size = constraints.biggest; 293 294 // The layout's progress from from a column to a row. Its value is 295 // 0.0 when size.height equals the maxHeight, 1.0 when the size.height 296 // equals the midHeight. 297 final double tColumnToRow = 298 1.0 - ((size.height - midHeight) / 299 (maxHeight - midHeight)).clamp(0.0, 1.0); 300 301 302 // The layout's progress from from the midHeight row layout to 303 // a minHeight row layout. Its value is 0.0 when size.height equals 304 // midHeight and 1.0 when size.height equals minHeight. 305 final double tCollapsed = 306 1.0 - ((size.height - minHeight) / 307 (midHeight - minHeight)).clamp(0.0, 1.0); 308 309 double _indicatorOpacity(int index) { 310 return 1.0 - _selectedIndexDelta(index) * 0.5; 311 } 312 313 double _titleOpacity(int index) { 314 return 1.0 - _selectedIndexDelta(index) * tColumnToRow * 0.5; 315 } 316 317 double _titleScale(int index) { 318 return 1.0 - _selectedIndexDelta(index) * tColumnToRow * 0.15; 319 } 320 321 final List<Widget> children = List<Widget>.from(sectionCards); 322 323 for (int index = 0; index < sections.length; index++) { 324 final Section section = sections[index]; 325 children.add(LayoutId( 326 id: 'title$index', 327 child: SectionTitle( 328 section: section, 329 scale: _titleScale(index), 330 opacity: _titleOpacity(index), 331 ), 332 )); 333 } 334 335 for (int index = 0; index < sections.length; index++) { 336 children.add(LayoutId( 337 id: 'indicator$index', 338 child: SectionIndicator( 339 opacity: _indicatorOpacity(index), 340 ), 341 )); 342 } 343 344 return CustomMultiChildLayout( 345 delegate: _AllSectionsLayout( 346 translation: Alignment((selectedIndex.value - sectionIndex) * 2.0 - 1.0, -1.0), 347 tColumnToRow: tColumnToRow, 348 tCollapsed: tCollapsed, 349 cardCount: sections.length, 350 selectedIndex: selectedIndex.value, 351 ), 352 children: children, 353 ); 354 } 355 356 @override 357 Widget build(BuildContext context) { 358 return LayoutBuilder(builder: _build); 359 } 360} 361 362// Support snapping scrolls to the midScrollOffset: the point at which the 363// app bar's height is _kAppBarMidHeight and only one section heading is 364// visible. 365class _SnappingScrollPhysics extends ClampingScrollPhysics { 366 const _SnappingScrollPhysics({ 367 ScrollPhysics parent, 368 @required this.midScrollOffset, 369 }) : assert(midScrollOffset != null), 370 super(parent: parent); 371 372 final double midScrollOffset; 373 374 @override 375 _SnappingScrollPhysics applyTo(ScrollPhysics ancestor) { 376 return _SnappingScrollPhysics(parent: buildParent(ancestor), midScrollOffset: midScrollOffset); 377 } 378 379 Simulation _toMidScrollOffsetSimulation(double offset, double dragVelocity) { 380 final double velocity = math.max(dragVelocity, minFlingVelocity); 381 return ScrollSpringSimulation(spring, offset, midScrollOffset, velocity, tolerance: tolerance); 382 } 383 384 Simulation _toZeroScrollOffsetSimulation(double offset, double dragVelocity) { 385 final double velocity = math.max(dragVelocity, minFlingVelocity); 386 return ScrollSpringSimulation(spring, offset, 0.0, velocity, tolerance: tolerance); 387 } 388 389 @override 390 Simulation createBallisticSimulation(ScrollMetrics position, double dragVelocity) { 391 final Simulation simulation = super.createBallisticSimulation(position, dragVelocity); 392 final double offset = position.pixels; 393 394 if (simulation != null) { 395 // The drag ended with sufficient velocity to trigger creating a simulation. 396 // If the simulation is headed up towards midScrollOffset but will not reach it, 397 // then snap it there. Similarly if the simulation is headed down past 398 // midScrollOffset but will not reach zero, then snap it to zero. 399 final double simulationEnd = simulation.x(double.infinity); 400 if (simulationEnd >= midScrollOffset) 401 return simulation; 402 if (dragVelocity > 0.0) 403 return _toMidScrollOffsetSimulation(offset, dragVelocity); 404 if (dragVelocity < 0.0) 405 return _toZeroScrollOffsetSimulation(offset, dragVelocity); 406 } else { 407 // The user ended the drag with little or no velocity. If they 408 // didn't leave the offset above midScrollOffset, then 409 // snap to midScrollOffset if they're more than halfway there, 410 // otherwise snap to zero. 411 final double snapThreshold = midScrollOffset / 2.0; 412 if (offset >= snapThreshold && offset < midScrollOffset) 413 return _toMidScrollOffsetSimulation(offset, dragVelocity); 414 if (offset > 0.0 && offset < snapThreshold) 415 return _toZeroScrollOffsetSimulation(offset, dragVelocity); 416 } 417 return simulation; 418 } 419} 420 421class AnimationDemoHome extends StatefulWidget { 422 const AnimationDemoHome({ Key key }) : super(key: key); 423 424 static const String routeName = '/animation'; 425 426 @override 427 _AnimationDemoHomeState createState() => _AnimationDemoHomeState(); 428} 429 430class _AnimationDemoHomeState extends State<AnimationDemoHome> { 431 final ScrollController _scrollController = ScrollController(); 432 final PageController _headingPageController = PageController(); 433 final PageController _detailsPageController = PageController(); 434 ScrollPhysics _headingScrollPhysics = const NeverScrollableScrollPhysics(); 435 ValueNotifier<double> selectedIndex = ValueNotifier<double>(0.0); 436 437 @override 438 Widget build(BuildContext context) { 439 return Scaffold( 440 backgroundColor: _kAppBackgroundColor, 441 body: Builder( 442 // Insert an element so that _buildBody can find the PrimaryScrollController. 443 builder: _buildBody, 444 ), 445 ); 446 } 447 448 void _handleBackButton(double midScrollOffset) { 449 if (_scrollController.offset >= midScrollOffset) 450 _scrollController.animateTo(0.0, curve: _kScrollCurve, duration: _kScrollDuration); 451 else 452 Navigator.maybePop(context); 453 } 454 455 // Only enable paging for the heading when the user has scrolled to midScrollOffset. 456 // Paging is enabled/disabled by setting the heading's PageView scroll physics. 457 bool _handleScrollNotification(ScrollNotification notification, double midScrollOffset) { 458 if (notification.depth == 0 && notification is ScrollUpdateNotification) { 459 final ScrollPhysics physics = _scrollController.position.pixels >= midScrollOffset 460 ? const PageScrollPhysics() 461 : const NeverScrollableScrollPhysics(); 462 if (physics != _headingScrollPhysics) { 463 setState(() { 464 _headingScrollPhysics = physics; 465 }); 466 } 467 } 468 return false; 469 } 470 471 void _maybeScroll(double midScrollOffset, int pageIndex, double xOffset) { 472 if (_scrollController.offset < midScrollOffset) { 473 // Scroll the overall list to the point where only one section card shows. 474 // At the same time scroll the PageViews to the page at pageIndex. 475 _headingPageController.animateToPage(pageIndex, curve: _kScrollCurve, duration: _kScrollDuration); 476 _scrollController.animateTo(midScrollOffset, curve: _kScrollCurve, duration: _kScrollDuration); 477 } else { 478 // One one section card is showing: scroll one page forward or back. 479 final double centerX = _headingPageController.position.viewportDimension / 2.0; 480 final int newPageIndex = xOffset > centerX ? pageIndex + 1 : pageIndex - 1; 481 _headingPageController.animateToPage(newPageIndex, curve: _kScrollCurve, duration: _kScrollDuration); 482 } 483 } 484 485 bool _handlePageNotification(ScrollNotification notification, PageController leader, PageController follower) { 486 if (notification.depth == 0 && notification is ScrollUpdateNotification) { 487 selectedIndex.value = leader.page; 488 if (follower.page != leader.page) 489 follower.position.jumpToWithoutSettling(leader.position.pixels); // ignore: deprecated_member_use 490 } 491 return false; 492 } 493 494 Iterable<Widget> _detailItemsFor(Section section) { 495 final Iterable<Widget> detailItems = section.details.map<Widget>((SectionDetail detail) { 496 return SectionDetailView(detail: detail); 497 }); 498 return ListTile.divideTiles(context: context, tiles: detailItems); 499 } 500 501 Iterable<Widget> _allHeadingItems(double maxHeight, double midScrollOffset) { 502 final List<Widget> sectionCards = <Widget>[]; 503 for (int index = 0; index < allSections.length; index++) { 504 sectionCards.add(LayoutId( 505 id: 'card$index', 506 child: GestureDetector( 507 behavior: HitTestBehavior.opaque, 508 child: SectionCard(section: allSections[index]), 509 onTapUp: (TapUpDetails details) { 510 final double xOffset = details.globalPosition.dx; 511 setState(() { 512 _maybeScroll(midScrollOffset, index, xOffset); 513 }); 514 }, 515 ), 516 )); 517 } 518 519 final List<Widget> headings = <Widget>[]; 520 for (int index = 0; index < allSections.length; index++) { 521 headings.add(Container( 522 color: _kAppBackgroundColor, 523 child: ClipRect( 524 child: _AllSectionsView( 525 sectionIndex: index, 526 sections: allSections, 527 selectedIndex: selectedIndex, 528 minHeight: _kAppBarMinHeight, 529 midHeight: _kAppBarMidHeight, 530 maxHeight: maxHeight, 531 sectionCards: sectionCards, 532 ), 533 ), 534 ) 535 ); 536 } 537 return headings; 538 } 539 540 Widget _buildBody(BuildContext context) { 541 final MediaQueryData mediaQueryData = MediaQuery.of(context); 542 final double statusBarHeight = mediaQueryData.padding.top; 543 final double screenHeight = mediaQueryData.size.height; 544 final double appBarMaxHeight = screenHeight - statusBarHeight; 545 546 // The scroll offset that reveals the appBarMidHeight appbar. 547 final double appBarMidScrollOffset = statusBarHeight + appBarMaxHeight - _kAppBarMidHeight; 548 549 return SizedBox.expand( 550 child: Stack( 551 children: <Widget>[ 552 NotificationListener<ScrollNotification>( 553 onNotification: (ScrollNotification notification) { 554 return _handleScrollNotification(notification, appBarMidScrollOffset); 555 }, 556 child: CustomScrollView( 557 controller: _scrollController, 558 physics: _SnappingScrollPhysics(midScrollOffset: appBarMidScrollOffset), 559 slivers: <Widget>[ 560 // Start out below the status bar, gradually move to the top of the screen. 561 _StatusBarPaddingSliver( 562 maxHeight: statusBarHeight, 563 scrollFactor: 7.0, 564 ), 565 // Section Headings 566 SliverPersistentHeader( 567 pinned: true, 568 delegate: _SliverAppBarDelegate( 569 minHeight: _kAppBarMinHeight, 570 maxHeight: appBarMaxHeight, 571 child: NotificationListener<ScrollNotification>( 572 onNotification: (ScrollNotification notification) { 573 return _handlePageNotification(notification, _headingPageController, _detailsPageController); 574 }, 575 child: PageView( 576 physics: _headingScrollPhysics, 577 controller: _headingPageController, 578 children: _allHeadingItems(appBarMaxHeight, appBarMidScrollOffset), 579 ), 580 ), 581 ), 582 ), 583 // Details 584 SliverToBoxAdapter( 585 child: SizedBox( 586 height: 610.0, 587 child: NotificationListener<ScrollNotification>( 588 onNotification: (ScrollNotification notification) { 589 return _handlePageNotification(notification, _detailsPageController, _headingPageController); 590 }, 591 child: PageView( 592 controller: _detailsPageController, 593 children: allSections.map<Widget>((Section section) { 594 return Column( 595 crossAxisAlignment: CrossAxisAlignment.stretch, 596 children: _detailItemsFor(section).toList(), 597 ); 598 }).toList(), 599 ), 600 ), 601 ), 602 ), 603 ], 604 ), 605 ), 606 Positioned( 607 top: statusBarHeight, 608 left: 0.0, 609 child: IconTheme( 610 data: const IconThemeData(color: Colors.white), 611 child: SafeArea( 612 top: false, 613 bottom: false, 614 child: IconButton( 615 icon: const BackButtonIcon(), 616 tooltip: 'Back', 617 onPressed: () { 618 _handleBackButton(appBarMidScrollOffset); 619 }, 620 ), 621 ), 622 ), 623 ), 624 ], 625 ), 626 ); 627 } 628} 629