From a446a3489425e52ab5438a1c9f62e96e155911ff Mon Sep 17 00:00:00 2001 From: Rami Abou Ghanem Date: Tue, 4 Feb 2020 16:31:38 -0500 Subject: [PATCH 1/5] Implement carousel for desktop view for studies --- gallery/gallery/lib/pages/home.dart | 191 +++++++++++++++++++++++++--- 1 file changed, 172 insertions(+), 19 deletions(-) diff --git a/gallery/gallery/lib/pages/home.dart b/gallery/gallery/lib/pages/home.dart index db3ecef3971..65158936b16 100644 --- a/gallery/gallery/lib/pages/home.dart +++ b/gallery/gallery/lib/pages/home.dart @@ -52,7 +52,7 @@ class HomePage extends StatelessWidget { Widget build(BuildContext context) { var carouselHeight = _carouselHeight(.7, context); final isDesktop = isDisplayDesktop(context); - final carouselCards = <_CarouselCard>[ + final carouselCards = [ _CarouselCard( title: shrineTitle, subtitle: GalleryLocalizations.of(context).shrineDescription, @@ -120,12 +120,15 @@ class HomePage extends StatelessWidget { return Scaffold( body: ListView( padding: EdgeInsetsDirectional.only( - start: _horizontalDesktopPadding, top: isDesktop ? firstHeaderDesktopTopPadding : 21, - end: _horizontalDesktopPadding, ), children: [ - ExcludeSemantics(child: _GalleryHeader()), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: _horizontalDesktopPadding, + ), + child: ExcludeSemantics(child: _GalleryHeader()), + ), /// TODO: When Focus widget becomes better remove dummy Focus /// variable. @@ -143,15 +146,19 @@ class HomePage extends StatelessWidget { ), Container( height: carouselHeight, - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: spaceBetween(30, carouselCards), + child: _DesktopCarousel(children: carouselCards), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: _horizontalDesktopPadding, ), + child: _CategoriesHeader(), ), - _CategoriesHeader(), Container( height: 585, + padding: const EdgeInsets.symmetric( + horizontal: _horizontalDesktopPadding, + ), child: Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -160,7 +167,9 @@ class HomePage extends StatelessWidget { ), Padding( padding: const EdgeInsetsDirectional.only( + start: _horizontalDesktopPadding, bottom: 81, + end: _horizontalDesktopPadding, top: 109, ), child: Row( @@ -198,17 +207,17 @@ class HomePage extends StatelessWidget { ); } } +} - List spaceBetween(double paddingBetween, List children) { - return [ - for (int index = 0; index < children.length; index++) ...[ - Flexible( - child: children[index], - ), - if (index < children.length - 1) SizedBox(width: paddingBetween), - ], - ]; - } +List spaceBetween(double paddingBetween, List children) { + return [ + for (int index = 0; index < children.length; index++) ...[ + Flexible( + child: children[index], + ), + if (index < children.length - 1) SizedBox(width: paddingBetween), + ], + ]; } class _GalleryHeader extends StatelessWidget { @@ -715,6 +724,150 @@ class _CarouselState extends State<_Carousel> } } +class _DesktopCarousel extends StatefulWidget { + const _DesktopCarousel({Key key, this.children}) : super(key: key); + + final List children; + + @override + __DesktopCarouselState createState() => __DesktopCarouselState(); +} + +class __DesktopCarouselState extends State<_DesktopCarousel> { + static const spaceBetweenCards = 30.0; + static const cardsPerPage = 4; + + PageController _controller; + int _currentPage = 0; + + @override + void initState() { + super.initState(); + if (_controller == null) { + _controller = PageController(initialPage: _currentPage); + } + } + + @override + dispose() { + _controller.dispose(); + super.dispose(); + } + + Widget _builder(int index) { + var paddedChildren = widget.children; + while (paddedChildren.length % cardsPerPage > 0) { + paddedChildren.add(Container()); + } + + final start = index * cardsPerPage; + var carouselCards = paddedChildren.sublist(start, start + cardsPerPage); + carouselCards = spaceBetween(spaceBetweenCards, carouselCards); + carouselCards.insert(0, SizedBox(width: spaceBetweenCards / 2)); + carouselCards.add(SizedBox(width: spaceBetweenCards / 2)); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), // For the shadow. + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: carouselCards, + ), + ); + } + + @override + Widget build(BuildContext context) { + final pageCount = (widget.children.length / cardsPerPage).ceil(); + final showPreviousButton = _currentPage > 0; + final showNextButton = _currentPage < pageCount - 1; + print(_currentPage); + return Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: _horizontalDesktopPadding - spaceBetweenCards / 2, + ), + child: PageView.builder( + onPageChanged: (value) { + setState(() { + _currentPage = value; + }); + }, + controller: _controller, + itemCount: pageCount, + itemBuilder: (context, index) => _builder(index), + ), + ), + if (showPreviousButton) + _DesktopPageButton( + onTap: () { + _controller.previousPage( + duration: Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + }, + ), + if (showNextButton) + _DesktopPageButton( + isEnd: true, + onTap: () { + _controller.nextPage( + duration: Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + }, + ), + ], + ); + } +} + +class _DesktopPageButton extends StatelessWidget { + const _DesktopPageButton({ + Key key, + this.isEnd = false, + this.onTap, + }) : super(key: key); + + final bool isEnd; + final GestureTapCallback onTap; + + @override + Widget build(BuildContext context) { + final buttonSize = 58.0; + final padding = _horizontalDesktopPadding - buttonSize / 2; + return Align( + alignment: isEnd + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart, + child: Container( + width: buttonSize, + height: buttonSize, + margin: EdgeInsetsDirectional.only( + start: isEnd ? 0 : padding, + end: isEnd ? padding : 0, + ), + child: Tooltip( + message: isEnd + ? MaterialLocalizations.of(context).nextPageTooltip + : MaterialLocalizations.of(context).previousPageTooltip, + child: Material( + color: Colors.black.withOpacity(0.5), + shape: CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Icon( + isEnd ? Icons.arrow_forward_ios : Icons.arrow_back_ios, + ), + ), + ), + ), + ), + ); + } +} + class _CarouselCard extends StatelessWidget { const _CarouselCard({ Key key, From 23ec8f6499fd7b7376f6d47409f980b89b2e6a1a Mon Sep 17 00:00:00 2001 From: Rami Abou Ghanem Date: Wed, 5 Feb 2020 14:21:51 -0500 Subject: [PATCH 2/5] Use ListView with custom ScrollPhysics for carousel --- gallery/gallery/lib/pages/home.dart | 144 ++++++++++++++++++---------- 1 file changed, 93 insertions(+), 51 deletions(-) diff --git a/gallery/gallery/lib/pages/home.dart b/gallery/gallery/lib/pages/home.dart index 65158936b16..7f7c8dc4511 100644 --- a/gallery/gallery/lib/pages/home.dart +++ b/gallery/gallery/lib/pages/home.dart @@ -30,12 +30,13 @@ const _horizontalPadding = 32.0; const _carouselItemMargin = 8.0; const _horizontalDesktopPadding = 81.0; const _carouselHeightMin = 200.0 + 2 * _carouselItemMargin; +const _desktopCardsPerPage = 4; -const shrineTitle = 'Shrine'; -const rallyTitle = 'Rally'; -const craneTitle = 'Crane'; -const homeCategoryMaterial = 'MATERIAL'; -const homeCategoryCupertino = 'CUPERTINO'; +const _shrineTitle = 'Shrine'; +const _rallyTitle = 'Rally'; +const _craneTitle = 'Crane'; +const _homeCategoryMaterial = 'MATERIAL'; +const _homeCategoryCupertino = 'CUPERTINO'; class ToggleSplashNotification extends Notification {} @@ -54,7 +55,7 @@ class HomePage extends StatelessWidget { final isDesktop = isDisplayDesktop(context); final carouselCards = [ _CarouselCard( - title: shrineTitle, + title: _shrineTitle, subtitle: GalleryLocalizations.of(context).shrineDescription, asset: 'assets/studies/shrine_card.png', assetDark: 'assets/studies/shrine_card_dark.png', @@ -63,7 +64,7 @@ class HomePage extends StatelessWidget { navigatorKey: NavigatorKeys.shrine, ), _CarouselCard( - title: rallyTitle, + title: _rallyTitle, subtitle: GalleryLocalizations.of(context).rallyDescription, textColor: RallyColors.accountColors[0], asset: 'assets/studies/rally_card.png', @@ -72,7 +73,7 @@ class HomePage extends StatelessWidget { navigatorKey: NavigatorKeys.rally, ), _CarouselCard( - title: craneTitle, + title: _craneTitle, subtitle: GalleryLocalizations.of(context).craneDescription, asset: 'assets/studies/crane_card.png', assetDark: 'assets/studies/crane_card_dark.png', @@ -101,12 +102,12 @@ class HomePage extends StatelessWidget { if (isDesktop) { final desktopCategoryItems = <_DesktopCategoryItem>[ _DesktopCategoryItem( - title: homeCategoryMaterial, + title: _homeCategoryMaterial, imageString: 'assets/icons/material/material.png', demos: materialDemos(context), ), _DesktopCategoryItem( - title: homeCategoryCupertino, + title: _homeCategoryCupertino, imageString: 'assets/icons/cupertino/cupertino.png', demos: cupertinoDemos(context), ), @@ -341,7 +342,7 @@ class _AnimatedHomePageState extends State<_AnimatedHomePage> startDelayFraction: 0.00, controller: _animationController, child: CategoryListItem( - title: homeCategoryMaterial, + title: _homeCategoryMaterial, imageString: 'assets/icons/material/material.png', demos: materialDemos(context), ), @@ -350,7 +351,7 @@ class _AnimatedHomePageState extends State<_AnimatedHomePage> startDelayFraction: 0.05, controller: _animationController, child: CategoryListItem( - title: homeCategoryCupertino, + title: _homeCategoryCupertino, imageString: 'assets/icons/cupertino/cupertino.png', demos: cupertinoDemos(context), ), @@ -730,22 +731,20 @@ class _DesktopCarousel extends StatefulWidget { final List children; @override - __DesktopCarouselState createState() => __DesktopCarouselState(); + _DesktopCarouselState createState() => _DesktopCarouselState(); } -class __DesktopCarouselState extends State<_DesktopCarousel> { - static const spaceBetweenCards = 30.0; - static const cardsPerPage = 4; - - PageController _controller; - int _currentPage = 0; +class _DesktopCarouselState extends State<_DesktopCarousel> { + static const cardPadding = 15.0; + ScrollController _controller; @override void initState() { super.initState(); - if (_controller == null) { - _controller = PageController(initialPage: _currentPage); - } + _controller = ScrollController(); + _controller.addListener(() { + setState(() {}); + }); } @override @@ -755,53 +754,49 @@ class __DesktopCarouselState extends State<_DesktopCarousel> { } Widget _builder(int index) { - var paddedChildren = widget.children; - while (paddedChildren.length % cardsPerPage > 0) { - paddedChildren.add(Container()); - } - - final start = index * cardsPerPage; - var carouselCards = paddedChildren.sublist(start, start + cardsPerPage); - carouselCards = spaceBetween(spaceBetweenCards, carouselCards); - carouselCards.insert(0, SizedBox(width: spaceBetweenCards / 2)); - carouselCards.add(SizedBox(width: spaceBetweenCards / 2)); return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), // For the shadow. - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: carouselCards, + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: cardPadding, ), + child: widget.children[index], ); } @override Widget build(BuildContext context) { - final pageCount = (widget.children.length / cardsPerPage).ceil(); - final showPreviousButton = _currentPage > 0; - final showNextButton = _currentPage < pageCount - 1; - print(_currentPage); + var showPreviousButton = false; + var showNextButton = true; + // Only check this after the _controller has been attached to the ListView. + if (_controller.hasClients) { + showPreviousButton = _controller.offset > 0; + showNextButton = + _controller.offset < _controller.position.maxScrollExtent; + } + final totalWidth = MediaQuery.of(context).size.width - + (_horizontalDesktopPadding - cardPadding) * 2; + final itemExtent = totalWidth / _desktopCardsPerPage; + return Stack( children: [ Padding( padding: const EdgeInsets.symmetric( - horizontal: _horizontalDesktopPadding - spaceBetweenCards / 2, + horizontal: _horizontalDesktopPadding - cardPadding, ), - child: PageView.builder( - onPageChanged: (value) { - setState(() { - _currentPage = value; - }); - }, + child: ListView.builder( + scrollDirection: Axis.horizontal, + physics: SnappingScrollPhysics(), controller: _controller, - itemCount: pageCount, + itemExtent: itemExtent, + itemCount: widget.children.length, itemBuilder: (context, index) => _builder(index), ), ), if (showPreviousButton) _DesktopPageButton( onTap: () { - _controller.previousPage( + _controller.animateTo( + _controller.offset - itemExtent, duration: Duration(milliseconds: 200), curve: Curves.easeInOut, ); @@ -811,7 +806,8 @@ class __DesktopCarouselState extends State<_DesktopCarousel> { _DesktopPageButton( isEnd: true, onTap: () { - _controller.nextPage( + _controller.animateTo( + _controller.offset + itemExtent, duration: Duration(milliseconds: 200), curve: Curves.easeInOut, ); @@ -822,6 +818,52 @@ class __DesktopCarouselState extends State<_DesktopCarousel> { } } +class SnappingScrollPhysics extends ScrollPhysics { + double _getTargetPixels( + ScrollMetrics position, + Tolerance tolerance, + double velocity, + ) { + final itemWidth = position.viewportDimension / _desktopCardsPerPage; + double item = position.pixels / itemWidth; + if (velocity < -tolerance.velocity) { + item -= 0.5; + } else if (velocity > tolerance.velocity) { + item += 0.5; + } + return math.min( + item.roundToDouble() * itemWidth, + position.maxScrollExtent, + ); + } + + @override + Simulation createBallisticSimulation( + ScrollMetrics position, + double velocity, + ) { + if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) || + (velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) { + return super.createBallisticSimulation(position, velocity); + } + final Tolerance tolerance = this.tolerance; + final double target = _getTargetPixels(position, tolerance, velocity); + if (target != position.pixels) { + return ScrollSpringSimulation( + spring, + position.pixels, + target, + velocity, + tolerance: tolerance, + ); + } + return null; + } + + @override + bool get allowImplicitScrolling => false; +} + class _DesktopPageButton extends StatelessWidget { const _DesktopPageButton({ Key key, From 7fea464b4fff1d626bd9b558ff7dd209ebe94770 Mon Sep 17 00:00:00 2001 From: Rami Abou Ghanem Date: Wed, 5 Feb 2020 14:49:30 -0500 Subject: [PATCH 3/5] Bring back physics constructor --- gallery/lib/pages/home.dart | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/gallery/lib/pages/home.dart b/gallery/lib/pages/home.dart index 7f7c8dc4511..d43435a9129 100644 --- a/gallery/lib/pages/home.dart +++ b/gallery/lib/pages/home.dart @@ -775,7 +775,7 @@ class _DesktopCarouselState extends State<_DesktopCarousel> { } final totalWidth = MediaQuery.of(context).size.width - (_horizontalDesktopPadding - cardPadding) * 2; - final itemExtent = totalWidth / _desktopCardsPerPage; + final itemWidth = totalWidth / _desktopCardsPerPage; return Stack( children: [ @@ -785,9 +785,9 @@ class _DesktopCarouselState extends State<_DesktopCarousel> { ), child: ListView.builder( scrollDirection: Axis.horizontal, - physics: SnappingScrollPhysics(), + physics: _SnappingScrollPhysics(), controller: _controller, - itemExtent: itemExtent, + itemExtent: itemWidth, itemCount: widget.children.length, itemBuilder: (context, index) => _builder(index), ), @@ -796,7 +796,7 @@ class _DesktopCarouselState extends State<_DesktopCarousel> { _DesktopPageButton( onTap: () { _controller.animateTo( - _controller.offset - itemExtent, + _controller.offset - itemWidth, duration: Duration(milliseconds: 200), curve: Curves.easeInOut, ); @@ -807,7 +807,7 @@ class _DesktopCarouselState extends State<_DesktopCarousel> { isEnd: true, onTap: () { _controller.animateTo( - _controller.offset + itemExtent, + _controller.offset + itemWidth, duration: Duration(milliseconds: 200), curve: Curves.easeInOut, ); @@ -818,7 +818,15 @@ class _DesktopCarouselState extends State<_DesktopCarousel> { } } -class SnappingScrollPhysics extends ScrollPhysics { +/// Scrolling physics that snaps to the new item in the [_DesktopCarousel]. +class _SnappingScrollPhysics extends ScrollPhysics { + const _SnappingScrollPhysics({ScrollPhysics parent}) : super(parent: parent); + + @override + _SnappingScrollPhysics applyTo(ScrollPhysics ancestor) { + return _SnappingScrollPhysics(parent: buildParent(ancestor)); + } + double _getTargetPixels( ScrollMetrics position, Tolerance tolerance, From b9cf0ef1b29c30d339e83e4232123df75644856b Mon Sep 17 00:00:00 2001 From: Rami Abou Ghanem Date: Wed, 5 Feb 2020 14:55:06 -0500 Subject: [PATCH 4/5] Move function back inside class --- gallery/lib/pages/home.dart | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/gallery/lib/pages/home.dart b/gallery/lib/pages/home.dart index d43435a9129..1ddc773bea3 100644 --- a/gallery/lib/pages/home.dart +++ b/gallery/lib/pages/home.dart @@ -208,17 +208,17 @@ class HomePage extends StatelessWidget { ); } } -} -List spaceBetween(double paddingBetween, List children) { - return [ - for (int index = 0; index < children.length; index++) ...[ - Flexible( - child: children[index], - ), - if (index < children.length - 1) SizedBox(width: paddingBetween), - ], - ]; + List spaceBetween(double paddingBetween, List children) { + return [ + for (int index = 0; index < children.length; index++) ...[ + Flexible( + child: children[index], + ), + if (index < children.length - 1) SizedBox(width: paddingBetween), + ], + ]; + } } class _GalleryHeader extends StatelessWidget { From 60b2309c30efe99e2430735df745311525c78fd1 Mon Sep 17 00:00:00 2001 From: Rami Abou Ghanem Date: Wed, 5 Feb 2020 15:12:42 -0500 Subject: [PATCH 5/5] Add documentation explaining the use of listview over pageview --- gallery/lib/pages/home.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gallery/lib/pages/home.dart b/gallery/lib/pages/home.dart index 1ddc773bea3..8780dba01a4 100644 --- a/gallery/lib/pages/home.dart +++ b/gallery/lib/pages/home.dart @@ -725,6 +725,11 @@ class _CarouselState extends State<_Carousel> } } +/// This creates a horizontally scrolling [ListView] of items. +/// +/// This class uses a [ListView] with a custom [ScrollPhysics] to enable +/// snapping behavior. A [PageView] was considered but does not allow for +/// multiple pages visible without centering the first page. class _DesktopCarousel extends StatefulWidget { const _DesktopCarousel({Key key, this.children}) : super(key: key);