Created
July 28, 2017 18:47
-
-
Save HansMuller/1f08e755ed444785ab875a7a0ccd814f to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:math' as math; | |
import 'package:flutter/material.dart'; | |
const _kMidHeadingsHeight = 128.0; | |
const _kMinHeadingsHeight = 72.0; | |
// SectionHeadingsLayout computes the initial bounds of each heading based | |
// on the layout configuration: | |
// - Column: headings are as wide as possible and equal height | |
// - Row: each heading's width matches the available width, height is _mMinHeadingsHeight | |
// The final bounds for each heading are based on the initial bounds "deflated" | |
// by these inset values. | |
const EdgeInsets _kColHeadingInsets = const EdgeInsets.only(left: 32.0, right: 8.0); | |
const EdgeInsets _kRowHeadingInsets = EdgeInsets.zero; | |
// SectionHeadingsLayout inserts a vertical gap between headings in the column | |
// configuration. | |
const _kHeadingGap = 8.0; | |
// Scroll animation from the full-screen section heading column configuration | |
// to the row configuration. | |
const Duration _kScrollDuration = const Duration(milliseconds: 400); | |
const Curve _kScrollCurve = Curves.fastOutSlowIn; | |
// If the section heading is in its row configuration, flings slower | |
// than this will cause it to stay that way rather than snap to the | |
// column configuration. | |
const _kToZeroDragThreshold = -800.0; | |
const TextStyle _kTitleStyle = const TextStyle( | |
inherit: false, | |
fontSize: 24.0, | |
fontWeight: FontWeight.w500, | |
color: Colors.white, | |
textBaseline: TextBaseline.alphabetic, | |
); | |
class Section { | |
const Section({ this.title, this.color }); | |
final String title; | |
final Color color; | |
} | |
const List<Section> allSections = const <Section>[ | |
const Section( | |
title: 'ONE', | |
color: Colors.indigo, | |
), | |
const Section( | |
title: 'TWO', | |
color: Colors.deepPurple, | |
), | |
const Section( | |
title: 'FREE', | |
color: Colors.amber, | |
), | |
const Section( | |
title: 'FOUR', | |
color: Colors.lightBlue, | |
), | |
const Section( | |
title: 'FIVE', | |
color: Colors.teal, | |
), | |
]; | |
class SectionItem extends StatelessWidget { | |
SectionItem({ Key key, this.section, this.index }) : super(key: key); | |
final Section section; | |
final int index; | |
@override | |
build(BuildContext context) { | |
return new Container( | |
margin: const EdgeInsets.only(top: 16.0, left: 8.0, right: 8.0), | |
height: 48.0, // height + vertical margins = 64.0, see SliverFixedExtentList itemExtent below | |
color: section.color, | |
alignment: FractionalOffset.center, | |
child: new Text('${section.title} Item $index', style: _kTitleStyle), | |
); | |
} | |
} | |
class SectionHeading extends StatelessWidget { | |
SectionHeading({ Key key, this.section }) : super(key: key); | |
final Section section; | |
@override | |
Widget build(BuildContext context) { | |
return new Container( | |
color: section.color, | |
alignment: FractionalOffset.center, | |
child: new Text(section.title, style: _kTitleStyle), | |
); | |
} | |
} | |
class SectionHeadingsLayout extends MultiChildLayoutDelegate { | |
SectionHeadingsLayout({ | |
this.headingCount, | |
this.maxHeight, | |
this.selectedIndex, | |
}); | |
final int headingCount; | |
final double maxHeight; | |
final int selectedIndex; | |
@override | |
void performLayout(Size size) { | |
final double headingHeight = (maxHeight - _kHeadingGap * (headingCount - 1)) / headingCount; | |
final double tColumnToRow = 1.0 - (size.height - _kMidHeadingsHeight) / (maxHeight - _kMidHeadingsHeight); | |
final double colHeadingHeight = (maxHeight - _kHeadingGap * (headingCount - 1)) / headingCount; | |
final Size colHeadingSize = new Size(size.width, colHeadingHeight); | |
final double rowHeadingHeight = (tColumnToRow <= 1.0) ? _kMidHeadingsHeight : size.height; | |
final Size rowHeadingSize = new Size(size.width, rowHeadingHeight); | |
double columnY = 0.0; | |
double rowX = -1.0 * selectedIndex * rowHeadingSize.width; | |
for (int index = 0; index < headingCount; index++) { | |
final Rect colHeadingRect = _kColHeadingInsets.deflateRect(new Offset(0.0, columnY) & colHeadingSize); | |
final Rect rowHeadingRect = _kRowHeadingInsets.deflateRect(new Offset(rowX, 0.0) & rowHeadingSize); | |
final Rect headingRect = Rect.lerp(colHeadingRect, rowHeadingRect, tColumnToRow.clamp(0.0, 1.0)); | |
final String headingId = 'heading$index'; | |
final Size headingSize = layoutChild(headingId, new BoxConstraints.tight(headingRect.size)); | |
positionChild(headingId, headingRect.topLeft); | |
columnY += headingSize.height + _kHeadingGap; | |
rowX += headingSize.width; | |
} | |
} | |
@override | |
bool shouldRelayout(SectionHeadingsLayout oldDelegate) { | |
return headingCount != oldDelegate.headingCount | |
|| maxHeight != oldDelegate.maxHeight | |
|| selectedIndex != oldDelegate.selectedIndex; | |
} | |
} | |
class SliverSectionHeadingsDelegate extends SliverPersistentHeaderDelegate { | |
SliverSectionHeadingsDelegate({ this.maxHeight, this.child }); | |
final double maxHeight; | |
final Widget child; | |
@override double get minExtent => _kMinHeadingsHeight; | |
@override double get maxExtent => math.max(maxHeight, _kMinHeadingsHeight); | |
@override | |
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { | |
return new SizedBox.expand(child: child); | |
} | |
@override | |
bool shouldRebuild(SliverSectionHeadingsDelegate oldDelegate) { | |
return maxHeight != oldDelegate.maxHeight || child != oldDelegate.child; | |
} | |
} | |
class _SnappingScrollPhysics extends ClampingScrollPhysics { | |
const _SnappingScrollPhysics({ | |
ScrollPhysics parent, | |
@required this.midScrollOffset, | |
}) : assert(midScrollOffset != null), super(parent: parent); | |
final double midScrollOffset; | |
@override | |
_SnappingScrollPhysics applyTo(ScrollPhysics ancestor) { | |
return new _SnappingScrollPhysics(parent: buildParent(ancestor), midScrollOffset: midScrollOffset); | |
} | |
Simulation _toMidScrollOffsetSimulation(double offset, double dragVelocity) { | |
final double velocity = math.max(dragVelocity, minFlingVelocity); | |
return new ScrollSpringSimulation(spring, offset, midScrollOffset, velocity, tolerance: tolerance); | |
} | |
Simulation _toZeroScrollOffsetSimulation(double offset, double dragVelocity) { | |
final double velocity = math.max(dragVelocity, minFlingVelocity); | |
return new 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) { | |
final double simulationEnd = simulation.x(double.INFINITY); | |
// The drag ended with sufficient velocity to trigger creating a simulation. | |
// If the simulation is headed up towards midScrollOffset then snap it there. | |
// Similarly if the simulation is headed down past midScrollOffset but will | |
// not reach zero, then snap it to zero. | |
if (offset >= midScrollOffset && simulationEnd >= midScrollOffset) | |
return simulation; | |
if (dragVelocity > 0.0) | |
return _toMidScrollOffsetSimulation(offset, dragVelocity); | |
if (dragVelocity < _kToZeroDragThreshold) | |
return _toZeroScrollOffsetSimulation(offset, dragVelocity); | |
} else { | |
// The user ended the drag with little or no velocity. If they | |
// didn't leave the 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 PosseDemo extends StatefulWidget { | |
@override | |
PosseDemoState createState() => new PosseDemoState(); | |
} | |
class PosseDemoState extends State<PosseDemo> { | |
ScrollController _scrollController = new ScrollController(); | |
PageController _pageController; | |
int _selectedIndex = 2; | |
Widget _buildRowSectionHeadings(Size size, double maxHeadingsHeight) { | |
_pageController = new PageController(initialPage: _selectedIndex); | |
return new PageView( | |
controller: _pageController, | |
onPageChanged: (int page) { | |
setState(() { | |
_selectedIndex = page; | |
}); | |
}, | |
children: allSections.map((Section section) { | |
return new SectionHeading(section: section); | |
}).toList(), | |
); | |
} | |
Widget _buildColSectionHeadings(Size size, double maxHeadingsHeight, double midHeightScrollOffset) { | |
_pageController = null; | |
final List<Widget> sectionHeadings = new List<Widget>(allSections.length); | |
for (int index = 0; index < allSections.length; index++) { | |
sectionHeadings[index] = new LayoutId( | |
id: 'heading$index', | |
child: new GestureDetector( | |
onTap: () { | |
setState(() { | |
_selectedIndex = index; | |
}); | |
_scrollController.animateTo(midHeightScrollOffset, | |
curve: _kScrollCurve, | |
duration: _kScrollDuration, | |
); | |
}, | |
child: new SectionHeading(section: allSections[index]), | |
), | |
); | |
} | |
return new CustomMultiChildLayout( | |
delegate: new SectionHeadingsLayout( | |
headingCount: allSections.length, | |
maxHeight: maxHeadingsHeight, | |
selectedIndex: _selectedIndex, | |
), | |
children: sectionHeadings, | |
); | |
} | |
SliverFixedExtentList _buildSectionDetails(int sectionIndex) { | |
return new SliverFixedExtentList( | |
itemExtent: 64.0, | |
delegate: new SliverChildBuilderDelegate( | |
(BuildContext context, int index) { | |
return new SectionItem( | |
section: allSections[sectionIndex], | |
index: index, | |
); | |
}, | |
childCount: 16, | |
), | |
); | |
} | |
@override | |
Widget build(BuildContext context) { | |
final MediaQueryData mediaQueryData = MediaQuery.of(context); | |
final double screenHeight = mediaQueryData.size.height; | |
final double statusBarHeight = mediaQueryData.padding.top; | |
final double maxHeadingsHeight = screenHeight - statusBarHeight; | |
final double midHeightScrollOffset = maxHeadingsHeight - _kMidHeadingsHeight; | |
return new Scaffold( | |
body: new Padding( | |
padding: new EdgeInsets.only(top: statusBarHeight), | |
child: new CustomScrollView( | |
controller: _scrollController, | |
physics: new _SnappingScrollPhysics(midScrollOffset: midHeightScrollOffset), | |
slivers: <Widget>[ | |
new SliverPersistentHeader( | |
pinned: true, | |
delegate: new SliverSectionHeadingsDelegate( | |
maxHeight: maxHeadingsHeight, | |
child: new LayoutBuilder( | |
builder: (BuildContext context, BoxConstraints constraints) { | |
final Size size = constraints.biggest; | |
return (size.height <= _kMidHeadingsHeight) | |
? _buildRowSectionHeadings(size, maxHeadingsHeight) | |
: _buildColSectionHeadings(size, maxHeadingsHeight, midHeightScrollOffset); | |
}, | |
), | |
), | |
), | |
_buildSectionDetails(_selectedIndex), | |
], | |
), | |
), | |
); | |
} | |
} | |
void main() { | |
runApp(new MaterialApp(home: new PosseDemo())); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment