blob: 8a7e37e8fa0196d5d16ed28e41fd2888aabdd9e7 [file] [log] [blame]
// Copyright 2013 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.
import 'package:flutter/material.dart';
import 'adaptive_layout.dart';
import 'breakpoints.dart';
import 'slot_layout.dart';
/// Gutter value between different parts of the body slot depending on
/// material 3 design spec.
const double kMaterialGutterValue = 8;
/// Margin value of the compact breakpoint layout according to the material
/// design 3 spec.
const double kMaterialCompactMinMargin = 8;
/// Margin value of the medium breakpoint layout according to the material
/// design 3 spec.
const double kMaterialMediumMinMargin = 12;
//// Margin value of the expanded breakpoint layout according to the material
/// design 3 spec.
const double kMaterialExpandedMinMargin = 32;
/// Implements the basic visual layout structure for
/// [Material Design 3](https://m3.material.io/foundations/adaptive-design/overview)
/// that adapts to a variety of screens.
///
/// !["Example of a display made with AdaptiveScaffold"](../../example/demo_files/adaptiveScaffold.gif)
///
/// [AdaptiveScaffold] provides a preset of layout, including positions and
/// animations, by handling macro changes in navigational elements and bodies
/// based on the current features of the screen, namely screen width and platform.
/// For example, the navigational elements would be a [BottomNavigationBar] on a
/// small mobile device or a [Drawer] on a small desktop device and a
/// [NavigationRail] on larger devices. When the app's size changes, for example
/// because its window is resized, the corresponding layout transition is animated.
/// The layout and navigation changes are dictated by "breakpoints" which can be
/// customized or overridden.
///
/// Also provides a variety of helper methods for navigational elements,
/// animations, and more.
///
/// [AdaptiveScaffold] is based on [AdaptiveLayout] but is easier to use at the
/// cost of being less customizable. Apps that would like more refined layout
/// and/or animation should use [AdaptiveLayout].
///
/// ```dart
/// AdaptiveScaffold(
/// destinations: const [
/// NavigationDestination(icon: Icon(Icons.inbox), label: 'Inbox'),
/// NavigationDestination(icon: Icon(Icons.article), label: 'Articles'),
/// NavigationDestination(icon: Icon(Icons.chat), label: 'Chat'),
/// NavigationDestination(icon: Icon(Icons.video_call), label: 'Video'),
/// ],
/// smallBody: (_) => ListView.builder(
/// itemCount: children.length,
/// itemBuilder: (_, idx) => children[idx]
/// ),
/// body: (_) => GridView.count(crossAxisCount: 2, children: children),
/// ),
/// ```
///
/// See also:
///
/// * [AdaptiveLayout], which is what this widget is built upon internally and
/// acts as a more customizable alternative.
/// * [SlotLayout], which handles switching and animations between elements
/// based on [Breakpoint]s.
/// * [SlotLayout.from], which holds information regarding Widgets and the
/// desired way to animate between switches. Often used within [SlotLayout].
/// * [Design Doc](https://flutter.dev/go/adaptive-layout-foldables).
/// * [Material Design 3 Specifications] (https://m3.material.io/foundations/adaptive-design/overview).
class AdaptiveScaffold extends StatefulWidget {
/// Returns a const [AdaptiveScaffold] by passing information down to an
/// [AdaptiveLayout].
const AdaptiveScaffold({
super.key,
required this.destinations,
this.selectedIndex = 0,
this.leadingUnextendedNavRail,
this.leadingExtendedNavRail,
this.trailingNavRail,
this.smallBody,
this.body,
this.largeBody,
this.smallSecondaryBody,
this.secondaryBody,
this.largeSecondaryBody,
this.bodyRatio,
this.smallBreakpoint = Breakpoints.small,
this.mediumBreakpoint = Breakpoints.medium,
this.largeBreakpoint = Breakpoints.large,
this.drawerBreakpoint = Breakpoints.smallDesktop,
this.internalAnimations = true,
this.bodyOrientation = Axis.horizontal,
this.onSelectedIndexChange,
this.useDrawer = true,
this.appBar,
this.navigationRailWidth = 72,
this.extendedNavigationRailWidth = 192,
});
/// The destinations to be used in navigation items. These are converted to
/// [NavigationRailDestination]s and [BottomNavigationBarItem]s and inserted
/// into the appropriate places. If passing destinations, you must also pass a
/// selected index to be used by the [NavigationRail].
final List<NavigationDestination> destinations;
/// The index to be used by the [NavigationRail].
final int? selectedIndex;
/// Option to display a leading widget at the top of the navigation rail
/// at the middle breakpoint.
final Widget? leadingUnextendedNavRail;
/// Option to display a leading widget at the top of the navigation rail
/// at the largest breakpoint.
final Widget? leadingExtendedNavRail;
/// Option to display a trailing widget below the destinations of the
/// navigation rail at the largest breakpoint.
final Widget? trailingNavRail;
/// Widget to be displayed in the body slot at the smallest breakpoint.
///
/// If nothing is entered for this property, then the default [body] is
/// displayed in the slot. If null is entered for this slot, the slot stays
/// empty.
final WidgetBuilder? smallBody;
/// Widget to be displayed in the body slot at the middle breakpoint.
///
/// The default displayed body.
final WidgetBuilder? body;
/// Widget to be displayed in the body slot at the largest breakpoint.
///
/// If nothing is entered for this property, then the default [body] is
/// displayed in the slot. If null is entered for this slot, the slot stays
/// empty.
final WidgetBuilder? largeBody;
/// Widget to be displayed in the secondaryBody slot at the smallest
/// breakpoint.
///
/// If nothing is entered for this property, then the default [secondaryBody]
/// is displayed in the slot. If null is entered for this slot, the slot stays
/// empty.
final WidgetBuilder? smallSecondaryBody;
/// Widget to be displayed in the secondaryBody slot at the middle breakpoint.
///
/// The default displayed secondaryBody.
final WidgetBuilder? secondaryBody;
/// Widget to be displayed in the secondaryBody slot at the largest
/// breakpoint.
///
/// If nothing is entered for this property, then the default [secondaryBody]
/// is displayed in the slot. If null is entered for this slot, the slot stays
/// empty.
final WidgetBuilder? largeSecondaryBody;
/// Defines the fractional ratio of body to the secondaryBody.
///
/// For example 0.3 would mean body takes up 30% of the available space and
/// secondaryBody takes up the rest.
///
/// If this value is null, the ratio is defined so that the split axis is in
/// the center of the screen.
final double? bodyRatio;
/// The breakpoint defined for the small size, associated with mobile-like
/// features.
///
/// Defaults to [Breakpoints.small].
final Breakpoint smallBreakpoint;
/// The breakpoint defined for the medium size, associated with tablet-like
/// features.
///
/// Defaults to [Breakpoints.mediumBreakpoint].
final Breakpoint mediumBreakpoint;
/// The breakpoint defined for the large size, associated with desktop-like
/// features.
///
/// Defaults to [Breakpoints.largeBreakpoint].
final Breakpoint largeBreakpoint;
/// Whether or not the developer wants the smooth entering slide transition on
/// secondaryBody.
///
/// Defaults to true.
final bool internalAnimations;
/// The orientation of the body and secondaryBody. Either horizontal (side by
/// side) or vertical (top to bottom).
///
/// Defaults to Axis.horizontal.
final Axis bodyOrientation;
/// Whether to use a [Drawer] over a [BottomNavigationBar] when not on mobile
/// and Breakpoint is small.
///
/// Defaults to true.
final bool useDrawer;
/// Option to override the drawerBreakpoint for the usage of [Drawer] over the
/// usual [BottomNavigationBar].
///
/// Defaults to [Breakpoints.smallDesktop].
final Breakpoint drawerBreakpoint;
/// Option to override the default [AppBar] when using drawer in desktop
/// small.
final PreferredSizeWidget? appBar;
/// Callback function for when the index of a [NavigationRail] changes.
final Function(int)? onSelectedIndexChange;
/// The width used for the internal [NavigationRail] at the medium [Breakpoint].
final double navigationRailWidth;
/// The width used for the internal extended [NavigationRail] at the large
/// [Breakpoint].
final double extendedNavigationRailWidth;
/// Callback function for when the index of a [NavigationRail] changes.
static WidgetBuilder emptyBuilder = (_) => const SizedBox();
/// Public helper method to be used for creating a [NavigationRailDestination] from
/// a [NavigationDestination].
static NavigationRailDestination toRailDestination(
NavigationDestination destination) {
return NavigationRailDestination(
label: Text(destination.label),
icon: destination.icon,
);
}
/// Creates a Material 3 Design Spec abiding [NavigationRail] from a
/// list of [NavigationDestination]s.
///
/// Takes in a [selectedIndex] property for the current selected item in
/// the [NavigationRail] and [extended] for whether the [NavigationRail]
/// is extended or not.
static Builder standardNavigationRail({
required List<NavigationRailDestination> destinations,
double width = 72,
int? selectedIndex,
bool extended = false,
Color backgroundColor = Colors.transparent,
EdgeInsetsGeometry padding = const EdgeInsets.all(8.0),
Widget? leading,
Widget? trailing,
Function(int)? onDestinationSelected,
IconThemeData selectedIconTheme = const IconThemeData(color: Colors.black),
IconThemeData unselectedIconTheme =
const IconThemeData(color: Colors.black),
TextStyle selectedLabelTextStyle = const TextStyle(color: Colors.black),
NavigationRailLabelType labelType = NavigationRailLabelType.none,
}) {
if (extended && width == 72) {
width = 192;
}
return Builder(builder: (BuildContext context) {
return Padding(
padding: padding,
child: SizedBox(
width: width,
height: MediaQuery.of(context).size.height,
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(
child: NavigationRail(
labelType: labelType,
leading: leading,
trailing: trailing,
onDestinationSelected: onDestinationSelected,
backgroundColor: backgroundColor,
extended: extended,
selectedIndex: selectedIndex,
selectedIconTheme: selectedIconTheme,
unselectedIconTheme: unselectedIconTheme,
selectedLabelTextStyle: selectedLabelTextStyle,
destinations: destinations,
),
),
));
},
),
),
);
});
}
/// Public helper method to be used for creating a [BottomNavigationBar] from
/// a list of [NavigationDestination]s.
static Builder standardBottomNavigationBar({
required List<NavigationDestination> destinations,
int? currentIndex,
double iconSize = 24,
ValueChanged<int>? onDestinationSelected,
}) {
currentIndex ??= 0;
return Builder(
builder: (_) {
return BottomNavigationBar(
currentIndex: currentIndex ?? 0,
iconSize: iconSize,
items: destinations
.map((NavigationDestination e) => _toBottomNavItem(e))
.toList(),
onTap: onDestinationSelected,
);
},
);
}
/// Public helper method to be used for creating a staggered grid following m3
/// specs from a list of [Widget]s
static Builder toMaterialGrid({
List<Widget> thisWidgets = const <Widget>[],
List<Breakpoint> breakpoints = const <Breakpoint>[
Breakpoints.small,
Breakpoints.medium,
Breakpoints.large,
],
double margin = 8,
int itemColumns = 1,
required BuildContext context,
}) {
return Builder(builder: (BuildContext context) {
Breakpoint? currentBreakpoint;
for (final Breakpoint breakpoint in breakpoints) {
if (breakpoint.isActive(context)) {
currentBreakpoint = breakpoint;
}
}
double? thisMargin = margin;
if (currentBreakpoint == Breakpoints.small) {
if (thisMargin < kMaterialCompactMinMargin) {
thisMargin = kMaterialCompactMinMargin;
}
} else if (currentBreakpoint == Breakpoints.medium) {
if (thisMargin < kMaterialMediumMinMargin) {
thisMargin = kMaterialMediumMinMargin;
}
} else if (currentBreakpoint == Breakpoints.large) {
if (thisMargin < kMaterialExpandedMinMargin) {
thisMargin = kMaterialExpandedMinMargin;
}
}
return CustomScrollView(
primary: false,
controller: ScrollController(),
shrinkWrap: true,
physics: const AlwaysScrollableScrollPhysics(),
slivers: <Widget>[
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(thisMargin),
child: _BrickLayout(
columns: itemColumns,
columnSpacing: kMaterialGutterValue,
itemPadding:
const EdgeInsets.only(bottom: kMaterialGutterValue),
children: thisWidgets,
),
),
),
],
);
});
}
/// Animation from bottom offscreen up onto the screen.
static AnimatedWidget bottomToTop(Widget child, Animation<double> animation) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(animation),
child: child,
);
}
/// Animation from on the screen down off the screen.
static AnimatedWidget topToBottom(Widget child, Animation<double> animation) {
return SlideTransition(
position: Tween<Offset>(
begin: Offset.zero,
end: const Offset(0, 1),
).animate(animation),
child: child,
);
}
/// Animation from left off the screen into the screen.
static AnimatedWidget leftOutIn(Widget child, Animation<double> animation) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(-1, 0),
end: Offset.zero,
).animate(animation),
child: child,
);
}
/// Animation from on screen to left off screen.
static AnimatedWidget leftInOut(Widget child, Animation<double> animation) {
return SlideTransition(
position: Tween<Offset>(
begin: Offset.zero,
end: const Offset(-1, 0),
).animate(animation),
child: child,
);
}
/// Animation from right off screen to on screen.
static AnimatedWidget rightOutIn(Widget child, Animation<double> animation) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(animation),
child: child,
);
}
/// Fade in animation.
static Widget fadeIn(Widget child, Animation<double> animation) {
return FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeInCubic),
child: child,
);
}
/// Fade out animation.
static Widget fadeOut(Widget child, Animation<double> animation) {
return FadeTransition(
opacity: CurvedAnimation(
parent: ReverseAnimation(animation),
curve: Curves.easeInCubic,
),
child: child,
);
}
/// Keep widget on screen while it is leaving
static Widget stayOnScreen(Widget child, Animation<double> animation) {
return FadeTransition(
opacity: Tween<double>(begin: 1.0, end: 1.0).animate(animation),
child: child,
);
}
@override
State<AdaptiveScaffold> createState() => _AdaptiveScaffoldState();
}
class _AdaptiveScaffoldState extends State<AdaptiveScaffold> {
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.ltr,
child: Scaffold(
appBar: widget.drawerBreakpoint.isActive(context) && widget.useDrawer
? widget.appBar ?? AppBar()
: null,
drawer: widget.drawerBreakpoint.isActive(context) && widget.useDrawer
? Drawer(
child: NavigationRail(
extended: true,
leading: widget.leadingExtendedNavRail,
trailing: widget.trailingNavRail,
selectedIndex: widget.selectedIndex,
destinations: widget.destinations
.map((_) => AdaptiveScaffold.toRailDestination(_))
.toList(),
onDestinationSelected: widget.onSelectedIndexChange,
),
)
: null,
body: AdaptiveLayout(
bodyOrientation: widget.bodyOrientation,
bodyRatio: widget.bodyRatio,
internalAnimations: widget.internalAnimations,
primaryNavigation: SlotLayout(
config: <Breakpoint, SlotLayoutConfig>{
widget.mediumBreakpoint: SlotLayout.from(
key: const Key('primaryNavigation'),
builder: (_) => AdaptiveScaffold.standardNavigationRail(
width: widget.navigationRailWidth,
leading: widget.leadingUnextendedNavRail,
trailing: widget.trailingNavRail,
selectedIndex: widget.selectedIndex,
destinations: widget.destinations
.map((_) => AdaptiveScaffold.toRailDestination(_))
.toList(),
onDestinationSelected: widget.onSelectedIndexChange,
),
),
widget.largeBreakpoint: SlotLayout.from(
key: const Key('primaryNavigation1'),
builder: (_) => AdaptiveScaffold.standardNavigationRail(
width: widget.extendedNavigationRailWidth,
extended: true,
leading: widget.leadingExtendedNavRail,
trailing: widget.trailingNavRail,
selectedIndex: widget.selectedIndex,
destinations: widget.destinations
.map((_) => AdaptiveScaffold.toRailDestination(_))
.toList(),
onDestinationSelected: widget.onSelectedIndexChange,
),
),
},
),
bottomNavigation:
!widget.drawerBreakpoint.isActive(context) || !widget.useDrawer
? SlotLayout(
config: <Breakpoint, SlotLayoutConfig>{
widget.smallBreakpoint: SlotLayout.from(
key: const Key('bottomNavigation'),
builder: (_) =>
AdaptiveScaffold.standardBottomNavigationBar(
currentIndex: widget.selectedIndex,
destinations: widget.destinations,
onDestinationSelected: widget.onSelectedIndexChange,
),
),
},
)
: null,
body: SlotLayout(
config: <Breakpoint, SlotLayoutConfig?>{
Breakpoints.standard: SlotLayout.from(
key: const Key('body'),
inAnimation: AdaptiveScaffold.fadeIn,
outAnimation: AdaptiveScaffold.fadeOut,
builder: widget.body,
),
if (widget.smallBody != null)
widget.smallBreakpoint:
(widget.smallBody != AdaptiveScaffold.emptyBuilder)
? SlotLayout.from(
key: const Key('smallBody'),
inAnimation: AdaptiveScaffold.fadeIn,
outAnimation: AdaptiveScaffold.fadeOut,
builder: widget.smallBody,
)
: null,
if (widget.body != null)
widget.mediumBreakpoint:
(widget.body != AdaptiveScaffold.emptyBuilder)
? SlotLayout.from(
key: const Key('body'),
inAnimation: AdaptiveScaffold.fadeIn,
outAnimation: AdaptiveScaffold.fadeOut,
builder: widget.body,
)
: null,
if (widget.largeBody != null)
widget.largeBreakpoint:
(widget.largeBody != AdaptiveScaffold.emptyBuilder)
? SlotLayout.from(
key: const Key('largeBody'),
inAnimation: AdaptiveScaffold.fadeIn,
outAnimation: AdaptiveScaffold.fadeOut,
builder: widget.largeBody,
)
: null,
},
),
secondaryBody: SlotLayout(
config: <Breakpoint, SlotLayoutConfig?>{
Breakpoints.standard: SlotLayout.from(
key: const Key('sBody'),
outAnimation: AdaptiveScaffold.stayOnScreen,
builder: widget.secondaryBody,
),
if (widget.smallSecondaryBody != null)
widget.smallBreakpoint:
(widget.smallSecondaryBody != AdaptiveScaffold.emptyBuilder)
? SlotLayout.from(
key: const Key('smallSBody'),
outAnimation: AdaptiveScaffold.stayOnScreen,
builder: widget.smallSecondaryBody,
)
: null,
if (widget.secondaryBody != null)
widget.mediumBreakpoint:
(widget.secondaryBody != AdaptiveScaffold.emptyBuilder)
? SlotLayout.from(
key: const Key('sBody'),
outAnimation: AdaptiveScaffold.stayOnScreen,
builder: widget.secondaryBody,
)
: null,
if (widget.largeSecondaryBody != null)
widget.largeBreakpoint:
(widget.largeSecondaryBody != AdaptiveScaffold.emptyBuilder)
? SlotLayout.from(
key: const Key('largeSBody'),
outAnimation: AdaptiveScaffold.stayOnScreen,
builder: widget.largeSecondaryBody,
)
: null,
},
),
),
),
);
}
}
BottomNavigationBarItem _toBottomNavItem(NavigationDestination destination) {
return BottomNavigationBarItem(
label: destination.label,
icon: destination.icon,
);
}
class _BrickLayout extends StatelessWidget {
const _BrickLayout({
this.columns = 1,
this.itemPadding = EdgeInsets.zero,
this.columnSpacing = 0,
required this.children,
});
final int columns;
final double columnSpacing;
final EdgeInsetsGeometry itemPadding;
final List<Widget> children;
@override
Widget build(BuildContext context) {
int i = -1;
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Expanded(
child: CustomMultiChildLayout(
delegate: _BrickLayoutDelegate(
columns: columns,
columnSpacing: columnSpacing,
itemPadding: itemPadding,
),
children: children
.map<Widget>(
(Widget child) => LayoutId(id: i += 1, child: child),
)
.toList(),
),
),
],
);
}
}
class _BrickLayoutDelegate extends MultiChildLayoutDelegate {
_BrickLayoutDelegate({
this.columns = 1,
this.columnSpacing = 0,
this.itemPadding = EdgeInsets.zero,
});
final int columns;
final EdgeInsetsGeometry itemPadding;
final double columnSpacing;
@override
void performLayout(Size size) {
final BoxConstraints looseConstraints = BoxConstraints.loose(size);
final BoxConstraints fullWidthConstraints =
looseConstraints.tighten(width: size.width);
final List<Size> childSizes = <Size>[];
int childCount = 0;
// Count how many children we have.
for (; hasChild(childCount); childCount += 1) {}
final BoxConstraints itemConstraints = BoxConstraints(
maxWidth: fullWidthConstraints.maxWidth / columns -
columnSpacing / 2 -
itemPadding.horizontal,
);
for (int i = 0; i < childCount; i += 1) {
childSizes.add(layoutChild(i, itemConstraints));
}
int columnIndex = 0;
int childId = 0;
final double totalColumnSpacing = columnSpacing * (columns - 1);
final double columnWidth = (size.width - totalColumnSpacing) / columns;
final double topPadding = itemPadding.resolve(TextDirection.ltr).top;
final List<double> columnUsage =
List<double>.generate(columns, (int index) => topPadding);
for (final Size childSize in childSizes) {
positionChild(
childId,
Offset(
columnSpacing * columnIndex +
columnWidth * columnIndex +
(columnWidth - childSize.width) / 2,
columnUsage[columnIndex],
),
);
columnUsage[columnIndex] += childSize.height + itemPadding.vertical;
columnIndex = (columnIndex + 1) % columns;
childId += 1;
}
}
@override
bool shouldRelayout(_BrickLayoutDelegate oldDelegate) {
return itemPadding != oldDelegate.itemPadding ||
columnSpacing != oldDelegate.columnSpacing;
}
}