Skip to content

Commit dc66d5f

Browse files
authored
[Gallery] Implement Desktop study carousel (#315)
1 parent cee267c commit dc66d5f

File tree

1 file changed

+229
-21
lines changed

1 file changed

+229
-21
lines changed

gallery/lib/pages/home.dart

Lines changed: 229 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,13 @@ const _horizontalPadding = 32.0;
3030
const _carouselItemMargin = 8.0;
3131
const _horizontalDesktopPadding = 81.0;
3232
const _carouselHeightMin = 200.0 + 2 * _carouselItemMargin;
33+
const _desktopCardsPerPage = 4;
3334

34-
const shrineTitle = 'Shrine';
35-
const rallyTitle = 'Rally';
36-
const craneTitle = 'Crane';
37-
const homeCategoryMaterial = 'MATERIAL';
38-
const homeCategoryCupertino = 'CUPERTINO';
35+
const _shrineTitle = 'Shrine';
36+
const _rallyTitle = 'Rally';
37+
const _craneTitle = 'Crane';
38+
const _homeCategoryMaterial = 'MATERIAL';
39+
const _homeCategoryCupertino = 'CUPERTINO';
3940

4041
class ToggleSplashNotification extends Notification {}
4142

@@ -52,9 +53,9 @@ class HomePage extends StatelessWidget {
5253
Widget build(BuildContext context) {
5354
var carouselHeight = _carouselHeight(.7, context);
5455
final isDesktop = isDisplayDesktop(context);
55-
final carouselCards = <_CarouselCard>[
56+
final carouselCards = <Widget>[
5657
_CarouselCard(
57-
title: shrineTitle,
58+
title: _shrineTitle,
5859
subtitle: GalleryLocalizations.of(context).shrineDescription,
5960
asset: 'assets/studies/shrine_card.png',
6061
assetDark: 'assets/studies/shrine_card_dark.png',
@@ -63,7 +64,7 @@ class HomePage extends StatelessWidget {
6364
navigatorKey: NavigatorKeys.shrine,
6465
),
6566
_CarouselCard(
66-
title: rallyTitle,
67+
title: _rallyTitle,
6768
subtitle: GalleryLocalizations.of(context).rallyDescription,
6869
textColor: RallyColors.accountColors[0],
6970
asset: 'assets/studies/rally_card.png',
@@ -72,7 +73,7 @@ class HomePage extends StatelessWidget {
7273
navigatorKey: NavigatorKeys.rally,
7374
),
7475
_CarouselCard(
75-
title: craneTitle,
76+
title: _craneTitle,
7677
subtitle: GalleryLocalizations.of(context).craneDescription,
7778
asset: 'assets/studies/crane_card.png',
7879
assetDark: 'assets/studies/crane_card_dark.png',
@@ -101,12 +102,12 @@ class HomePage extends StatelessWidget {
101102
if (isDesktop) {
102103
final desktopCategoryItems = <_DesktopCategoryItem>[
103104
_DesktopCategoryItem(
104-
title: homeCategoryMaterial,
105+
title: _homeCategoryMaterial,
105106
imageString: 'assets/icons/material/material.png',
106107
demos: materialDemos(context),
107108
),
108109
_DesktopCategoryItem(
109-
title: homeCategoryCupertino,
110+
title: _homeCategoryCupertino,
110111
imageString: 'assets/icons/cupertino/cupertino.png',
111112
demos: cupertinoDemos(context),
112113
),
@@ -120,12 +121,15 @@ class HomePage extends StatelessWidget {
120121
return Scaffold(
121122
body: ListView(
122123
padding: EdgeInsetsDirectional.only(
123-
start: _horizontalDesktopPadding,
124124
top: isDesktop ? firstHeaderDesktopTopPadding : 21,
125-
end: _horizontalDesktopPadding,
126125
),
127126
children: [
128-
ExcludeSemantics(child: _GalleryHeader()),
127+
Padding(
128+
padding: const EdgeInsets.symmetric(
129+
horizontal: _horizontalDesktopPadding,
130+
),
131+
child: ExcludeSemantics(child: _GalleryHeader()),
132+
),
129133

130134
/// TODO: When Focus widget becomes better remove dummy Focus
131135
/// variable.
@@ -143,15 +147,19 @@ class HomePage extends StatelessWidget {
143147
),
144148
Container(
145149
height: carouselHeight,
146-
child: Row(
147-
mainAxisSize: MainAxisSize.max,
148-
mainAxisAlignment: MainAxisAlignment.spaceBetween,
149-
children: spaceBetween(30, carouselCards),
150+
child: _DesktopCarousel(children: carouselCards),
151+
),
152+
Padding(
153+
padding: const EdgeInsets.symmetric(
154+
horizontal: _horizontalDesktopPadding,
150155
),
156+
child: _CategoriesHeader(),
151157
),
152-
_CategoriesHeader(),
153158
Container(
154159
height: 585,
160+
padding: const EdgeInsets.symmetric(
161+
horizontal: _horizontalDesktopPadding,
162+
),
155163
child: Row(
156164
mainAxisSize: MainAxisSize.max,
157165
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -160,7 +168,9 @@ class HomePage extends StatelessWidget {
160168
),
161169
Padding(
162170
padding: const EdgeInsetsDirectional.only(
171+
start: _horizontalDesktopPadding,
163172
bottom: 81,
173+
end: _horizontalDesktopPadding,
164174
top: 109,
165175
),
166176
child: Row(
@@ -332,7 +342,7 @@ class _AnimatedHomePageState extends State<_AnimatedHomePage>
332342
startDelayFraction: 0.00,
333343
controller: _animationController,
334344
child: CategoryListItem(
335-
title: homeCategoryMaterial,
345+
title: _homeCategoryMaterial,
336346
imageString: 'assets/icons/material/material.png',
337347
demos: materialDemos(context),
338348
),
@@ -341,7 +351,7 @@ class _AnimatedHomePageState extends State<_AnimatedHomePage>
341351
startDelayFraction: 0.05,
342352
controller: _animationController,
343353
child: CategoryListItem(
344-
title: homeCategoryCupertino,
354+
title: _homeCategoryCupertino,
345355
imageString: 'assets/icons/cupertino/cupertino.png',
346356
demos: cupertinoDemos(context),
347357
),
@@ -715,6 +725,204 @@ class _CarouselState extends State<_Carousel>
715725
}
716726
}
717727

728+
/// This creates a horizontally scrolling [ListView] of items.
729+
///
730+
/// This class uses a [ListView] with a custom [ScrollPhysics] to enable
731+
/// snapping behavior. A [PageView] was considered but does not allow for
732+
/// multiple pages visible without centering the first page.
733+
class _DesktopCarousel extends StatefulWidget {
734+
const _DesktopCarousel({Key key, this.children}) : super(key: key);
735+
736+
final List<Widget> children;
737+
738+
@override
739+
_DesktopCarouselState createState() => _DesktopCarouselState();
740+
}
741+
742+
class _DesktopCarouselState extends State<_DesktopCarousel> {
743+
static const cardPadding = 15.0;
744+
ScrollController _controller;
745+
746+
@override
747+
void initState() {
748+
super.initState();
749+
_controller = ScrollController();
750+
_controller.addListener(() {
751+
setState(() {});
752+
});
753+
}
754+
755+
@override
756+
dispose() {
757+
_controller.dispose();
758+
super.dispose();
759+
}
760+
761+
Widget _builder(int index) {
762+
return Padding(
763+
padding: const EdgeInsets.symmetric(
764+
vertical: 8,
765+
horizontal: cardPadding,
766+
),
767+
child: widget.children[index],
768+
);
769+
}
770+
771+
@override
772+
Widget build(BuildContext context) {
773+
var showPreviousButton = false;
774+
var showNextButton = true;
775+
// Only check this after the _controller has been attached to the ListView.
776+
if (_controller.hasClients) {
777+
showPreviousButton = _controller.offset > 0;
778+
showNextButton =
779+
_controller.offset < _controller.position.maxScrollExtent;
780+
}
781+
final totalWidth = MediaQuery.of(context).size.width -
782+
(_horizontalDesktopPadding - cardPadding) * 2;
783+
final itemWidth = totalWidth / _desktopCardsPerPage;
784+
785+
return Stack(
786+
children: [
787+
Padding(
788+
padding: const EdgeInsets.symmetric(
789+
horizontal: _horizontalDesktopPadding - cardPadding,
790+
),
791+
child: ListView.builder(
792+
scrollDirection: Axis.horizontal,
793+
physics: _SnappingScrollPhysics(),
794+
controller: _controller,
795+
itemExtent: itemWidth,
796+
itemCount: widget.children.length,
797+
itemBuilder: (context, index) => _builder(index),
798+
),
799+
),
800+
if (showPreviousButton)
801+
_DesktopPageButton(
802+
onTap: () {
803+
_controller.animateTo(
804+
_controller.offset - itemWidth,
805+
duration: Duration(milliseconds: 200),
806+
curve: Curves.easeInOut,
807+
);
808+
},
809+
),
810+
if (showNextButton)
811+
_DesktopPageButton(
812+
isEnd: true,
813+
onTap: () {
814+
_controller.animateTo(
815+
_controller.offset + itemWidth,
816+
duration: Duration(milliseconds: 200),
817+
curve: Curves.easeInOut,
818+
);
819+
},
820+
),
821+
],
822+
);
823+
}
824+
}
825+
826+
/// Scrolling physics that snaps to the new item in the [_DesktopCarousel].
827+
class _SnappingScrollPhysics extends ScrollPhysics {
828+
const _SnappingScrollPhysics({ScrollPhysics parent}) : super(parent: parent);
829+
830+
@override
831+
_SnappingScrollPhysics applyTo(ScrollPhysics ancestor) {
832+
return _SnappingScrollPhysics(parent: buildParent(ancestor));
833+
}
834+
835+
double _getTargetPixels(
836+
ScrollMetrics position,
837+
Tolerance tolerance,
838+
double velocity,
839+
) {
840+
final itemWidth = position.viewportDimension / _desktopCardsPerPage;
841+
double item = position.pixels / itemWidth;
842+
if (velocity < -tolerance.velocity) {
843+
item -= 0.5;
844+
} else if (velocity > tolerance.velocity) {
845+
item += 0.5;
846+
}
847+
return math.min(
848+
item.roundToDouble() * itemWidth,
849+
position.maxScrollExtent,
850+
);
851+
}
852+
853+
@override
854+
Simulation createBallisticSimulation(
855+
ScrollMetrics position,
856+
double velocity,
857+
) {
858+
if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) ||
859+
(velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) {
860+
return super.createBallisticSimulation(position, velocity);
861+
}
862+
final Tolerance tolerance = this.tolerance;
863+
final double target = _getTargetPixels(position, tolerance, velocity);
864+
if (target != position.pixels) {
865+
return ScrollSpringSimulation(
866+
spring,
867+
position.pixels,
868+
target,
869+
velocity,
870+
tolerance: tolerance,
871+
);
872+
}
873+
return null;
874+
}
875+
876+
@override
877+
bool get allowImplicitScrolling => false;
878+
}
879+
880+
class _DesktopPageButton extends StatelessWidget {
881+
const _DesktopPageButton({
882+
Key key,
883+
this.isEnd = false,
884+
this.onTap,
885+
}) : super(key: key);
886+
887+
final bool isEnd;
888+
final GestureTapCallback onTap;
889+
890+
@override
891+
Widget build(BuildContext context) {
892+
final buttonSize = 58.0;
893+
final padding = _horizontalDesktopPadding - buttonSize / 2;
894+
return Align(
895+
alignment: isEnd
896+
? AlignmentDirectional.centerEnd
897+
: AlignmentDirectional.centerStart,
898+
child: Container(
899+
width: buttonSize,
900+
height: buttonSize,
901+
margin: EdgeInsetsDirectional.only(
902+
start: isEnd ? 0 : padding,
903+
end: isEnd ? padding : 0,
904+
),
905+
child: Tooltip(
906+
message: isEnd
907+
? MaterialLocalizations.of(context).nextPageTooltip
908+
: MaterialLocalizations.of(context).previousPageTooltip,
909+
child: Material(
910+
color: Colors.black.withOpacity(0.5),
911+
shape: CircleBorder(),
912+
clipBehavior: Clip.antiAlias,
913+
child: InkWell(
914+
onTap: onTap,
915+
child: Icon(
916+
isEnd ? Icons.arrow_forward_ios : Icons.arrow_back_ios,
917+
),
918+
),
919+
),
920+
),
921+
),
922+
);
923+
}
924+
}
925+
718926
class _CarouselCard extends StatelessWidget {
719927
const _CarouselCard({
720928
Key key,

0 commit comments

Comments
 (0)