blob: 3803e9ea15519ca11f3dea9374c075536355f80e [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Based on https://material.uplabs.com/posts/google-newsstand-navigation-pattern
// See also: https://material-motion.github.io/material-motion/documentation/
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'sections.dart';
import 'widgets.dart';
const Color _kAppBackgroundColor = Color(0xFF353662);
const Duration _kScrollDuration = Duration(milliseconds: 400);
const Curve _kScrollCurve = Curves.fastOutSlowIn;
// This app's contents start out at _kHeadingMaxHeight and they function like
// an appbar. Initially the appbar occupies most of the screen and its section
// headings are laid out in a column. By the time its height has been
// reduced to _kAppBarMidHeight, its layout is horizontal, only one section
// heading is visible, and the section's list of details is visible below the
// heading. The appbar's height can be reduced to no more than _kAppBarMinHeight.
const double _kAppBarMinHeight = 90.0;
const double _kAppBarMidHeight = 256.0;
// The AppBar's max height depends on the screen, see _AnimationDemoHomeState._buildBody()
// Initially occupies the same space as the status bar and gets smaller as
// the primary scrollable scrolls upwards.
// TODO(hansmuller): it would be worth adding something like this to the framework.
class _RenderStatusBarPaddingSliver extends RenderSliver {
_RenderStatusBarPaddingSliver({
required double maxHeight,
required double scrollFactor,
}) : assert(maxHeight >= 0.0),
assert(scrollFactor >= 1.0),
_maxHeight = maxHeight,
_scrollFactor = scrollFactor;
// The height of the status bar
double get maxHeight => _maxHeight;
double _maxHeight;
set maxHeight(double value) {
assert(maxHeight >= 0.0);
if (_maxHeight == value) {
return;
}
_maxHeight = value;
markNeedsLayout();
}
// That rate at which this renderer's height shrinks when the scroll
// offset changes.
double get scrollFactor => _scrollFactor;
double _scrollFactor;
set scrollFactor(double value) {
assert(scrollFactor >= 1.0);
if (_scrollFactor == value) {
return;
}
_scrollFactor = value;
markNeedsLayout();
}
@override
void performLayout() {
final double height = (maxHeight - constraints.scrollOffset / scrollFactor).clamp(0.0, maxHeight);
geometry = SliverGeometry(
paintExtent: math.min(height, constraints.remainingPaintExtent),
scrollExtent: maxHeight,
maxPaintExtent: maxHeight,
);
}
}
class _StatusBarPaddingSliver extends SingleChildRenderObjectWidget {
const _StatusBarPaddingSliver({
required this.maxHeight,
this.scrollFactor = 5.0,
}) : assert(maxHeight >= 0.0),
assert(scrollFactor >= 1.0);
final double maxHeight;
final double scrollFactor;
@override
_RenderStatusBarPaddingSliver createRenderObject(BuildContext context) {
return _RenderStatusBarPaddingSliver(
maxHeight: maxHeight,
scrollFactor: scrollFactor,
);
}
@override
void updateRenderObject(BuildContext context, _RenderStatusBarPaddingSliver renderObject) {
renderObject
..maxHeight = maxHeight
..scrollFactor = scrollFactor;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description);
description.add(DoubleProperty('maxHeight', maxHeight));
description.add(DoubleProperty('scrollFactor', scrollFactor));
}
}
class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
_SliverAppBarDelegate({
required this.minHeight,
required this.maxHeight,
required this.child,
});
final double minHeight;
final double maxHeight;
final Widget child;
@override
double get minExtent => minHeight;
@override
double get maxExtent => math.max(maxHeight, minHeight);
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return SizedBox.expand(child: child);
}
@override
bool shouldRebuild(_SliverAppBarDelegate oldDelegate) {
return maxHeight != oldDelegate.maxHeight
|| minHeight != oldDelegate.minHeight
|| child != oldDelegate.child;
}
@override
String toString() => '_SliverAppBarDelegate';
}
// Arrange the section titles, indicators, and cards. The cards are only included when
// the layout is transitioning between vertical and horizontal. Once the layout is
// horizontal the cards are laid out by a PageView.
//
// The layout of the section cards, titles, and indicators is defined by the
// two 0.0-1.0 "t" parameters, both of which are based on the layout's height:
// - tColumnToRow
// 0.0 when height is maxHeight and the layout is a column
// 1.0 when the height is midHeight and the layout is a row
// - tCollapsed
// 0.0 when height is midHeight and the layout is a row
// 1.0 when height is minHeight and the layout is a (still) row
//
// minHeight < midHeight < maxHeight
//
// The general approach here is to compute the column layout and row size
// and position of each element and then interpolate between them using
// tColumnToRow. Once tColumnToRow reaches 1.0, the layout changes are
// defined by tCollapsed. As tCollapsed increases the titles spread out
// until only one title is visible and the indicators cluster together
// until they're all visible.
class _AllSectionsLayout extends MultiChildLayoutDelegate {
_AllSectionsLayout({
this.translation,
this.tColumnToRow,
this.tCollapsed,
this.cardCount,
this.selectedIndex,
});
final Alignment? translation;
final double? tColumnToRow;
final double? tCollapsed;
final int? cardCount;
final double? selectedIndex;
Rect? _interpolateRect(Rect begin, Rect end) {
return Rect.lerp(begin, end, tColumnToRow!);
}
Offset? _interpolatePoint(Offset begin, Offset end) {
return Offset.lerp(begin, end, tColumnToRow!);
}
@override
void performLayout(Size size) {
final double columnCardX = size.width / 5.0;
final double columnCardWidth = size.width - columnCardX;
final double columnCardHeight = size.height / cardCount!;
final double rowCardWidth = size.width;
final Offset offset = translation!.alongSize(size);
double columnCardY = 0.0;
double rowCardX = -(selectedIndex! * rowCardWidth);
// When tCollapsed > 0 the titles spread apart
final double columnTitleX = size.width / 10.0;
final double rowTitleWidth = size.width * ((1 + tCollapsed!) / 2.25);
double rowTitleX = (size.width - rowTitleWidth) / 2.0 - selectedIndex! * rowTitleWidth;
// When tCollapsed > 0, the indicators move closer together
//final double rowIndicatorWidth = 48.0 + (1.0 - tCollapsed) * (rowTitleWidth - 48.0);
const double paddedSectionIndicatorWidth = kSectionIndicatorWidth + 8.0;
final double rowIndicatorWidth = paddedSectionIndicatorWidth +
(1.0 - tCollapsed!) * (rowTitleWidth - paddedSectionIndicatorWidth);
double rowIndicatorX = (size.width - rowIndicatorWidth) / 2.0 - selectedIndex! * rowIndicatorWidth;
// Compute the size and origin of each card, title, and indicator for the maxHeight
// "column" layout, and the midHeight "row" layout. The actual layout is just the
// interpolated value between the column and row layouts for t.
for (int index = 0; index < cardCount!; index++) {
// Layout the card for index.
final Rect columnCardRect = Rect.fromLTWH(columnCardX, columnCardY, columnCardWidth, columnCardHeight);
final Rect rowCardRect = Rect.fromLTWH(rowCardX, 0.0, rowCardWidth, size.height);
final Rect cardRect = _interpolateRect(columnCardRect, rowCardRect)!.shift(offset);
final String cardId = 'card$index';
if (hasChild(cardId)) {
layoutChild(cardId, BoxConstraints.tight(cardRect.size));
positionChild(cardId, cardRect.topLeft);
}
// Layout the title for index.
final Size titleSize = layoutChild('title$index', BoxConstraints.loose(cardRect.size));
final double columnTitleY = columnCardRect.centerLeft.dy - titleSize.height / 2.0;
final double rowTitleY = rowCardRect.centerLeft.dy - titleSize.height / 2.0;
final double centeredRowTitleX = rowTitleX + (rowTitleWidth - titleSize.width) / 2.0;
final Offset columnTitleOrigin = Offset(columnTitleX, columnTitleY);
final Offset rowTitleOrigin = Offset(centeredRowTitleX, rowTitleY);
final Offset titleOrigin = _interpolatePoint(columnTitleOrigin, rowTitleOrigin)!;
positionChild('title$index', titleOrigin + offset);
// Layout the selection indicator for index.
final Size indicatorSize = layoutChild('indicator$index', BoxConstraints.loose(cardRect.size));
final double columnIndicatorX = cardRect.centerRight.dx - indicatorSize.width - 16.0;
final double columnIndicatorY = cardRect.bottomRight.dy - indicatorSize.height - 16.0;
final Offset columnIndicatorOrigin = Offset(columnIndicatorX, columnIndicatorY);
final Rect titleRect = Rect.fromPoints(titleOrigin, titleSize.bottomRight(titleOrigin));
final double centeredRowIndicatorX = rowIndicatorX + (rowIndicatorWidth - indicatorSize.width) / 2.0;
final double rowIndicatorY = titleRect.bottomCenter.dy + 16.0;
final Offset rowIndicatorOrigin = Offset(centeredRowIndicatorX, rowIndicatorY);
final Offset indicatorOrigin = _interpolatePoint(columnIndicatorOrigin, rowIndicatorOrigin)!;
positionChild('indicator$index', indicatorOrigin + offset);
columnCardY += columnCardHeight;
rowCardX += rowCardWidth;
rowTitleX += rowTitleWidth;
rowIndicatorX += rowIndicatorWidth;
}
}
@override
bool shouldRelayout(_AllSectionsLayout oldDelegate) {
return tColumnToRow != oldDelegate.tColumnToRow
|| cardCount != oldDelegate.cardCount
|| selectedIndex != oldDelegate.selectedIndex;
}
}
class _AllSectionsView extends AnimatedWidget {
_AllSectionsView({
required this.sectionIndex,
required this.sections,
required this.selectedIndex,
this.minHeight,
this.midHeight,
this.maxHeight,
this.sectionCards = const <Widget>[],
}) : assert(sectionCards.length == sections.length),
assert(sectionIndex >= 0 && sectionIndex < sections.length),
assert(selectedIndex.value! >= 0.0 && selectedIndex.value! < sections.length.toDouble()),
super(listenable: selectedIndex);
final int sectionIndex;
final List<Section> sections;
final ValueNotifier<double?> selectedIndex;
final double? minHeight;
final double? midHeight;
final double? maxHeight;
final List<Widget> sectionCards;
double _selectedIndexDelta(int index) {
return (index.toDouble() - selectedIndex.value!).abs().clamp(0.0, 1.0);
}
Widget _build(BuildContext context, BoxConstraints constraints) {
final Size size = constraints.biggest;
// The layout's progress from a column to a row. Its value is
// 0.0 when size.height equals the maxHeight, 1.0 when the size.height
// equals the midHeight.
final double tColumnToRow =
1.0 - ((size.height - midHeight!) /
(maxHeight! - midHeight!)).clamp(0.0, 1.0);
// The layout's progress from the midHeight row layout to
// a minHeight row layout. Its value is 0.0 when size.height equals
// midHeight and 1.0 when size.height equals minHeight.
final double tCollapsed =
1.0 - ((size.height - minHeight!) /
(midHeight! - minHeight!)).clamp(0.0, 1.0);
double indicatorOpacity(int index) {
return 1.0 - _selectedIndexDelta(index) * 0.5;
}
double titleOpacity(int index) {
return 1.0 - _selectedIndexDelta(index) * tColumnToRow * 0.5;
}
double titleScale(int index) {
return 1.0 - _selectedIndexDelta(index) * tColumnToRow * 0.15;
}
final List<Widget> children = List<Widget>.from(sectionCards);
for (int index = 0; index < sections.length; index++) {
final Section section = sections[index];
children.add(LayoutId(
id: 'title$index',
child: SectionTitle(
section: section,
scale: titleScale(index),
opacity: titleOpacity(index),
),
));
}
for (int index = 0; index < sections.length; index++) {
children.add(LayoutId(
id: 'indicator$index',
child: SectionIndicator(
opacity: indicatorOpacity(index),
),
));
}
return CustomMultiChildLayout(
delegate: _AllSectionsLayout(
translation: Alignment((selectedIndex.value! - sectionIndex) * 2.0 - 1.0, -1.0),
tColumnToRow: tColumnToRow,
tCollapsed: tCollapsed,
cardCount: sections.length,
selectedIndex: selectedIndex.value,
),
children: children,
);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: _build);
}
}
// Support snapping scrolls to the midScrollOffset: the point at which the
// app bar's height is _kAppBarMidHeight and only one section heading is
// visible.
class _SnappingScrollPhysics extends ClampingScrollPhysics {
const _SnappingScrollPhysics({
super.parent,
required this.midScrollOffset,
});
final double midScrollOffset;
@override
_SnappingScrollPhysics applyTo(ScrollPhysics? ancestor) {
return _SnappingScrollPhysics(parent: buildParent(ancestor), midScrollOffset: midScrollOffset);
}
Simulation _toMidScrollOffsetSimulation(double offset, double dragVelocity) {
final double velocity = math.max(dragVelocity, minFlingVelocity);
return ScrollSpringSimulation(spring, offset, midScrollOffset, velocity, tolerance: tolerance);
}
Simulation _toZeroScrollOffsetSimulation(double offset, double dragVelocity) {
final double velocity = math.max(dragVelocity, minFlingVelocity);
return ScrollSpringSimulation(spring, offset, 0.0, velocity, tolerance: tolerance);
}
@override
Simulation? createBallisticSimulation(ScrollMetrics position, double dragVelocity) {
final Simulation? simulation = super.createBallisticSimulation(position, dragVelocity);
final double offset = position.pixels;
if (simulation != null) {
// The drag ended with sufficient velocity to trigger creating a simulation.
// If the simulation is headed up towards midScrollOffset but will not reach it,
// then snap it there. Similarly if the simulation is headed down past
// midScrollOffset but will not reach zero, then snap it to zero.
final double simulationEnd = simulation.x(double.infinity);
if (simulationEnd >= midScrollOffset) {
return simulation;
}
if (dragVelocity > 0.0) {
return _toMidScrollOffsetSimulation(offset, dragVelocity);
}
if (dragVelocity < 0.0) {
return _toZeroScrollOffsetSimulation(offset, dragVelocity);
}
} else {
// The user ended the drag with little or no velocity. If they
// didn't leave the offset above midScrollOffset, then
// snap to midScrollOffset if they're more than halfway there,
// otherwise snap to zero.
final double snapThreshold = midScrollOffset / 2.0;
if (offset >= snapThreshold && offset < midScrollOffset) {
return _toMidScrollOffsetSimulation(offset, dragVelocity);
}
if (offset > 0.0 && offset < snapThreshold) {
return _toZeroScrollOffsetSimulation(offset, dragVelocity);
}
}
return simulation;
}
}
class AnimationDemoHome extends StatefulWidget {
const AnimationDemoHome({ super.key });
static const String routeName = '/animation';
@override
State<AnimationDemoHome> createState() => _AnimationDemoHomeState();
}
class _AnimationDemoHomeState extends State<AnimationDemoHome> {
final ScrollController _scrollController = ScrollController();
final PageController _headingPageController = PageController();
final PageController _detailsPageController = PageController();
ScrollPhysics _headingScrollPhysics = const NeverScrollableScrollPhysics();
ValueNotifier<double?> selectedIndex = ValueNotifier<double?>(0.0);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _kAppBackgroundColor,
body: Builder(
// Insert an element so that _buildBody can find the PrimaryScrollController.
builder: _buildBody,
),
);
}
void _handleBackButton(double midScrollOffset) {
if (_scrollController.offset >= midScrollOffset) {
_scrollController.animateTo(0.0, curve: _kScrollCurve, duration: _kScrollDuration);
} else {
Navigator.maybePop(context);
}
}
// Only enable paging for the heading when the user has scrolled to midScrollOffset.
// Paging is enabled/disabled by setting the heading's PageView scroll physics.
bool _handleScrollNotification(ScrollNotification notification, double midScrollOffset) {
if (notification.depth == 0 && notification is ScrollUpdateNotification) {
final ScrollPhysics physics = _scrollController.position.pixels >= midScrollOffset
? const PageScrollPhysics()
: const NeverScrollableScrollPhysics();
if (physics != _headingScrollPhysics) {
setState(() {
_headingScrollPhysics = physics;
});
}
}
return false;
}
void _maybeScroll(double midScrollOffset, int pageIndex, double xOffset) {
if (_scrollController.offset < midScrollOffset) {
// Scroll the overall list to the point where only one section card shows.
// At the same time scroll the PageViews to the page at pageIndex.
_headingPageController.animateToPage(pageIndex, curve: _kScrollCurve, duration: _kScrollDuration);
_scrollController.animateTo(midScrollOffset, curve: _kScrollCurve, duration: _kScrollDuration);
} else {
// One one section card is showing: scroll one page forward or back.
final double centerX = _headingPageController.position.viewportDimension / 2.0;
final int newPageIndex = xOffset > centerX ? pageIndex + 1 : pageIndex - 1;
_headingPageController.animateToPage(newPageIndex, curve: _kScrollCurve, duration: _kScrollDuration);
}
}
bool _handlePageNotification(ScrollNotification notification, PageController leader, PageController follower) {
if (notification.depth == 0 && notification is ScrollUpdateNotification) {
selectedIndex.value = leader.page;
if (follower.page != leader.page) {
follower.position.jumpToWithoutSettling(leader.position.pixels); // ignore: deprecated_member_use
}
}
return false;
}
Iterable<Widget> _detailItemsFor(Section section) {
final Iterable<Widget> detailItems = section.details!.map<Widget>((SectionDetail detail) {
return SectionDetailView(detail: detail);
});
return ListTile.divideTiles(context: context, tiles: detailItems);
}
List<Widget> _allHeadingItems(double maxHeight, double midScrollOffset) {
final List<Widget> sectionCards = <Widget>[];
for (int index = 0; index < allSections.length; index++) {
sectionCards.add(LayoutId(
id: 'card$index',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
child: SectionCard(section: allSections[index]),
onTapUp: (TapUpDetails details) {
final double xOffset = details.globalPosition.dx;
setState(() {
_maybeScroll(midScrollOffset, index, xOffset);
});
},
),
));
}
final List<Widget> headings = <Widget>[];
for (int index = 0; index < allSections.length; index++) {
headings.add(Container(
color: _kAppBackgroundColor,
child: ClipRect(
child: _AllSectionsView(
sectionIndex: index,
sections: allSections,
selectedIndex: selectedIndex,
minHeight: _kAppBarMinHeight,
midHeight: _kAppBarMidHeight,
maxHeight: maxHeight,
sectionCards: sectionCards,
),
),
)
);
}
return headings;
}
Widget _buildBody(BuildContext context) {
final MediaQueryData mediaQueryData = MediaQuery.of(context);
final double statusBarHeight = mediaQueryData.padding.top;
final double screenHeight = mediaQueryData.size.height;
final double appBarMaxHeight = screenHeight - statusBarHeight;
// The scroll offset that reveals the appBarMidHeight appbar.
final double appBarMidScrollOffset = statusBarHeight + appBarMaxHeight - _kAppBarMidHeight;
return SizedBox.expand(
child: Stack(
children: <Widget>[
NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
return _handleScrollNotification(notification, appBarMidScrollOffset);
},
child: CustomScrollView(
controller: _scrollController,
physics: _SnappingScrollPhysics(midScrollOffset: appBarMidScrollOffset),
slivers: <Widget>[
// Start out below the status bar, gradually move to the top of the screen.
_StatusBarPaddingSliver(
maxHeight: statusBarHeight,
scrollFactor: 7.0,
),
// Section Headings
SliverPersistentHeader(
pinned: true,
delegate: _SliverAppBarDelegate(
minHeight: _kAppBarMinHeight,
maxHeight: appBarMaxHeight,
child: NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
return _handlePageNotification(notification, _headingPageController, _detailsPageController);
},
child: PageView(
physics: _headingScrollPhysics,
controller: _headingPageController,
children: _allHeadingItems(appBarMaxHeight, appBarMidScrollOffset),
),
),
),
),
// Details
SliverToBoxAdapter(
child: SizedBox(
height: 610.0,
child: NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
return _handlePageNotification(notification, _detailsPageController, _headingPageController);
},
child: PageView(
controller: _detailsPageController,
children: allSections.map<Widget>((Section section) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _detailItemsFor(section).toList(),
);
}).toList(),
),
),
),
),
],
),
),
Positioned(
top: statusBarHeight,
left: 0.0,
child: IconTheme(
data: const IconThemeData(color: Colors.white),
child: SafeArea(
top: false,
bottom: false,
child: IconButton(
icon: const BackButtonIcon(),
tooltip: 'Back',
onPressed: () {
_handleBackButton(appBarMidScrollOffset);
},
),
),
),
),
],
),
);
}
}