blob: 7ed15363d20cac9b1698a5e255c71ef3ea17d189 [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 'dart:ui';
import 'package:flutter/widgets.dart';
import 'breakpoints.dart';
import 'slot_layout.dart';
enum _SlotIds {
primaryNavigation,
secondaryNavigation,
topNavigation,
bottomNavigation,
body,
secondaryBody,
}
/// Layout an app that adapts to different screens using predefined slots.
///
/// This widget separates the app window into predefined sections called
/// "slots". It lays out the app using the following kinds of slots (in order):
///
/// * [topNavigation], full width at the top. Must have defined size.
/// * [bottomNavigation], full width at the bottom. Must have defined size.
/// * [primaryNavigation], displayed on the beginning side of the app window
/// from the bottom of [topNavigation] to the top of [bottomNavigation]. Must
/// have defined size.
/// * [secondaryNavigation], displayed on the end side of the app window from
/// the bottom of [topNavigation] to the top of [bottomNavigation]. Must have
/// defined size.
/// * [body], first panel; fills the remaining space from the beginning side.
/// The main view should have flexible size (like a container).
/// * [secondaryBody], second panel; fills the remaining space from the end
/// side. The use of this property is common in apps that have a main view
/// and a detail view. The main view should have flexible size (like a
/// Container). This provides some automatic functionality with foldable
/// screens.
///
/// Slots can display differently under different screen conditions (such as
/// different widths), and each slot is defined with a [SlotLayout], which maps
/// [Breakpoint]s to [SlotLayoutConfig], where [SlotLayoutConfig] defines the
/// content and transition.
///
/// [AdaptiveLayout] handles the placement of the slots on the app window and
/// animations regarding their macromovements.
///
/// ```dart
/// AdaptiveLayout(
/// primaryNavigation: SlotLayout(
/// config: {
/// Breakpoints.small: SlotLayout.from(
/// key: const Key('Primary Navigation Small'),
/// builder: (_) => const SizedBox.shrink(),
/// ),
/// Breakpoints.medium: SlotLayout.from(
/// inAnimation: leftOutIn,
/// key: const Key('Primary Navigation Medium'),
/// builder: (_) => AdaptiveScaffold.toNavigationRail(destinations: destinations),
/// ),
/// Breakpoints.large: SlotLayout.from(
/// key: const Key('Primary Navigation Large'),
/// inAnimation: leftOutIn,
/// builder: (_) => AdaptiveScaffold.toNavigationRail(extended: true, destinations: destinations),
/// ),
/// },
/// ),
/// body: SlotLayout(
/// config: {
/// Breakpoints.small: SlotLayout.from(
/// key: const Key('Body Small'),
/// builder: (_) => ListView.builder(
/// itemCount: children.length,
/// itemBuilder: (_, idx) => children[idx]
/// ),
/// ),
/// Breakpoints.medium: SlotLayout.from(
/// key: const Key('Body Medium'),
/// builder: (_) => GridView.count(
/// crossAxisCount: 2,
/// children: children
/// ),
/// ),
/// },
/// ),
/// bottomNavigation: SlotLayout(
/// config: {
/// Breakpoints.small: SlotLayout.from(
/// key: const Key('Bottom Navigation Small'),
/// inAnimation: bottomToTop,
/// builder: (_) => AdaptiveScaffold.toBottomNavigationBar(destinations: destinations),
/// ),
/// },
/// ),
/// )
/// ```
///
/// See also:
///
/// * [SlotLayout], which handles the actual switching and animations between
/// elements based on [Breakpoint]s.
/// * [SlotLayout.from], which holds information regarding the actual Widgets
/// and the desired way to animate between switches. Often used within
/// [SlotLayout].
/// * [AdaptiveScaffold], which provides a more friendly API with less
/// customizability. and holds a preset of animations and helper builders.
/// * [Design Doc](https://flutter.dev/go/adaptive-layout-foldables).
/// * [Material Design 3 Specifications](https://m3.material.io/foundations/adaptive-design/overview).
class AdaptiveLayout extends StatefulWidget {
/// Creates a const [AdaptiveLayout] widget.
const AdaptiveLayout({
super.key,
this.topNavigation,
this.primaryNavigation,
this.secondaryNavigation,
this.bottomNavigation,
this.body,
this.secondaryBody,
this.bodyRatio,
this.internalAnimations = true,
this.bodyOrientation = Axis.horizontal,
});
/// The slot placed on the beginning side of the app window.
///
/// The beginning side means the right when the ambient [Directionality] is
/// [TextDirection.rtl] and on the left when it is [TextDirection.ltr].
///
/// If the content is a flexibly sized Widget like [Container], wrap the
/// content in a [SizedBox] or limit its size (width and height) by another
/// method. See the builder in [AdaptiveScaffold.standardNavigationRail] for
/// an example.
final SlotLayout? primaryNavigation;
/// The slot placed on the end side of the app window.
///
/// The end side means the right when the ambient [Directionality] is
/// [TextDirection.ltr] and on the left when it is [TextDirection.rtl].
///
/// If the content is a flexibly sized Widget like [Container], wrap the
/// content in a [SizedBox] or limit its size (width and height) by another
/// method. See the builder in [AdaptiveScaffold.standardNavigationRail] for
/// an example.
final SlotLayout? secondaryNavigation;
/// The slot placed on the top part of the app window.
///
/// If the content is a flexibly sized Widget like [Container], wrap the
/// content in a [SizedBox] or limit its size (width and height) by another
/// method. See the builder in [AdaptiveScaffold.standardNavigationRail] for
/// an example.
final SlotLayout? topNavigation;
/// The slot placed on the bottom part of the app window.
///
/// If the content is a flexibly sized Widget like [Container], wrap the
/// content in a [SizedBox] or limit its size (width and height) by another
/// method. See the builder in [AdaptiveScaffold.standardNavigationRail] for
/// an example.
final SlotLayout? bottomNavigation;
/// The slot that fills the rest of the space in the center.
final SlotLayout? body;
/// A supporting slot for [body].
///
/// The [secondaryBody] as a sliding entrance animation by default.
///
/// The default ratio for the split between [body] and [secondaryBody] is so
/// that the split axis is in the center of the app window when there is no
/// hinge and surrounding the hinge when there is one.
final SlotLayout? secondaryBody;
/// 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 app window when there is no hinge and surrounding the
/// hinge when there is one.
final double? bodyRatio;
/// 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;
@override
State<AdaptiveLayout> createState() => _AdaptiveLayoutState();
}
class _AdaptiveLayoutState extends State<AdaptiveLayout>
with TickerProviderStateMixin {
late AnimationController _controller;
late Map<String, SlotLayoutConfig?> chosenWidgets =
<String, SlotLayoutConfig?>{};
Map<String, Size?> slotSizes = <String, Size?>{};
Map<String, ValueNotifier<Key?>> notifiers = <String, ValueNotifier<Key?>>{};
Set<String> isAnimating = <String>{};
@override
void initState() {
if (widget.internalAnimations) {
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
)..forward();
} else {
_controller = AnimationController(
duration: Duration.zero,
vsync: this,
);
}
for (final _SlotIds item in _SlotIds.values) {
notifiers[item.name] = ValueNotifier<Key?>(null)
..addListener(() {
isAnimating.add(item.name);
_controller.reset();
_controller.forward();
});
}
_controller.addStatusListener((AnimationStatus status) {
if (status == AnimationStatus.completed) {
isAnimating.clear();
}
});
super.initState();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final Map<String, SlotLayout?> slots = <String, SlotLayout?>{
_SlotIds.primaryNavigation.name: widget.primaryNavigation,
_SlotIds.secondaryNavigation.name: widget.secondaryNavigation,
_SlotIds.topNavigation.name: widget.topNavigation,
_SlotIds.bottomNavigation.name: widget.bottomNavigation,
_SlotIds.body.name: widget.body,
_SlotIds.secondaryBody.name: widget.secondaryBody,
};
chosenWidgets = <String, SlotLayoutConfig?>{};
slots.forEach((String key, SlotLayout? value) {
slots.update(
key,
(SlotLayout? val) => val,
ifAbsent: () => value,
);
chosenWidgets.update(
key,
(SlotLayoutConfig? val) => val,
ifAbsent: () => SlotLayout.pickWidget(
context, value?.config ?? <Breakpoint, SlotLayoutConfig?>{}),
);
});
final List<Widget> entries = slots.entries
.map((MapEntry<String, SlotLayout?> entry) {
if (entry.value != null) {
return LayoutId(
id: entry.key, child: entry.value ?? const SizedBox());
}
})
.whereType<Widget>()
.toList();
notifiers.forEach((String key, ValueNotifier<Key?> notifier) {
notifier.value = chosenWidgets[key]?.key;
});
Rect? hinge;
for (final DisplayFeature e in MediaQuery.of(context).displayFeatures) {
if (e.type == DisplayFeatureType.hinge ||
e.type == DisplayFeatureType.fold) {
if (e.bounds.left != 0) {
hinge = e.bounds;
}
}
}
return CustomMultiChildLayout(
delegate: _AdaptiveLayoutDelegate(
slots: slots,
chosenWidgets: chosenWidgets,
slotSizes: slotSizes,
controller: _controller,
bodyRatio: widget.bodyRatio,
isAnimating: isAnimating,
internalAnimations: widget.internalAnimations,
bodyOrientation: widget.bodyOrientation,
textDirection: Directionality.of(context) == TextDirection.ltr,
hinge: hinge,
),
children: entries,
);
}
}
/// The delegate responsible for laying out the slots in their correct
/// positions.
class _AdaptiveLayoutDelegate extends MultiChildLayoutDelegate {
_AdaptiveLayoutDelegate({
required this.slots,
required this.chosenWidgets,
required this.slotSizes,
required this.controller,
required this.bodyRatio,
required this.isAnimating,
required this.internalAnimations,
required this.bodyOrientation,
required this.textDirection,
this.hinge,
}) : super(relayout: controller);
final Map<String, SlotLayout?> slots;
final Map<String, SlotLayoutConfig?> chosenWidgets;
final Map<String, Size?> slotSizes;
final Set<String> isAnimating;
final AnimationController controller;
final double? bodyRatio;
final bool internalAnimations;
final Axis bodyOrientation;
final bool textDirection;
final Rect? hinge;
@override
void performLayout(Size size) {
double leftMargin = 0;
double topMargin = 0;
double rightMargin = 0;
double bottomMargin = 0;
// An animation that is used as either a width or height value on the Size
// for the body/secondaryBody.
double animatedSize(double begin, double end) {
if (isAnimating.contains(_SlotIds.secondaryBody.name)) {
return internalAnimations
? Tween<double>(begin: begin, end: end)
.animate(CurvedAnimation(
parent: controller, curve: Curves.easeInOutCubic))
.value
: end;
}
return end;
}
if (hasChild(_SlotIds.topNavigation.name)) {
final Size childSize = layoutChild(
_SlotIds.topNavigation.name,
BoxConstraints.loose(size),
);
// Trigger the animation if the new size is different from the old size.
updateSize(_SlotIds.topNavigation.name, childSize);
// Tween not the actual size, but the size that is used in the margins so
// the offsets can be animated.
final Size currentSize = Tween<Size>(
begin: slotSizes[_SlotIds.topNavigation.name] ?? Size.zero,
end: childSize,
).animate(controller).value;
positionChild(_SlotIds.topNavigation.name, Offset.zero);
topMargin += currentSize.height;
}
if (hasChild(_SlotIds.bottomNavigation.name)) {
final Size childSize = layoutChild(
_SlotIds.bottomNavigation.name,
BoxConstraints.loose(size),
);
updateSize(_SlotIds.bottomNavigation.name, childSize);
final Size currentSize = Tween<Size>(
begin: slotSizes[_SlotIds.bottomNavigation.name] ?? Size.zero,
end: childSize,
).animate(controller).value;
positionChild(
_SlotIds.bottomNavigation.name,
Offset(0, size.height - currentSize.height),
);
bottomMargin += currentSize.height;
}
if (hasChild(_SlotIds.primaryNavigation.name)) {
final Size childSize = layoutChild(
_SlotIds.primaryNavigation.name,
BoxConstraints.loose(size),
);
updateSize(_SlotIds.primaryNavigation.name, childSize);
final Size currentSize = Tween<Size>(
begin: slotSizes[_SlotIds.primaryNavigation.name] ?? Size.zero,
end: childSize,
).animate(controller).value;
if (textDirection) {
positionChild(
_SlotIds.primaryNavigation.name,
Offset(leftMargin, topMargin),
);
leftMargin += currentSize.width;
} else {
positionChild(
_SlotIds.primaryNavigation.name,
Offset(size.width - currentSize.width, topMargin),
);
rightMargin += currentSize.width;
}
}
if (hasChild(_SlotIds.secondaryNavigation.name)) {
final Size childSize = layoutChild(
_SlotIds.secondaryNavigation.name,
BoxConstraints.loose(size),
);
updateSize(_SlotIds.secondaryNavigation.name, childSize);
final Size currentSize = Tween<Size>(
begin: slotSizes[_SlotIds.secondaryNavigation.name] ?? Size.zero,
end: childSize,
).animate(controller).value;
if (textDirection) {
positionChild(
_SlotIds.secondaryNavigation.name,
Offset(size.width - currentSize.width, topMargin),
);
rightMargin += currentSize.width;
} else {
positionChild(_SlotIds.secondaryNavigation.name, Offset(0, topMargin));
leftMargin += currentSize.width;
}
}
final double remainingWidth = size.width - rightMargin - leftMargin;
final double remainingHeight = size.height - bottomMargin - topMargin;
final double halfWidth = size.width / 2;
final double halfHeight = size.height / 2;
final double hingeWidth = hinge != null ? hinge!.right - hinge!.left : 0;
if (hasChild(_SlotIds.body.name) && hasChild(_SlotIds.secondaryBody.name)) {
Size currentBodySize = Size.zero;
Size currentSBodySize = Size.zero;
if (chosenWidgets[_SlotIds.secondaryBody.name] == null ||
chosenWidgets[_SlotIds.secondaryBody.name]!.builder == null) {
if (!textDirection) {
currentBodySize = layoutChild(
_SlotIds.body.name,
BoxConstraints.tight(
Size(remainingWidth, remainingHeight),
),
);
} else if (bodyOrientation == Axis.horizontal) {
double beginWidth;
if (bodyRatio == null) {
beginWidth = halfWidth - leftMargin;
} else {
beginWidth = remainingWidth * bodyRatio!;
}
currentBodySize = layoutChild(
_SlotIds.body.name,
BoxConstraints.tight(
Size(animatedSize(beginWidth, remainingWidth), remainingHeight),
),
);
} else {
double beginHeight;
if (bodyRatio == null) {
beginHeight = halfHeight - topMargin;
} else {
beginHeight = remainingHeight * bodyRatio!;
}
currentBodySize = layoutChild(
_SlotIds.body.name,
BoxConstraints.tight(
Size(remainingWidth, animatedSize(beginHeight, remainingHeight)),
),
);
}
layoutChild(_SlotIds.secondaryBody.name, BoxConstraints.loose(size));
} else {
if (bodyOrientation == Axis.horizontal) {
// Take this path if the body and secondaryBody are laid out horizontally.
if (textDirection) {
// Take this path if the textDirection is LTR.
double finalBodySize;
double finalSBodySize;
if (hinge != null) {
finalBodySize = hinge!.left - leftMargin;
finalSBodySize =
size.width - (hinge!.left + hingeWidth) - rightMargin;
} else if (bodyRatio != null) {
finalBodySize = remainingWidth * bodyRatio!;
finalSBodySize = remainingWidth * (1 - bodyRatio!);
} else {
finalBodySize = halfWidth - leftMargin;
finalSBodySize = halfWidth - rightMargin;
}
currentBodySize = layoutChild(
_SlotIds.body.name,
BoxConstraints.tight(
Size(animatedSize(remainingWidth, finalBodySize),
remainingHeight),
),
);
layoutChild(
_SlotIds.secondaryBody.name,
BoxConstraints.tight(
Size(finalSBodySize, remainingHeight),
),
);
} else {
// Take this path if the textDirection is RTL.
double finalBodySize;
double finalSBodySize;
if (hinge != null) {
finalBodySize =
size.width - (hinge!.left + hingeWidth) - rightMargin;
finalSBodySize = hinge!.left - leftMargin;
} else if (bodyRatio != null) {
finalBodySize = remainingWidth * bodyRatio!;
finalSBodySize = remainingWidth * (1 - bodyRatio!);
} else {
finalBodySize = halfWidth - rightMargin;
finalSBodySize = halfWidth - leftMargin;
}
currentSBodySize = layoutChild(
_SlotIds.secondaryBody.name,
BoxConstraints.tight(
Size(animatedSize(0, finalSBodySize), remainingHeight),
),
);
layoutChild(
_SlotIds.body.name,
BoxConstraints.tight(
Size(finalBodySize, remainingHeight),
),
);
}
} else {
// Take this path if the body and secondaryBody are laid out vertically.
currentBodySize = layoutChild(
_SlotIds.body.name,
BoxConstraints.tight(
Size(
remainingWidth,
animatedSize(
remainingHeight,
bodyRatio == null
? halfHeight - topMargin
: remainingHeight * bodyRatio!,
),
),
),
);
layoutChild(
_SlotIds.secondaryBody.name,
BoxConstraints.tight(
Size(
remainingWidth,
bodyRatio == null
? halfHeight - bottomMargin
: remainingHeight * (1 - bodyRatio!),
),
),
);
}
}
// Handle positioning for the body and secondaryBody.
if (bodyOrientation == Axis.horizontal &&
!textDirection &&
chosenWidgets[_SlotIds.secondaryBody.name] != null) {
if (hinge != null) {
positionChild(
_SlotIds.body.name,
Offset(currentSBodySize.width + leftMargin + hingeWidth, topMargin),
);
positionChild(
_SlotIds.secondaryBody.name, Offset(leftMargin, topMargin));
} else {
positionChild(
_SlotIds.body.name,
Offset(currentSBodySize.width + leftMargin, topMargin),
);
positionChild(
_SlotIds.secondaryBody.name, Offset(leftMargin, topMargin));
}
} else {
positionChild(_SlotIds.body.name, Offset(leftMargin, topMargin));
if (bodyOrientation == Axis.horizontal) {
if (hinge != null) {
positionChild(
_SlotIds.secondaryBody.name,
Offset(
currentBodySize.width + leftMargin + hingeWidth, topMargin),
);
} else {
positionChild(
_SlotIds.secondaryBody.name,
Offset(currentBodySize.width + leftMargin, topMargin),
);
}
} else {
positionChild(
_SlotIds.secondaryBody.name,
Offset(leftMargin, topMargin + currentBodySize.height),
);
}
}
} else if (hasChild(_SlotIds.body.name)) {
layoutChild(
_SlotIds.body.name,
BoxConstraints.tight(
Size(remainingWidth, remainingHeight),
),
);
positionChild(_SlotIds.body.name, Offset(leftMargin, topMargin));
} else if (hasChild(_SlotIds.secondaryBody.name)) {
layoutChild(
_SlotIds.secondaryBody.name,
BoxConstraints.tight(
Size(remainingWidth, remainingHeight),
),
);
}
}
void updateSize(String id, Size childSize) {
if (slotSizes[id] == null || slotSizes[id] != childSize) {
void listener(AnimationStatus status) {
if ((status == AnimationStatus.completed ||
status == AnimationStatus.dismissed) &&
(slotSizes[id] == null || slotSizes[id] != childSize)) {
slotSizes[id] = childSize;
}
controller.removeStatusListener(listener);
}
controller.addStatusListener(listener);
}
}
@override
bool shouldRelayout(_AdaptiveLayoutDelegate oldDelegate) {
return oldDelegate.slots != slots;
}
}