| // 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; |
| } |
| } |