blob: e96f7fcfc67bba1e5e94a8efebda6a75cd9a5f8c [file] [log] [blame]
// Copyright 2019 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/gallery_localizations.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:gallery/data/gallery_options.dart';
import 'package:gallery/layout/adaptive.dart';
import 'package:gallery/layout/image_placeholder.dart';
import 'package:gallery/studies/crane/backlayer.dart';
import 'package:gallery/studies/crane/border_tab_indicator.dart';
import 'package:gallery/studies/crane/colors.dart';
import 'package:gallery/studies/crane/header_form.dart';
import 'package:gallery/studies/crane/item_cards.dart';
import 'package:gallery/studies/crane/model/data.dart';
import 'package:gallery/studies/crane/model/destination.dart';
class _FrontLayer extends StatefulWidget {
const _FrontLayer({
required this.title,
required this.index,
required this.mobileTopOffset,
required this.restorationId,
});
final String title;
final int index;
final double mobileTopOffset;
final String restorationId;
@override
_FrontLayerState createState() => _FrontLayerState();
}
class _FrontLayerState extends State<_FrontLayer> {
List<Destination>? destinations;
static const frontLayerBorderRadius = 16.0;
static const bottomPadding = EdgeInsets.only(bottom: 120);
@override
void didChangeDependencies() {
super.didChangeDependencies();
// We use didChangeDependencies because the initialization involves an
// InheritedWidget (for localization). However, we don't need to get
// destinations again when, say, resizing the window.
if (destinations == null) {
if (widget.index == 0) destinations = getFlyDestinations(context);
if (widget.index == 1) destinations = getSleepDestinations(context);
if (widget.index == 2) destinations = getEatDestinations(context);
}
}
Widget _header() {
return Align(
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsets.only(
top: 20,
bottom: 22,
),
child: SelectableText(
widget.title,
style: Theme.of(context).textTheme.titleSmall,
),
),
);
}
@override
Widget build(BuildContext context) {
final isDesktop = isDisplayDesktop(context);
final isSmallDesktop = isDisplaySmallDesktop(context);
final crossAxisCount = isDesktop ? 4 : 1;
return FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: Padding(
padding: isDesktop
? EdgeInsets.zero
: EdgeInsets.only(top: widget.mobileTopOffset),
child: PhysicalShape(
elevation: 16,
color: cranePrimaryWhite,
clipper: const ShapeBorderClipper(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(frontLayerBorderRadius),
topRight: Radius.circular(frontLayerBorderRadius),
),
),
),
child: Padding(
padding: isDesktop
? EdgeInsets.symmetric(
horizontal:
isSmallDesktop ? appPaddingSmall : appPaddingLarge)
.add(bottomPadding)
: const EdgeInsets.symmetric(horizontal: 20).add(bottomPadding),
child: Column(
children: [
_header(),
Expanded(
child: MasonryGridView.count(
key: ValueKey('CraneListView-${widget.index}'),
restorationId: widget.restorationId,
crossAxisCount: crossAxisCount,
crossAxisSpacing: 16.0,
itemBuilder: (context, index) =>
DestinationCard(destination: destinations![index]),
itemCount: destinations!.length,
),
),
],
),
),
),
),
);
}
}
/// Builds a Backdrop.
///
/// A Backdrop widget has two layers, front and back. The front layer is shown
/// by default, and slides down to show the back layer, from which a user
/// can make a selection. The user can also configure the titles for when the
/// front or back layer is showing.
class Backdrop extends StatefulWidget {
final Widget frontLayer;
final List<BackLayerItem> backLayerItems;
final Widget frontTitle;
final Widget backTitle;
const Backdrop({
super.key,
required this.frontLayer,
required this.backLayerItems,
required this.frontTitle,
required this.backTitle,
});
@override
State<Backdrop> createState() => _BackdropState();
}
class _BackdropState extends State<Backdrop>
with TickerProviderStateMixin, RestorationMixin {
final RestorableInt tabIndex = RestorableInt(0);
late TabController _tabController;
late Animation<Offset> _flyLayerHorizontalOffset;
late Animation<Offset> _sleepLayerHorizontalOffset;
late Animation<Offset> _eatLayerHorizontalOffset;
// How much the 'sleep' front layer is vertically offset relative to other
// front layers, in pixels, with the mobile layout.
static const _sleepLayerTopOffset = 60.0;
@override
String get restorationId => 'tab_non_scrollable_demo';
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(tabIndex, 'tab_index');
_tabController.index = tabIndex.value;
}
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
// When the tab controller's value is updated, make sure to update the
// tab index value, which is state restorable.
setState(() {
tabIndex.value = _tabController.index;
});
});
// Offsets to create a horizontal gap between front layers.
final tabControllerAnimation = _tabController.animation!;
_flyLayerHorizontalOffset = tabControllerAnimation.drive(
Tween<Offset>(begin: const Offset(0, 0), end: const Offset(-0.05, 0)));
_sleepLayerHorizontalOffset = tabControllerAnimation.drive(
Tween<Offset>(begin: const Offset(0.05, 0), end: const Offset(0, 0)));
_eatLayerHorizontalOffset = tabControllerAnimation.drive(Tween<Offset>(
begin: const Offset(0.10, 0), end: const Offset(0.05, 0)));
}
@override
void dispose() {
_tabController.dispose();
tabIndex.dispose();
super.dispose();
}
void _handleTabs(int tabIndex) {
_tabController.animateTo(tabIndex,
duration: const Duration(milliseconds: 300));
}
@override
Widget build(BuildContext context) {
final isDesktop = isDisplayDesktop(context);
final textScaleFactor = GalleryOptions.of(context).textScaleFactor(context);
final localizations = GalleryLocalizations.of(context)!;
return Material(
color: cranePurple800,
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: Scaffold(
backgroundColor: cranePurple800,
appBar: AppBar(
automaticallyImplyLeading: false,
systemOverlayStyle: SystemUiOverlayStyle.light,
elevation: 0,
titleSpacing: 0,
flexibleSpace: CraneAppBar(
tabController: _tabController,
tabHandler: _handleTabs,
),
),
body: Stack(
children: [
BackLayer(
tabController: _tabController,
backLayerItems: widget.backLayerItems,
),
Container(
margin: EdgeInsets.only(
top: isDesktop
? (isDisplaySmallDesktop(context)
? textFieldHeight * 3
: textFieldHeight * 2) +
20 * textScaleFactor / 2
: 175 + 140 * textScaleFactor / 2,
),
// To display the middle front layer higher than the others,
// we allow the TabBarView to overflow by an offset
// (doubled because it technically overflows top & bottom).
// The other front layers are top padded by this offset.
child: LayoutBuilder(builder: (context, constraints) {
return OverflowBox(
maxHeight:
constraints.maxHeight + _sleepLayerTopOffset * 2,
child: TabBarView(
physics: isDesktop
? const NeverScrollableScrollPhysics()
: null, // use default TabBarView physics
controller: _tabController,
children: [
SlideTransition(
position: _flyLayerHorizontalOffset,
child: _FrontLayer(
title: localizations.craneFlySubhead,
index: 0,
mobileTopOffset: _sleepLayerTopOffset,
restorationId: 'fly-subhead',
),
),
SlideTransition(
position: _sleepLayerHorizontalOffset,
child: _FrontLayer(
title: localizations.craneSleepSubhead,
index: 1,
mobileTopOffset: 0,
restorationId: 'sleep-subhead',
),
),
SlideTransition(
position: _eatLayerHorizontalOffset,
child: _FrontLayer(
title: localizations.craneEatSubhead,
index: 2,
mobileTopOffset: _sleepLayerTopOffset,
restorationId: 'eat-subhead',
),
),
],
),
);
}),
),
],
),
),
),
),
);
}
}
class CraneAppBar extends StatefulWidget {
final Function(int)? tabHandler;
final TabController tabController;
const CraneAppBar({
super.key,
this.tabHandler,
required this.tabController,
});
@override
State<CraneAppBar> createState() => _CraneAppBarState();
}
class _CraneAppBarState extends State<CraneAppBar> {
@override
Widget build(BuildContext context) {
final isDesktop = isDisplayDesktop(context);
final isSmallDesktop = isDisplaySmallDesktop(context);
final textScaleFactor = GalleryOptions.of(context).textScaleFactor(context);
final localizations = GalleryLocalizations.of(context)!;
return SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal:
isDesktop && !isSmallDesktop ? appPaddingLarge : appPaddingSmall,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const ExcludeSemantics(
child: FadeInImagePlaceholder(
image: AssetImage(
'crane/logo/logo.png',
package: 'flutter_gallery_assets',
),
placeholder: SizedBox(
width: 40,
height: 60,
),
width: 40,
height: 60,
),
),
Expanded(
child: Padding(
padding: const EdgeInsetsDirectional.only(start: 24),
child: Theme(
data: Theme.of(context).copyWith(
splashColor: Colors.transparent,
),
child: TabBar(
indicator: BorderTabIndicator(
indicatorHeight: isDesktop ? 28 : 32,
textScaleFactor: textScaleFactor,
),
controller: widget.tabController,
labelPadding: isDesktop
? const EdgeInsets.symmetric(horizontal: 32)
: EdgeInsets.zero,
isScrollable: isDesktop,
// left-align tabs on desktop
labelStyle: Theme.of(context).textTheme.labelLarge,
labelColor: cranePrimaryWhite,
unselectedLabelColor: cranePrimaryWhite.withOpacity(.6),
onTap: (index) => widget.tabController.animateTo(
index,
duration: const Duration(milliseconds: 300),
),
tabs: [
Tab(text: localizations.craneFly),
Tab(text: localizations.craneSleep),
Tab(text: localizations.craneEat),
],
),
),
),
),
],
),
),
);
}
}