From 79bd62952d68851954c5bc30df22454d0574f6ec Mon Sep 17 00:00:00 2001 From: Pierre-Louis <6655696+guidezpl@users.noreply.github.com> Date: Tue, 14 Feb 2023 19:18:36 +0100 Subject: [PATCH] Improvements to M3 demo app (#1647) * improvements * fix focus * add comment * add comment * copy changes to root material_3_demo * fix large breakpoint * fix large breakpoint * Create integration_test.dart * refactor main.dart into home.dart and constants.dart * add integration_test to pubspec * copy to root material_3_demo * remove removal of constraints * address feedback --- .../integration_test/integration_test.dart | 15 + .../material_3_demo/lib/component_screen.dart | 172 +++-- .../material_3_demo/lib/constants.dart | 40 + experimental/material_3_demo/lib/home.dart | 665 +++++++++++++++++ experimental/material_3_demo/lib/main.dart | 684 +----------------- experimental/material_3_demo/pubspec.yaml | 2 + .../test/color_screen_test.dart | 4 +- .../test/component_screen_test.dart | 14 +- .../test/elevation_screen_test.dart | 4 +- .../test/typography_screen_test.dart | 4 +- .../integration_test/integration_test.dart | 15 + material_3_demo/lib/component_screen.dart | 172 +++-- material_3_demo/lib/constants.dart | 40 + material_3_demo/lib/home.dart | 665 +++++++++++++++++ material_3_demo/lib/main.dart | 684 +----------------- material_3_demo/pubspec.yaml | 2 + material_3_demo/test/color_screen_test.dart | 4 +- .../test/component_screen_test.dart | 14 +- .../test/elevation_screen_test.dart | 4 +- .../test/typography_screen_test.dart | 4 +- 20 files changed, 1708 insertions(+), 1500 deletions(-) create mode 100644 experimental/material_3_demo/integration_test/integration_test.dart create mode 100644 experimental/material_3_demo/lib/constants.dart create mode 100644 experimental/material_3_demo/lib/home.dart create mode 100644 material_3_demo/integration_test/integration_test.dart create mode 100644 material_3_demo/lib/constants.dart create mode 100644 material_3_demo/lib/home.dart diff --git a/experimental/material_3_demo/integration_test/integration_test.dart b/experimental/material_3_demo/integration_test/integration_test.dart new file mode 100644 index 000000000..a8d59c4e6 --- /dev/null +++ b/experimental/material_3_demo/integration_test/integration_test.dart @@ -0,0 +1,15 @@ +// Copyright 2021 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_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:material_3_demo/main.dart' as app; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('verify app can run', (tester) async { + app.main(); + }); +} diff --git a/experimental/material_3_demo/lib/component_screen.dart b/experimental/material_3_demo/lib/component_screen.dart index ff3c8bd01..826d50873 100644 --- a/experimental/material_3_demo/lib/component_screen.dart +++ b/experimental/material_3_demo/lib/component_screen.dart @@ -26,25 +26,28 @@ class FirstComponentList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView( - padding: showSecondList - ? const EdgeInsetsDirectional.only(end: smallSpacing) - : EdgeInsets.zero, - children: [ - const Actions(), - colDivider, - const Communication(), - colDivider, - const Containment(), - if (!showSecondList) ...[ - colDivider, - Navigation(scaffoldKey: scaffoldKey), + // Fully traverse this list before moving on. + return FocusTraversalGroup( + child: ListView( + padding: showSecondList + ? const EdgeInsetsDirectional.only(end: smallSpacing) + : EdgeInsets.zero, + children: [ + const Actions(), colDivider, - const Selection(), + const Communication(), colDivider, - const TextInputs() + const Containment(), + if (!showSecondList) ...[ + colDivider, + Navigation(scaffoldKey: scaffoldKey), + colDivider, + const Selection(), + colDivider, + const TextInputs() + ], ], - ], + ), ); } } @@ -59,15 +62,18 @@ class SecondComponentList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsetsDirectional.only(end: smallSpacing), - children: [ - Navigation(scaffoldKey: scaffoldKey), - colDivider, - const Selection(), - colDivider, - const TextInputs(), - ], + // Fully traverse this list before moving on. + return FocusTraversalGroup( + child: ListView( + padding: const EdgeInsetsDirectional.only(end: smallSpacing), + children: [ + Navigation(scaffoldKey: scaffoldKey), + colDivider, + const Selection(), + colDivider, + const TextInputs(), + ], + ), ); } } @@ -1011,13 +1017,13 @@ class NavigationBars extends StatefulWidget { this.onSelectItem, required this.selectedIndex, required this.isExampleBar, - this.isBadgeExample, + this.isBadgeExample = false, }); final void Function(int)? onSelectItem; final int selectedIndex; final bool isExampleBar; - final bool? isBadgeExample; + final bool isBadgeExample; @override State createState() => _NavigationBarsState(); @@ -1042,23 +1048,26 @@ class _NavigationBarsState extends State { @override Widget build(BuildContext context) { - bool isBadgeExample = widget.isBadgeExample ?? false; - Widget navigationBar = NavigationBar( - selectedIndex: selectedIndex, - onDestinationSelected: (index) { - setState(() { - selectedIndex = index; - }); - if (!widget.isExampleBar) widget.onSelectItem!(index); - }, - destinations: widget.isExampleBar && isBadgeExample - ? barWithBadgeDestinations - : widget.isExampleBar - ? exampleBarDestinations - : appBarDestinations, + // App NavigationBar should get first focus. + Widget navigationBar = Focus( + autofocus: !(widget.isExampleBar || widget.isBadgeExample), + child: NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: (index) { + setState(() { + selectedIndex = index; + }); + if (!widget.isExampleBar) widget.onSelectItem!(index); + }, + destinations: widget.isExampleBar && widget.isBadgeExample + ? barWithBadgeDestinations + : widget.isExampleBar + ? exampleBarDestinations + : appBarDestinations, + ), ); - if (widget.isExampleBar && isBadgeExample) { + if (widget.isExampleBar && widget.isBadgeExample) { navigationBar = ComponentDecoration( label: 'Badges', tooltipMessage: 'Use Badge or Badge.count', @@ -2188,7 +2197,7 @@ class _SlidersState extends State { } } -class ComponentDecoration extends StatelessWidget { +class ComponentDecoration extends StatefulWidget { const ComponentDecoration({ super.key, required this.label, @@ -2200,6 +2209,13 @@ class ComponentDecoration extends StatelessWidget { final Widget child; final String? tooltipMessage; + @override + State createState() => _ComponentDecorationState(); +} + +class _ComponentDecorationState extends State { + final focusNode = FocusNode(); + @override Widget build(BuildContext context) { return RepaintBoundary( @@ -2210,9 +2226,10 @@ class ComponentDecoration extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(label, style: Theme.of(context).textTheme.titleSmall), + Text(widget.label, + style: Theme.of(context).textTheme.titleSmall), Tooltip( - message: tooltipMessage, + message: widget.tooltipMessage, child: const Padding( padding: EdgeInsets.symmetric(horizontal: 5.0), child: Icon(Icons.info_outline, size: 16)), @@ -2222,18 +2239,32 @@ class ComponentDecoration extends StatelessWidget { ConstrainedBox( constraints: const BoxConstraints.tightFor(width: widthConstraint), - child: Card( - elevation: 0, - shape: RoundedRectangleBorder( - side: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, + // Tapping within the a component card should request focus + // for that component's children. + child: Focus( + focusNode: focusNode, + canRequestFocus: true, + child: GestureDetector( + onTapDown: (_) { + focusNode.requestFocus(); + }, + behavior: HitTestBehavior.opaque, + child: Card( + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 5.0, vertical: 20.0), + child: Center( + child: widget.child, + ), + ), ), - borderRadius: const BorderRadius.all(Radius.circular(12)), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 5.0, vertical: 20.0), - child: Center(child: child), ), ), ), @@ -2253,19 +2284,22 @@ class ComponentGroupDecoration extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - margin: EdgeInsets.zero, - elevation: 0, - color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 20.0), - child: Center( - child: Column( - children: [ - Text(label, style: Theme.of(context).textTheme.titleLarge), - colDivider, - ...children - ], + // Fully traverse this component group before moving on + return FocusTraversalGroup( + child: Card( + margin: EdgeInsets.zero, + elevation: 0, + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0), + child: Center( + child: Column( + children: [ + Text(label, style: Theme.of(context).textTheme.titleLarge), + colDivider, + ...children + ], + ), ), ), ), diff --git a/experimental/material_3_demo/lib/constants.dart b/experimental/material_3_demo/lib/constants.dart new file mode 100644 index 000000000..943b0ca18 --- /dev/null +++ b/experimental/material_3_demo/lib/constants.dart @@ -0,0 +1,40 @@ +// Copyright 2021 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'; + +// NavigationRail shows if the screen width is greater or equal to +// narrowScreenWidthThreshold; otherwise, NavigationBar is used for navigation. +const double narrowScreenWidthThreshold = 450; + +const double mediumWidthBreakpoint = 1000; +const double largeWidthBreakpoint = 1500; + +const double transitionLength = 500; + +enum ColorSeed { + baseColor('M3 Baseline', Color(0xff6750a4)), + indigo('Indigo', Colors.indigo), + blue('Blue', Colors.blue), + teal('Teal', Colors.teal), + green('Green', Colors.green), + yellow('Yellow', Colors.yellow), + orange('Orange', Colors.orange), + deepOrange('Deep Orange', Colors.deepOrange), + pink('Pink', Colors.pink); + + const ColorSeed(this.label, this.color); + final String label; + final Color color; +} + +enum ScreenSelected { + component(0), + color(1), + typography(2), + elevation(3); + + const ScreenSelected(this.value); + final int value; +} diff --git a/experimental/material_3_demo/lib/home.dart b/experimental/material_3_demo/lib/home.dart new file mode 100644 index 000000000..b50e6573d --- /dev/null +++ b/experimental/material_3_demo/lib/home.dart @@ -0,0 +1,665 @@ +// Copyright 2021 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 'color_palettes_screen.dart'; +import 'component_screen.dart'; +import 'constants.dart'; +import 'elevation_screen.dart'; +import 'typography_screen.dart'; + +class Home extends StatefulWidget { + const Home({ + super.key, + required this.useLightMode, + required this.useMaterial3, + required this.colorSelected, + required this.handleBrightnessChange, + required this.handleMaterialVersionChange, + required this.handleColorSelect, + }); + + final bool useLightMode; + final bool useMaterial3; + final ColorSeed colorSelected; + final void Function(bool useLightMode) handleBrightnessChange; + final void Function() handleMaterialVersionChange; + final void Function(int value) handleColorSelect; + + @override + State createState() => _HomeState(); +} + +class _HomeState extends State with SingleTickerProviderStateMixin { + final GlobalKey scaffoldKey = GlobalKey(); + late final AnimationController controller; + late final CurvedAnimation railAnimation; + bool controllerInitialized = false; + bool showMediumSizeLayout = false; + bool showLargeSizeLayout = false; + + int screenIndex = ScreenSelected.component.value; + + @override + initState() { + super.initState(); + controller = AnimationController( + duration: Duration(milliseconds: transitionLength.toInt() * 2), + value: 0, + vsync: this, + ); + railAnimation = CurvedAnimation( + parent: controller, + curve: const Interval(0.5, 1.0), + ); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final double width = MediaQuery.of(context).size.width; + final AnimationStatus status = controller.status; + if (width > mediumWidthBreakpoint) { + if (width > largeWidthBreakpoint) { + showMediumSizeLayout = false; + showLargeSizeLayout = true; + } else { + showMediumSizeLayout = true; + showLargeSizeLayout = false; + } + if (status != AnimationStatus.forward && + status != AnimationStatus.completed) { + controller.forward(); + } + } else { + showMediumSizeLayout = false; + showLargeSizeLayout = false; + if (status != AnimationStatus.reverse && + status != AnimationStatus.dismissed) { + controller.reverse(); + } + } + if (!controllerInitialized) { + controllerInitialized = true; + controller.value = width > mediumWidthBreakpoint ? 1 : 0; + } + } + + void handleScreenChanged(int screenSelected) { + setState(() { + screenIndex = screenSelected; + }); + } + + Widget createScreenFor( + ScreenSelected screenSelected, bool showNavBarExample) { + switch (screenSelected) { + case ScreenSelected.component: + return Expanded( + child: OneTwoTransition( + animation: railAnimation, + one: FirstComponentList( + showNavBottomBar: showNavBarExample, + scaffoldKey: scaffoldKey, + showSecondList: showMediumSizeLayout || showLargeSizeLayout), + two: SecondComponentList( + scaffoldKey: scaffoldKey, + ), + ), + ); + case ScreenSelected.color: + return const ColorPalettesScreen(); + case ScreenSelected.typography: + return const TypographyScreen(); + case ScreenSelected.elevation: + return const ElevationScreen(); + default: + return FirstComponentList( + showNavBottomBar: showNavBarExample, + scaffoldKey: scaffoldKey, + showSecondList: showMediumSizeLayout || showLargeSizeLayout); + } + } + + PreferredSizeWidget createAppBar() { + return AppBar( + title: widget.useMaterial3 + ? const Text('Material 3') + : const Text('Material 2'), + actions: !showMediumSizeLayout && !showLargeSizeLayout + ? [ + _BrightnessButton( + handleBrightnessChange: widget.handleBrightnessChange, + ), + _Material3Button( + handleMaterialVersionChange: widget.handleMaterialVersionChange, + ), + _ColorSeedButton( + handleColorSelect: widget.handleColorSelect, + colorSelected: widget.colorSelected, + ), + ] + : [Container()], + ); + } + + Widget _expandedTrailingActions() => Container( + constraints: const BoxConstraints.tightFor(width: 250), + padding: const EdgeInsets.symmetric(horizontal: 30), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Text('Brightness'), + Expanded(child: Container()), + Switch( + value: widget.useLightMode, + onChanged: (value) { + widget.handleBrightnessChange(value); + }) + ], + ), + Row( + children: [ + widget.useMaterial3 + ? const Text('Material 3') + : const Text('Material 2'), + Expanded(child: Container()), + Switch( + value: widget.useMaterial3, + onChanged: (_) { + widget.handleMaterialVersionChange(); + }) + ], + ), + const Divider(), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200.0), + child: GridView.count( + crossAxisCount: 3, + children: List.generate( + ColorSeed.values.length, + (i) => IconButton( + icon: const Icon(Icons.radio_button_unchecked), + color: ColorSeed.values[i].color, + isSelected: widget.colorSelected.color == + ColorSeed.values[i].color, + selectedIcon: const Icon(Icons.circle), + onPressed: () { + widget.handleColorSelect(i); + }, + )), + ), + ), + ], + ), + ); + + Widget _trailingActions() => Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: _BrightnessButton( + handleBrightnessChange: widget.handleBrightnessChange, + showTooltipBelow: false, + ), + ), + Flexible( + child: _Material3Button( + handleMaterialVersionChange: widget.handleMaterialVersionChange, + showTooltipBelow: false, + ), + ), + Flexible( + child: _ColorSeedButton( + handleColorSelect: widget.handleColorSelect, + colorSelected: widget.colorSelected, + ), + ), + ], + ); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, child) { + return NavigationTransition( + scaffoldKey: scaffoldKey, + animationController: controller, + railAnimation: railAnimation, + appBar: createAppBar(), + body: createScreenFor( + ScreenSelected.values[screenIndex], controller.value == 1), + navigationRail: NavigationRail( + extended: showLargeSizeLayout, + destinations: navRailDestinations, + selectedIndex: screenIndex, + onDestinationSelected: (index) { + setState(() { + screenIndex = index; + handleScreenChanged(screenIndex); + }); + }, + trailing: Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 20), + child: showLargeSizeLayout + ? _expandedTrailingActions() + : _trailingActions(), + ), + ), + ), + navigationBar: NavigationBars( + onSelectItem: (index) { + setState(() { + screenIndex = index; + handleScreenChanged(screenIndex); + }); + }, + selectedIndex: screenIndex, + isExampleBar: false, + ), + ); + }, + ); + } +} + +class _BrightnessButton extends StatelessWidget { + const _BrightnessButton({ + required this.handleBrightnessChange, + this.showTooltipBelow = true, + }); + + final Function handleBrightnessChange; + final bool showTooltipBelow; + + @override + Widget build(BuildContext context) { + final isBright = Theme.of(context).brightness == Brightness.light; + return Tooltip( + preferBelow: showTooltipBelow, + message: 'Toggle brightness', + child: IconButton( + icon: isBright + ? const Icon(Icons.dark_mode_outlined) + : const Icon(Icons.light_mode_outlined), + onPressed: () => handleBrightnessChange(!isBright), + ), + ); + } +} + +class _Material3Button extends StatelessWidget { + const _Material3Button({ + required this.handleMaterialVersionChange, + this.showTooltipBelow = true, + }); + + final void Function() handleMaterialVersionChange; + final bool showTooltipBelow; + + @override + Widget build(BuildContext context) { + final useMaterial3 = Theme.of(context).useMaterial3; + return Tooltip( + preferBelow: showTooltipBelow, + message: 'Switch to Material ${useMaterial3 ? 2 : 3}', + child: IconButton( + icon: useMaterial3 + ? const Icon(Icons.filter_2) + : const Icon(Icons.filter_3), + onPressed: handleMaterialVersionChange, + ), + ); + } +} + +class _ColorSeedButton extends StatelessWidget { + const _ColorSeedButton({ + required this.handleColorSelect, + required this.colorSelected, + }); + + final void Function(int) handleColorSelect; + final ColorSeed colorSelected; + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + icon: Icon( + Icons.palette_outlined, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + tooltip: 'Select a seed color', + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + itemBuilder: (context) { + return List.generate(ColorSeed.values.length, (index) { + ColorSeed currentColor = ColorSeed.values[index]; + + return PopupMenuItem( + value: index, + enabled: currentColor != colorSelected, + child: Wrap( + children: [ + Padding( + padding: const EdgeInsets.only(left: 10), + child: Icon( + currentColor == colorSelected + ? Icons.color_lens + : Icons.color_lens_outlined, + color: currentColor.color, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 20), + child: Text(currentColor.label), + ), + ], + ), + ); + }); + }, + onSelected: handleColorSelect, + ); + } +} + +class NavigationTransition extends StatefulWidget { + const NavigationTransition( + {super.key, + required this.scaffoldKey, + required this.animationController, + required this.railAnimation, + required this.navigationRail, + required this.navigationBar, + required this.appBar, + required this.body}); + + final GlobalKey scaffoldKey; + final AnimationController animationController; + final CurvedAnimation railAnimation; + final Widget navigationRail; + final Widget navigationBar; + final PreferredSizeWidget appBar; + final Widget body; + + @override + State createState() => _NavigationTransitionState(); +} + +class _NavigationTransitionState extends State { + late final AnimationController controller; + late final CurvedAnimation railAnimation; + late final ReverseAnimation barAnimation; + bool controllerInitialized = false; + bool showDivider = false; + + @override + void initState() { + super.initState(); + + controller = widget.animationController; + railAnimation = widget.railAnimation; + + barAnimation = ReverseAnimation( + CurvedAnimation( + parent: controller, + curve: const Interval(0.0, 0.5), + ), + ); + } + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + key: widget.scaffoldKey, + appBar: widget.appBar, + body: Row( + children: [ + RailTransition( + animation: railAnimation, + backgroundColor: colorScheme.surface, + child: widget.navigationRail, + ), + widget.body, + ], + ), + bottomNavigationBar: BarTransition( + animation: barAnimation, + backgroundColor: colorScheme.surface, + child: widget.navigationBar, + ), + endDrawer: const NavigationDrawerSection(), + ); + } +} + +final List navRailDestinations = appBarDestinations + .map( + (destination) => NavigationRailDestination( + icon: Tooltip( + message: destination.label, + child: destination.icon, + ), + selectedIcon: Tooltip( + message: destination.label, + child: destination.selectedIcon, + ), + label: Text(destination.label), + ), + ) + .toList(); + +class SizeAnimation extends CurvedAnimation { + SizeAnimation(Animation parent) + : super( + parent: parent, + curve: const Interval( + 0.2, + 0.8, + curve: Curves.easeInOutCubicEmphasized, + ), + reverseCurve: Interval( + 0, + 0.2, + curve: Curves.easeInOutCubicEmphasized.flipped, + ), + ); +} + +class OffsetAnimation extends CurvedAnimation { + OffsetAnimation(Animation parent) + : super( + parent: parent, + curve: const Interval( + 0.4, + 1.0, + curve: Curves.easeInOutCubicEmphasized, + ), + reverseCurve: Interval( + 0, + 0.2, + curve: Curves.easeInOutCubicEmphasized.flipped, + ), + ); +} + +class RailTransition extends StatefulWidget { + const RailTransition( + {super.key, + required this.animation, + required this.backgroundColor, + required this.child}); + + final Animation animation; + final Widget child; + final Color backgroundColor; + + @override + State createState() => _RailTransition(); +} + +class _RailTransition extends State { + late Animation offsetAnimation; + late Animation widthAnimation; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + // The animations are only rebuilt by this method when the text + // direction changes because this widget only depends on Directionality. + final bool ltr = Directionality.of(context) == TextDirection.ltr; + + widthAnimation = Tween( + begin: 0, + end: 1, + ).animate(SizeAnimation(widget.animation)); + + offsetAnimation = Tween( + begin: ltr ? const Offset(-1, 0) : const Offset(1, 0), + end: Offset.zero, + ).animate(OffsetAnimation(widget.animation)); + } + + @override + Widget build(BuildContext context) { + return ClipRect( + child: DecoratedBox( + decoration: BoxDecoration(color: widget.backgroundColor), + child: Align( + alignment: Alignment.topLeft, + widthFactor: widthAnimation.value, + child: FractionalTranslation( + translation: offsetAnimation.value, + child: widget.child, + ), + ), + ), + ); + } +} + +class BarTransition extends StatefulWidget { + const BarTransition( + {super.key, + required this.animation, + required this.backgroundColor, + required this.child}); + + final Animation animation; + final Color backgroundColor; + final Widget child; + + @override + State createState() => _BarTransition(); +} + +class _BarTransition extends State { + late final Animation offsetAnimation; + late final Animation heightAnimation; + + @override + void initState() { + super.initState(); + + offsetAnimation = Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(OffsetAnimation(widget.animation)); + + heightAnimation = Tween( + begin: 0, + end: 1, + ).animate(SizeAnimation(widget.animation)); + } + + @override + Widget build(BuildContext context) { + return ClipRect( + child: DecoratedBox( + decoration: BoxDecoration(color: widget.backgroundColor), + child: Align( + alignment: Alignment.topLeft, + heightFactor: heightAnimation.value, + child: FractionalTranslation( + translation: offsetAnimation.value, + child: widget.child, + ), + ), + ), + ); + } +} + +class OneTwoTransition extends StatefulWidget { + const OneTwoTransition({ + super.key, + required this.animation, + required this.one, + required this.two, + }); + + final Animation animation; + final Widget one; + final Widget two; + + @override + State createState() => _OneTwoTransitionState(); +} + +class _OneTwoTransitionState extends State { + late final Animation offsetAnimation; + late final Animation widthAnimation; + + @override + void initState() { + super.initState(); + + offsetAnimation = Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(OffsetAnimation(widget.animation)); + + widthAnimation = Tween( + begin: 0, + end: mediumWidthBreakpoint, + ).animate(SizeAnimation(widget.animation)); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Flexible( + flex: mediumWidthBreakpoint.toInt(), + child: widget.one, + ), + if (widthAnimation.value.toInt() > 0) ...[ + Flexible( + flex: widthAnimation.value.toInt(), + child: FractionalTranslation( + translation: offsetAnimation.value, + child: widget.two, + ), + ) + ], + ], + ); + } +} diff --git a/experimental/material_3_demo/lib/main.dart b/experimental/material_3_demo/lib/main.dart index b36fd8f10..262201921 100644 --- a/experimental/material_3_demo/lib/main.dart +++ b/experimental/material_3_demo/lib/main.dart @@ -5,69 +5,27 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'color_palettes_screen.dart'; -import 'component_screen.dart'; -import 'elevation_screen.dart'; -import 'typography_screen.dart'; +import 'constants.dart'; +import 'home.dart'; void main() { runApp( - const MaterialApp( - debugShowCheckedModeBanner: false, - home: Material3Demo(), - ), + const App(), ); } -class Material3Demo extends StatefulWidget { - const Material3Demo({super.key}); +class App extends StatefulWidget { + const App({super.key}); @override - State createState() => _Material3DemoState(); -} - -// NavigationRail shows if the screen width is greater or equal to -// screenWidthThreshold; otherwise, NavigationBar is used for navigation. -const double narrowScreenWidthThreshold = 450; - -const double transitionLength = 500; - -enum ColorSeed { - baseColor('M3 Baseline', Color(0xff6750a4)), - indigo('Indigo', Colors.indigo), - blue('Blue', Colors.blue), - teal('Teal', Colors.teal), - green('Green', Colors.green), - yellow('Yellow', Colors.yellow), - orange('Orange', Colors.orange), - deepOrange('Deep Orange', Colors.deepOrange), - pink('Pink', Colors.pink); - - const ColorSeed(this.label, this.color); - final String label; - final Color color; + State createState() => _AppState(); } -enum ScreenSelected { - component(0), - color(1), - typography(2), - elevation(3); - - const ScreenSelected(this.value); - final int value; -} - -class _Material3DemoState extends State - with SingleTickerProviderStateMixin { - final GlobalKey scaffoldKey = GlobalKey(); - late final AnimationController controller; - late final CurvedAnimation railAnimation; - bool controllerInitialized = false; - bool showMediumSizeLayout = false; - bool showLargeSizeLayout = false; +class _AppState extends State { bool useMaterial3 = true; ThemeMode themeMode = ThemeMode.system; + ColorSeed colorSelected = ColorSeed.baseColor; + bool get useLightMode { switch (themeMode) { case ThemeMode.system: @@ -80,67 +38,6 @@ class _Material3DemoState extends State } } - ColorSeed colorSelected = ColorSeed.baseColor; - int screenIndex = ScreenSelected.component.value; - - @override - initState() { - super.initState(); - controller = AnimationController( - duration: Duration(milliseconds: transitionLength.toInt() * 2), - value: 0, - vsync: this, - ); - railAnimation = CurvedAnimation( - parent: controller, - curve: const Interval(0.5, 1.0), - ); - } - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - final double width = MediaQuery.of(context).size.width; - final AnimationStatus status = controller.status; - if (width > 1000) { - if (width > 1500) { - showMediumSizeLayout = false; - showLargeSizeLayout = true; - } else { - showMediumSizeLayout = true; - showLargeSizeLayout = false; - } - if (status != AnimationStatus.forward && - status != AnimationStatus.completed) { - controller.forward(); - } - } else { - showMediumSizeLayout = false; - showLargeSizeLayout = false; - if (status != AnimationStatus.reverse && - status != AnimationStatus.dismissed) { - controller.reverse(); - } - } - if (!controllerInitialized) { - controllerInitialized = true; - controller.value = width > 1000 ? 1 : 0; - } - } - - void handleScreenChanged(int screenSelected) { - setState(() { - screenIndex = screenSelected; - }); - } - void handleBrightnessChange(bool useLightMode) { setState(() { themeMode = useLightMode ? ThemeMode.light : ThemeMode.dark; @@ -159,134 +56,6 @@ class _Material3DemoState extends State }); } - Widget createScreenFor( - ScreenSelected screenSelected, bool showNavBarExample) { - switch (screenSelected) { - case ScreenSelected.component: - return Expanded( - child: OneTwoTransition( - animation: railAnimation, - one: FirstComponentList( - showNavBottomBar: showNavBarExample, - scaffoldKey: scaffoldKey, - showSecondList: showMediumSizeLayout || showLargeSizeLayout), - two: SecondComponentList( - scaffoldKey: scaffoldKey, - ), - ), - ); - case ScreenSelected.color: - return const ColorPalettesScreen(); - case ScreenSelected.typography: - return const TypographyScreen(); - case ScreenSelected.elevation: - return const ElevationScreen(); - default: - return FirstComponentList( - showNavBottomBar: showNavBarExample, - scaffoldKey: scaffoldKey, - showSecondList: showMediumSizeLayout || showLargeSizeLayout); - } - } - - PreferredSizeWidget createAppBar() { - return AppBar( - title: useMaterial3 ? const Text('Material 3') : const Text('Material 2'), - actions: !showMediumSizeLayout && !showLargeSizeLayout - ? [ - _BrightnessButton( - handleBrightnessChange: handleBrightnessChange, - ), - _Material3Button( - handleMaterialVersionChange: handleMaterialVersionChange, - ), - _ColorSeedButton( - handleColorSelect: handleColorSelect, - colorSelected: colorSelected, - ), - ] - : [Container()], - ); - } - - Widget _expandedTrailingActions() => Container( - constraints: const BoxConstraints.tightFor(width: 250), - padding: const EdgeInsets.symmetric(horizontal: 30), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - const Text('Brightness'), - Expanded(child: Container()), - Switch( - value: useLightMode, - onChanged: (value) { - handleBrightnessChange(value); - }) - ], - ), - Row( - children: [ - useMaterial3 - ? const Text('Material 3') - : const Text('Material 2'), - Expanded(child: Container()), - Switch( - value: useMaterial3, - onChanged: (_) { - handleMaterialVersionChange(); - }) - ], - ), - const Divider(), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200.0), - child: GridView.count( - crossAxisCount: 3, - children: List.generate( - ColorSeed.values.length, - (i) => IconButton( - icon: const Icon(Icons.radio_button_unchecked), - color: ColorSeed.values[i].color, - isSelected: - colorSelected.color == ColorSeed.values[i].color, - selectedIcon: const Icon(Icons.circle), - onPressed: () { - handleColorSelect(i); - }, - )), - ), - ), - ], - ), - ); - - Widget _trailingActions() => Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Flexible( - child: _BrightnessButton( - handleBrightnessChange: handleBrightnessChange, - showTooltipBelow: false, - ), - ), - Flexible( - child: _Material3Button( - handleMaterialVersionChange: handleMaterialVersionChange, - showTooltipBelow: false, - ), - ), - Flexible( - child: _ColorSeedButton( - handleColorSelect: handleColorSelect, - colorSelected: colorSelected, - ), - ), - ], - ); - @override Widget build(BuildContext context) { return MaterialApp( @@ -303,435 +72,14 @@ class _Material3DemoState extends State useMaterial3: useMaterial3, brightness: Brightness.dark, ), - home: AnimatedBuilder( - animation: controller, - builder: (context, child) { - return NavigationTransition( - scaffoldKey: scaffoldKey, - animationController: controller, - railAnimation: railAnimation, - appBar: createAppBar(), - body: createScreenFor( - ScreenSelected.values[screenIndex], controller.value == 1), - navigationRail: NavigationRail( - extended: showLargeSizeLayout, - destinations: navRailDestinations, - selectedIndex: screenIndex, - onDestinationSelected: (index) { - setState(() { - screenIndex = index; - handleScreenChanged(screenIndex); - }); - }, - trailing: Expanded( - child: Padding( - padding: const EdgeInsets.only(bottom: 20), - child: showLargeSizeLayout - ? _expandedTrailingActions() - : _trailingActions(), - ), - ), - ), - navigationBar: NavigationBars( - onSelectItem: (index) { - setState(() { - screenIndex = index; - handleScreenChanged(screenIndex); - }); - }, - selectedIndex: screenIndex, - isExampleBar: false, - ), - ); - }, - ), - ); - } -} - -class _BrightnessButton extends StatelessWidget { - const _BrightnessButton({ - required this.handleBrightnessChange, - this.showTooltipBelow = true, - }); - - final Function handleBrightnessChange; - final bool showTooltipBelow; - - @override - Widget build(BuildContext context) { - final isBright = Theme.of(context).brightness == Brightness.light; - return Tooltip( - preferBelow: showTooltipBelow, - message: 'Toggle brightness', - child: IconButton( - icon: isBright - ? const Icon(Icons.dark_mode_outlined) - : const Icon(Icons.light_mode_outlined), - onPressed: () => handleBrightnessChange(!isBright), - ), - ); - } -} - -class _Material3Button extends StatelessWidget { - const _Material3Button({ - required this.handleMaterialVersionChange, - this.showTooltipBelow = true, - }); - - final void Function() handleMaterialVersionChange; - final bool showTooltipBelow; - - @override - Widget build(BuildContext context) { - final useMaterial3 = Theme.of(context).useMaterial3; - return Tooltip( - preferBelow: showTooltipBelow, - message: 'Switch to Material ${useMaterial3 ? 2 : 3}', - child: IconButton( - icon: useMaterial3 - ? const Icon(Icons.filter_2) - : const Icon(Icons.filter_3), - onPressed: handleMaterialVersionChange, - ), - ); - } -} - -class _ColorSeedButton extends StatelessWidget { - const _ColorSeedButton({ - required this.handleColorSelect, - required this.colorSelected, - }); - - final void Function(int) handleColorSelect; - final ColorSeed colorSelected; - - @override - Widget build(BuildContext context) { - return PopupMenuButton( - icon: Icon( - Icons.palette_outlined, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - tooltip: 'Select a seed color', - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - itemBuilder: (context) { - return List.generate(ColorSeed.values.length, (index) { - ColorSeed currentColor = ColorSeed.values[index]; - - return PopupMenuItem( - value: index, - enabled: currentColor != colorSelected, - child: Wrap( - children: [ - Padding( - padding: const EdgeInsets.only(left: 10), - child: Icon( - currentColor == colorSelected - ? Icons.color_lens - : Icons.color_lens_outlined, - color: currentColor.color, - ), - ), - Padding( - padding: const EdgeInsets.only(left: 20), - child: Text(currentColor.label), - ), - ], - ), - ); - }); - }, - onSelected: handleColorSelect, - ); - } -} - -class NavigationTransition extends StatefulWidget { - const NavigationTransition( - {super.key, - required this.scaffoldKey, - required this.animationController, - required this.railAnimation, - required this.navigationRail, - required this.navigationBar, - required this.appBar, - required this.body}); - - final GlobalKey scaffoldKey; - final AnimationController animationController; - final CurvedAnimation railAnimation; - final Widget navigationRail; - final Widget navigationBar; - final PreferredSizeWidget appBar; - final Widget body; - - @override - State createState() => _NavigationTransitionState(); -} - -class _NavigationTransitionState extends State { - late final AnimationController controller; - late final CurvedAnimation railAnimation; - late final ReverseAnimation barAnimation; - bool controllerInitialized = false; - bool showDivider = false; - - @override - void initState() { - super.initState(); - - controller = widget.animationController; - railAnimation = widget.railAnimation; - - barAnimation = ReverseAnimation( - CurvedAnimation( - parent: controller, - curve: const Interval(0.0, 0.5), - ), - ); - } - - @override - Widget build(BuildContext context) { - final ColorScheme colorScheme = Theme.of(context).colorScheme; - - return Scaffold( - key: widget.scaffoldKey, - appBar: widget.appBar, - body: Row( - children: [ - RailTransition( - animation: railAnimation, - backgroundColor: colorScheme.surface, - child: widget.navigationRail, - ), - widget.body, - ], - ), - bottomNavigationBar: BarTransition( - animation: barAnimation, - backgroundColor: colorScheme.surface, - child: widget.navigationBar, - ), - endDrawer: const NavigationDrawerSection(), - ); - } -} - -final List navRailDestinations = appBarDestinations - .map( - (destination) => NavigationRailDestination( - icon: Tooltip( - message: destination.label, - child: destination.icon, - ), - selectedIcon: Tooltip( - message: destination.label, - child: destination.selectedIcon, - ), - label: Text(destination.label), - ), - ) - .toList(); - -class SizeAnimation extends CurvedAnimation { - SizeAnimation(Animation parent) - : super( - parent: parent, - curve: const Interval( - 0.2, - 0.8, - curve: Curves.easeInOutCubicEmphasized, - ), - reverseCurve: Interval( - 0, - 0.2, - curve: Curves.easeInOutCubicEmphasized.flipped, - ), - ); -} - -class OffsetAnimation extends CurvedAnimation { - OffsetAnimation(Animation parent) - : super( - parent: parent, - curve: const Interval( - 0.4, - 1.0, - curve: Curves.easeInOutCubicEmphasized, - ), - reverseCurve: Interval( - 0, - 0.2, - curve: Curves.easeInOutCubicEmphasized.flipped, - ), - ); -} - -class RailTransition extends StatefulWidget { - const RailTransition( - {super.key, - required this.animation, - required this.backgroundColor, - required this.child}); - - final Animation animation; - final Widget child; - final Color backgroundColor; - - @override - State createState() => _RailTransition(); -} - -class _RailTransition extends State { - late Animation offsetAnimation; - late Animation widthAnimation; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - // The animations are only rebuilt by this method when the text - // direction changes because this widget only depends on Directionality. - final bool ltr = Directionality.of(context) == TextDirection.ltr; - - widthAnimation = Tween( - begin: 0, - end: 1, - ).animate(SizeAnimation(widget.animation)); - - offsetAnimation = Tween( - begin: ltr ? const Offset(-1, 0) : const Offset(1, 0), - end: Offset.zero, - ).animate(OffsetAnimation(widget.animation)); - } - - @override - Widget build(BuildContext context) { - return ClipRect( - child: DecoratedBox( - decoration: BoxDecoration(color: widget.backgroundColor), - child: Align( - alignment: Alignment.topLeft, - widthFactor: widthAnimation.value, - child: FractionalTranslation( - translation: offsetAnimation.value, - child: widget.child, - ), - ), - ), - ); - } -} - -class BarTransition extends StatefulWidget { - const BarTransition( - {super.key, - required this.animation, - required this.backgroundColor, - required this.child}); - - final Animation animation; - final Color backgroundColor; - final Widget child; - - @override - State createState() => _BarTransition(); -} - -class _BarTransition extends State { - late final Animation offsetAnimation; - late final Animation heightAnimation; - - @override - void initState() { - super.initState(); - - offsetAnimation = Tween( - begin: const Offset(0, 1), - end: Offset.zero, - ).animate(OffsetAnimation(widget.animation)); - - heightAnimation = Tween( - begin: 0, - end: 1, - ).animate(SizeAnimation(widget.animation)); - } - - @override - Widget build(BuildContext context) { - return ClipRect( - child: DecoratedBox( - decoration: BoxDecoration(color: widget.backgroundColor), - child: Align( - alignment: Alignment.topLeft, - heightFactor: heightAnimation.value, - child: FractionalTranslation( - translation: offsetAnimation.value, - child: widget.child, - ), - ), + home: Home( + useLightMode: useLightMode, + useMaterial3: useMaterial3, + colorSelected: colorSelected, + handleBrightnessChange: handleBrightnessChange, + handleMaterialVersionChange: handleMaterialVersionChange, + handleColorSelect: handleColorSelect, ), ); } } - -class OneTwoTransition extends StatefulWidget { - const OneTwoTransition({ - super.key, - required this.animation, - required this.one, - required this.two, - }); - - final Animation animation; - final Widget one; - final Widget two; - - @override - State createState() => _OneTwoTransitionState(); -} - -class _OneTwoTransitionState extends State { - late final Animation offsetAnimation; - late final Animation widthAnimation; - - @override - void initState() { - super.initState(); - - offsetAnimation = Tween( - begin: const Offset(1, 0), - end: Offset.zero, - ).animate(OffsetAnimation(widget.animation)); - - widthAnimation = Tween( - begin: 0, - end: 1000, - ).animate(SizeAnimation(widget.animation)); - } - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Flexible( - flex: 1000, - child: widget.one, - ), - if (widthAnimation.value.toInt() > 0) ...[ - Flexible( - flex: widthAnimation.value.toInt(), - child: FractionalTranslation( - translation: offsetAnimation.value, - child: widget.two, - ), - ) - ], - ], - ); - } -} diff --git a/experimental/material_3_demo/pubspec.yaml b/experimental/material_3_demo/pubspec.yaml index 09c335396..d4891c1b0 100644 --- a/experimental/material_3_demo/pubspec.yaml +++ b/experimental/material_3_demo/pubspec.yaml @@ -22,6 +22,8 @@ dev_dependencies: sdk: flutter flutter_lints: ^2.0.1 + integration_test: + sdk: flutter flutter: uses-material-design: true diff --git a/experimental/material_3_demo/test/color_screen_test.dart b/experimental/material_3_demo/test/color_screen_test.dart index a29b089f7..64efe5884 100644 --- a/experimental/material_3_demo/test/color_screen_test.dart +++ b/experimental/material_3_demo/test/color_screen_test.dart @@ -16,7 +16,7 @@ void main() { 'on NavigationBar', (tester) async { widgetSetup(tester, 449); addTearDown(tester.binding.window.clearPhysicalSizeTestValue); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); expect(find.text('Light ColorScheme'), findsNothing); expect(find.text('Dark ColorScheme'), findsNothing); @@ -45,7 +45,7 @@ void main() { widgetSetup( tester, 1200); // NavigationRail shows only when width is > 1000. addTearDown(tester.binding.window.clearPhysicalSizeTestValue); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); await tester.pumpAndSettle(); expect(find.text('Light ColorScheme'), findsNothing); expect(find.text('Dark ColorScheme'), findsNothing); diff --git a/experimental/material_3_demo/test/component_screen_test.dart b/experimental/material_3_demo/test/component_screen_test.dart index e5f820995..550547fad 100644 --- a/experimental/material_3_demo/test/component_screen_test.dart +++ b/experimental/material_3_demo/test/component_screen_test.dart @@ -11,7 +11,7 @@ import 'package:material_3_demo/main.dart'; void main() { testWidgets('Default main page shows all M3 components', (tester) async { widgetSetup(tester, 800, windowHeight: 7000); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); // Elements on the app bar expect(find.text('Material 3'), findsOneWidget); @@ -131,7 +131,7 @@ void main() { 'NavigationRail doesn\'t show when width value is small than 1000 ' '(in Portrait mode or narrow screen)', (tester) async { widgetSetup(tester, 999, windowHeight: 7000); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); await tester.pumpAndSettle(); // When screen width is less than 1000, NavigationBar will show. At the same @@ -152,7 +152,7 @@ void main() { 'NavigationRail shows when width value is greater than or equal ' 'to 1000 (in Landscape mode or wider screen)', (tester) async { widgetSetup(tester, 1001, windowHeight: 3000); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); await tester.pumpAndSettle(); // When screen width is greater than or equal to 1000, NavigationRail will show. @@ -178,7 +178,7 @@ void main() { 'Material version switches between Material3 and Material2 when' 'the version icon is clicked', (tester) async { widgetSetup(tester, 450, windowHeight: 7000); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); BuildContext defaultElevatedButton = tester.firstElement(find.byType(ElevatedButton)); BuildContext defaultIconButton = @@ -244,7 +244,7 @@ void main() { testWidgets( 'Other screens become Material2 mode after changing mode from ' 'main screen', (tester) async { - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); Finder appbarM2Icon = find.descendant( of: find.byType(AppBar), matching: find.widgetWithIcon(IconButton, Icons.filter_2)); @@ -279,7 +279,7 @@ void main() { testWidgets( 'Brightness mode switches between dark and light when' 'the brightness icon is clicked', (tester) async { - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); Finder lightIcon = find.descendant( of: find.byType(AppBar), matching: find.widgetWithIcon(IconButton, Icons.light_mode_outlined)); @@ -314,7 +314,7 @@ void main() { (tester) async { Color m3BaseColor = const Color(0xff6750a4); await tester.pumpWidget(Container()); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); await tester.pump(); Finder menuIcon = find.descendant( of: find.byType(AppBar), diff --git a/experimental/material_3_demo/test/elevation_screen_test.dart b/experimental/material_3_demo/test/elevation_screen_test.dart index 737a355a6..95d886e5f 100644 --- a/experimental/material_3_demo/test/elevation_screen_test.dart +++ b/experimental/material_3_demo/test/elevation_screen_test.dart @@ -16,7 +16,7 @@ void main() { 'selected on NavigationBar', (tester) async { widgetSetup(tester, 449); addTearDown(tester.binding.window.clearPhysicalSizeTestValue); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); expect(find.text('Surface Tint Color Only'), findsNothing); expect(find.byType(NavigationBar), findsOneWidget); @@ -41,7 +41,7 @@ void main() { widgetSetup( tester, 1200); // NavigationRail shows only when width is > 1000. addTearDown(tester.binding.window.clearPhysicalSizeTestValue); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); expect(find.text('Surface Tint Color Only'), findsNothing); Finder tintIconOnRail = find.descendant( of: find.byType(NavigationRail), diff --git a/experimental/material_3_demo/test/typography_screen_test.dart b/experimental/material_3_demo/test/typography_screen_test.dart index 234c14b3d..fff11f9ec 100644 --- a/experimental/material_3_demo/test/typography_screen_test.dart +++ b/experimental/material_3_demo/test/typography_screen_test.dart @@ -16,7 +16,7 @@ void main() { 'selected on NavigationBar', (tester) async { widgetSetup(tester, 449); addTearDown(tester.binding.window.clearPhysicalSizeTestValue); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); expect(find.text('Display Large'), findsNothing); expect(find.byType(NavigationBar), findsOneWidget); @@ -40,7 +40,7 @@ void main() { widgetSetup( tester, 1200); // NavigationRail shows only when width is > 1000. addTearDown(tester.binding.window.clearPhysicalSizeTestValue); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); expect(find.text('Display Large'), findsNothing); Finder textIconOnRail = find.descendant( of: find.byType(NavigationRail), diff --git a/material_3_demo/integration_test/integration_test.dart b/material_3_demo/integration_test/integration_test.dart new file mode 100644 index 000000000..a8d59c4e6 --- /dev/null +++ b/material_3_demo/integration_test/integration_test.dart @@ -0,0 +1,15 @@ +// Copyright 2021 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_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:material_3_demo/main.dart' as app; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('verify app can run', (tester) async { + app.main(); + }); +} diff --git a/material_3_demo/lib/component_screen.dart b/material_3_demo/lib/component_screen.dart index ff3c8bd01..826d50873 100644 --- a/material_3_demo/lib/component_screen.dart +++ b/material_3_demo/lib/component_screen.dart @@ -26,25 +26,28 @@ class FirstComponentList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView( - padding: showSecondList - ? const EdgeInsetsDirectional.only(end: smallSpacing) - : EdgeInsets.zero, - children: [ - const Actions(), - colDivider, - const Communication(), - colDivider, - const Containment(), - if (!showSecondList) ...[ - colDivider, - Navigation(scaffoldKey: scaffoldKey), + // Fully traverse this list before moving on. + return FocusTraversalGroup( + child: ListView( + padding: showSecondList + ? const EdgeInsetsDirectional.only(end: smallSpacing) + : EdgeInsets.zero, + children: [ + const Actions(), colDivider, - const Selection(), + const Communication(), colDivider, - const TextInputs() + const Containment(), + if (!showSecondList) ...[ + colDivider, + Navigation(scaffoldKey: scaffoldKey), + colDivider, + const Selection(), + colDivider, + const TextInputs() + ], ], - ], + ), ); } } @@ -59,15 +62,18 @@ class SecondComponentList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsetsDirectional.only(end: smallSpacing), - children: [ - Navigation(scaffoldKey: scaffoldKey), - colDivider, - const Selection(), - colDivider, - const TextInputs(), - ], + // Fully traverse this list before moving on. + return FocusTraversalGroup( + child: ListView( + padding: const EdgeInsetsDirectional.only(end: smallSpacing), + children: [ + Navigation(scaffoldKey: scaffoldKey), + colDivider, + const Selection(), + colDivider, + const TextInputs(), + ], + ), ); } } @@ -1011,13 +1017,13 @@ class NavigationBars extends StatefulWidget { this.onSelectItem, required this.selectedIndex, required this.isExampleBar, - this.isBadgeExample, + this.isBadgeExample = false, }); final void Function(int)? onSelectItem; final int selectedIndex; final bool isExampleBar; - final bool? isBadgeExample; + final bool isBadgeExample; @override State createState() => _NavigationBarsState(); @@ -1042,23 +1048,26 @@ class _NavigationBarsState extends State { @override Widget build(BuildContext context) { - bool isBadgeExample = widget.isBadgeExample ?? false; - Widget navigationBar = NavigationBar( - selectedIndex: selectedIndex, - onDestinationSelected: (index) { - setState(() { - selectedIndex = index; - }); - if (!widget.isExampleBar) widget.onSelectItem!(index); - }, - destinations: widget.isExampleBar && isBadgeExample - ? barWithBadgeDestinations - : widget.isExampleBar - ? exampleBarDestinations - : appBarDestinations, + // App NavigationBar should get first focus. + Widget navigationBar = Focus( + autofocus: !(widget.isExampleBar || widget.isBadgeExample), + child: NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: (index) { + setState(() { + selectedIndex = index; + }); + if (!widget.isExampleBar) widget.onSelectItem!(index); + }, + destinations: widget.isExampleBar && widget.isBadgeExample + ? barWithBadgeDestinations + : widget.isExampleBar + ? exampleBarDestinations + : appBarDestinations, + ), ); - if (widget.isExampleBar && isBadgeExample) { + if (widget.isExampleBar && widget.isBadgeExample) { navigationBar = ComponentDecoration( label: 'Badges', tooltipMessage: 'Use Badge or Badge.count', @@ -2188,7 +2197,7 @@ class _SlidersState extends State { } } -class ComponentDecoration extends StatelessWidget { +class ComponentDecoration extends StatefulWidget { const ComponentDecoration({ super.key, required this.label, @@ -2200,6 +2209,13 @@ class ComponentDecoration extends StatelessWidget { final Widget child; final String? tooltipMessage; + @override + State createState() => _ComponentDecorationState(); +} + +class _ComponentDecorationState extends State { + final focusNode = FocusNode(); + @override Widget build(BuildContext context) { return RepaintBoundary( @@ -2210,9 +2226,10 @@ class ComponentDecoration extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(label, style: Theme.of(context).textTheme.titleSmall), + Text(widget.label, + style: Theme.of(context).textTheme.titleSmall), Tooltip( - message: tooltipMessage, + message: widget.tooltipMessage, child: const Padding( padding: EdgeInsets.symmetric(horizontal: 5.0), child: Icon(Icons.info_outline, size: 16)), @@ -2222,18 +2239,32 @@ class ComponentDecoration extends StatelessWidget { ConstrainedBox( constraints: const BoxConstraints.tightFor(width: widthConstraint), - child: Card( - elevation: 0, - shape: RoundedRectangleBorder( - side: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, + // Tapping within the a component card should request focus + // for that component's children. + child: Focus( + focusNode: focusNode, + canRequestFocus: true, + child: GestureDetector( + onTapDown: (_) { + focusNode.requestFocus(); + }, + behavior: HitTestBehavior.opaque, + child: Card( + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 5.0, vertical: 20.0), + child: Center( + child: widget.child, + ), + ), ), - borderRadius: const BorderRadius.all(Radius.circular(12)), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 5.0, vertical: 20.0), - child: Center(child: child), ), ), ), @@ -2253,19 +2284,22 @@ class ComponentGroupDecoration extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - margin: EdgeInsets.zero, - elevation: 0, - color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 20.0), - child: Center( - child: Column( - children: [ - Text(label, style: Theme.of(context).textTheme.titleLarge), - colDivider, - ...children - ], + // Fully traverse this component group before moving on + return FocusTraversalGroup( + child: Card( + margin: EdgeInsets.zero, + elevation: 0, + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0), + child: Center( + child: Column( + children: [ + Text(label, style: Theme.of(context).textTheme.titleLarge), + colDivider, + ...children + ], + ), ), ), ), diff --git a/material_3_demo/lib/constants.dart b/material_3_demo/lib/constants.dart new file mode 100644 index 000000000..943b0ca18 --- /dev/null +++ b/material_3_demo/lib/constants.dart @@ -0,0 +1,40 @@ +// Copyright 2021 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'; + +// NavigationRail shows if the screen width is greater or equal to +// narrowScreenWidthThreshold; otherwise, NavigationBar is used for navigation. +const double narrowScreenWidthThreshold = 450; + +const double mediumWidthBreakpoint = 1000; +const double largeWidthBreakpoint = 1500; + +const double transitionLength = 500; + +enum ColorSeed { + baseColor('M3 Baseline', Color(0xff6750a4)), + indigo('Indigo', Colors.indigo), + blue('Blue', Colors.blue), + teal('Teal', Colors.teal), + green('Green', Colors.green), + yellow('Yellow', Colors.yellow), + orange('Orange', Colors.orange), + deepOrange('Deep Orange', Colors.deepOrange), + pink('Pink', Colors.pink); + + const ColorSeed(this.label, this.color); + final String label; + final Color color; +} + +enum ScreenSelected { + component(0), + color(1), + typography(2), + elevation(3); + + const ScreenSelected(this.value); + final int value; +} diff --git a/material_3_demo/lib/home.dart b/material_3_demo/lib/home.dart new file mode 100644 index 000000000..b50e6573d --- /dev/null +++ b/material_3_demo/lib/home.dart @@ -0,0 +1,665 @@ +// Copyright 2021 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 'color_palettes_screen.dart'; +import 'component_screen.dart'; +import 'constants.dart'; +import 'elevation_screen.dart'; +import 'typography_screen.dart'; + +class Home extends StatefulWidget { + const Home({ + super.key, + required this.useLightMode, + required this.useMaterial3, + required this.colorSelected, + required this.handleBrightnessChange, + required this.handleMaterialVersionChange, + required this.handleColorSelect, + }); + + final bool useLightMode; + final bool useMaterial3; + final ColorSeed colorSelected; + final void Function(bool useLightMode) handleBrightnessChange; + final void Function() handleMaterialVersionChange; + final void Function(int value) handleColorSelect; + + @override + State createState() => _HomeState(); +} + +class _HomeState extends State with SingleTickerProviderStateMixin { + final GlobalKey scaffoldKey = GlobalKey(); + late final AnimationController controller; + late final CurvedAnimation railAnimation; + bool controllerInitialized = false; + bool showMediumSizeLayout = false; + bool showLargeSizeLayout = false; + + int screenIndex = ScreenSelected.component.value; + + @override + initState() { + super.initState(); + controller = AnimationController( + duration: Duration(milliseconds: transitionLength.toInt() * 2), + value: 0, + vsync: this, + ); + railAnimation = CurvedAnimation( + parent: controller, + curve: const Interval(0.5, 1.0), + ); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final double width = MediaQuery.of(context).size.width; + final AnimationStatus status = controller.status; + if (width > mediumWidthBreakpoint) { + if (width > largeWidthBreakpoint) { + showMediumSizeLayout = false; + showLargeSizeLayout = true; + } else { + showMediumSizeLayout = true; + showLargeSizeLayout = false; + } + if (status != AnimationStatus.forward && + status != AnimationStatus.completed) { + controller.forward(); + } + } else { + showMediumSizeLayout = false; + showLargeSizeLayout = false; + if (status != AnimationStatus.reverse && + status != AnimationStatus.dismissed) { + controller.reverse(); + } + } + if (!controllerInitialized) { + controllerInitialized = true; + controller.value = width > mediumWidthBreakpoint ? 1 : 0; + } + } + + void handleScreenChanged(int screenSelected) { + setState(() { + screenIndex = screenSelected; + }); + } + + Widget createScreenFor( + ScreenSelected screenSelected, bool showNavBarExample) { + switch (screenSelected) { + case ScreenSelected.component: + return Expanded( + child: OneTwoTransition( + animation: railAnimation, + one: FirstComponentList( + showNavBottomBar: showNavBarExample, + scaffoldKey: scaffoldKey, + showSecondList: showMediumSizeLayout || showLargeSizeLayout), + two: SecondComponentList( + scaffoldKey: scaffoldKey, + ), + ), + ); + case ScreenSelected.color: + return const ColorPalettesScreen(); + case ScreenSelected.typography: + return const TypographyScreen(); + case ScreenSelected.elevation: + return const ElevationScreen(); + default: + return FirstComponentList( + showNavBottomBar: showNavBarExample, + scaffoldKey: scaffoldKey, + showSecondList: showMediumSizeLayout || showLargeSizeLayout); + } + } + + PreferredSizeWidget createAppBar() { + return AppBar( + title: widget.useMaterial3 + ? const Text('Material 3') + : const Text('Material 2'), + actions: !showMediumSizeLayout && !showLargeSizeLayout + ? [ + _BrightnessButton( + handleBrightnessChange: widget.handleBrightnessChange, + ), + _Material3Button( + handleMaterialVersionChange: widget.handleMaterialVersionChange, + ), + _ColorSeedButton( + handleColorSelect: widget.handleColorSelect, + colorSelected: widget.colorSelected, + ), + ] + : [Container()], + ); + } + + Widget _expandedTrailingActions() => Container( + constraints: const BoxConstraints.tightFor(width: 250), + padding: const EdgeInsets.symmetric(horizontal: 30), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Text('Brightness'), + Expanded(child: Container()), + Switch( + value: widget.useLightMode, + onChanged: (value) { + widget.handleBrightnessChange(value); + }) + ], + ), + Row( + children: [ + widget.useMaterial3 + ? const Text('Material 3') + : const Text('Material 2'), + Expanded(child: Container()), + Switch( + value: widget.useMaterial3, + onChanged: (_) { + widget.handleMaterialVersionChange(); + }) + ], + ), + const Divider(), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200.0), + child: GridView.count( + crossAxisCount: 3, + children: List.generate( + ColorSeed.values.length, + (i) => IconButton( + icon: const Icon(Icons.radio_button_unchecked), + color: ColorSeed.values[i].color, + isSelected: widget.colorSelected.color == + ColorSeed.values[i].color, + selectedIcon: const Icon(Icons.circle), + onPressed: () { + widget.handleColorSelect(i); + }, + )), + ), + ), + ], + ), + ); + + Widget _trailingActions() => Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: _BrightnessButton( + handleBrightnessChange: widget.handleBrightnessChange, + showTooltipBelow: false, + ), + ), + Flexible( + child: _Material3Button( + handleMaterialVersionChange: widget.handleMaterialVersionChange, + showTooltipBelow: false, + ), + ), + Flexible( + child: _ColorSeedButton( + handleColorSelect: widget.handleColorSelect, + colorSelected: widget.colorSelected, + ), + ), + ], + ); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, child) { + return NavigationTransition( + scaffoldKey: scaffoldKey, + animationController: controller, + railAnimation: railAnimation, + appBar: createAppBar(), + body: createScreenFor( + ScreenSelected.values[screenIndex], controller.value == 1), + navigationRail: NavigationRail( + extended: showLargeSizeLayout, + destinations: navRailDestinations, + selectedIndex: screenIndex, + onDestinationSelected: (index) { + setState(() { + screenIndex = index; + handleScreenChanged(screenIndex); + }); + }, + trailing: Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 20), + child: showLargeSizeLayout + ? _expandedTrailingActions() + : _trailingActions(), + ), + ), + ), + navigationBar: NavigationBars( + onSelectItem: (index) { + setState(() { + screenIndex = index; + handleScreenChanged(screenIndex); + }); + }, + selectedIndex: screenIndex, + isExampleBar: false, + ), + ); + }, + ); + } +} + +class _BrightnessButton extends StatelessWidget { + const _BrightnessButton({ + required this.handleBrightnessChange, + this.showTooltipBelow = true, + }); + + final Function handleBrightnessChange; + final bool showTooltipBelow; + + @override + Widget build(BuildContext context) { + final isBright = Theme.of(context).brightness == Brightness.light; + return Tooltip( + preferBelow: showTooltipBelow, + message: 'Toggle brightness', + child: IconButton( + icon: isBright + ? const Icon(Icons.dark_mode_outlined) + : const Icon(Icons.light_mode_outlined), + onPressed: () => handleBrightnessChange(!isBright), + ), + ); + } +} + +class _Material3Button extends StatelessWidget { + const _Material3Button({ + required this.handleMaterialVersionChange, + this.showTooltipBelow = true, + }); + + final void Function() handleMaterialVersionChange; + final bool showTooltipBelow; + + @override + Widget build(BuildContext context) { + final useMaterial3 = Theme.of(context).useMaterial3; + return Tooltip( + preferBelow: showTooltipBelow, + message: 'Switch to Material ${useMaterial3 ? 2 : 3}', + child: IconButton( + icon: useMaterial3 + ? const Icon(Icons.filter_2) + : const Icon(Icons.filter_3), + onPressed: handleMaterialVersionChange, + ), + ); + } +} + +class _ColorSeedButton extends StatelessWidget { + const _ColorSeedButton({ + required this.handleColorSelect, + required this.colorSelected, + }); + + final void Function(int) handleColorSelect; + final ColorSeed colorSelected; + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + icon: Icon( + Icons.palette_outlined, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + tooltip: 'Select a seed color', + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + itemBuilder: (context) { + return List.generate(ColorSeed.values.length, (index) { + ColorSeed currentColor = ColorSeed.values[index]; + + return PopupMenuItem( + value: index, + enabled: currentColor != colorSelected, + child: Wrap( + children: [ + Padding( + padding: const EdgeInsets.only(left: 10), + child: Icon( + currentColor == colorSelected + ? Icons.color_lens + : Icons.color_lens_outlined, + color: currentColor.color, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 20), + child: Text(currentColor.label), + ), + ], + ), + ); + }); + }, + onSelected: handleColorSelect, + ); + } +} + +class NavigationTransition extends StatefulWidget { + const NavigationTransition( + {super.key, + required this.scaffoldKey, + required this.animationController, + required this.railAnimation, + required this.navigationRail, + required this.navigationBar, + required this.appBar, + required this.body}); + + final GlobalKey scaffoldKey; + final AnimationController animationController; + final CurvedAnimation railAnimation; + final Widget navigationRail; + final Widget navigationBar; + final PreferredSizeWidget appBar; + final Widget body; + + @override + State createState() => _NavigationTransitionState(); +} + +class _NavigationTransitionState extends State { + late final AnimationController controller; + late final CurvedAnimation railAnimation; + late final ReverseAnimation barAnimation; + bool controllerInitialized = false; + bool showDivider = false; + + @override + void initState() { + super.initState(); + + controller = widget.animationController; + railAnimation = widget.railAnimation; + + barAnimation = ReverseAnimation( + CurvedAnimation( + parent: controller, + curve: const Interval(0.0, 0.5), + ), + ); + } + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + key: widget.scaffoldKey, + appBar: widget.appBar, + body: Row( + children: [ + RailTransition( + animation: railAnimation, + backgroundColor: colorScheme.surface, + child: widget.navigationRail, + ), + widget.body, + ], + ), + bottomNavigationBar: BarTransition( + animation: barAnimation, + backgroundColor: colorScheme.surface, + child: widget.navigationBar, + ), + endDrawer: const NavigationDrawerSection(), + ); + } +} + +final List navRailDestinations = appBarDestinations + .map( + (destination) => NavigationRailDestination( + icon: Tooltip( + message: destination.label, + child: destination.icon, + ), + selectedIcon: Tooltip( + message: destination.label, + child: destination.selectedIcon, + ), + label: Text(destination.label), + ), + ) + .toList(); + +class SizeAnimation extends CurvedAnimation { + SizeAnimation(Animation parent) + : super( + parent: parent, + curve: const Interval( + 0.2, + 0.8, + curve: Curves.easeInOutCubicEmphasized, + ), + reverseCurve: Interval( + 0, + 0.2, + curve: Curves.easeInOutCubicEmphasized.flipped, + ), + ); +} + +class OffsetAnimation extends CurvedAnimation { + OffsetAnimation(Animation parent) + : super( + parent: parent, + curve: const Interval( + 0.4, + 1.0, + curve: Curves.easeInOutCubicEmphasized, + ), + reverseCurve: Interval( + 0, + 0.2, + curve: Curves.easeInOutCubicEmphasized.flipped, + ), + ); +} + +class RailTransition extends StatefulWidget { + const RailTransition( + {super.key, + required this.animation, + required this.backgroundColor, + required this.child}); + + final Animation animation; + final Widget child; + final Color backgroundColor; + + @override + State createState() => _RailTransition(); +} + +class _RailTransition extends State { + late Animation offsetAnimation; + late Animation widthAnimation; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + // The animations are only rebuilt by this method when the text + // direction changes because this widget only depends on Directionality. + final bool ltr = Directionality.of(context) == TextDirection.ltr; + + widthAnimation = Tween( + begin: 0, + end: 1, + ).animate(SizeAnimation(widget.animation)); + + offsetAnimation = Tween( + begin: ltr ? const Offset(-1, 0) : const Offset(1, 0), + end: Offset.zero, + ).animate(OffsetAnimation(widget.animation)); + } + + @override + Widget build(BuildContext context) { + return ClipRect( + child: DecoratedBox( + decoration: BoxDecoration(color: widget.backgroundColor), + child: Align( + alignment: Alignment.topLeft, + widthFactor: widthAnimation.value, + child: FractionalTranslation( + translation: offsetAnimation.value, + child: widget.child, + ), + ), + ), + ); + } +} + +class BarTransition extends StatefulWidget { + const BarTransition( + {super.key, + required this.animation, + required this.backgroundColor, + required this.child}); + + final Animation animation; + final Color backgroundColor; + final Widget child; + + @override + State createState() => _BarTransition(); +} + +class _BarTransition extends State { + late final Animation offsetAnimation; + late final Animation heightAnimation; + + @override + void initState() { + super.initState(); + + offsetAnimation = Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(OffsetAnimation(widget.animation)); + + heightAnimation = Tween( + begin: 0, + end: 1, + ).animate(SizeAnimation(widget.animation)); + } + + @override + Widget build(BuildContext context) { + return ClipRect( + child: DecoratedBox( + decoration: BoxDecoration(color: widget.backgroundColor), + child: Align( + alignment: Alignment.topLeft, + heightFactor: heightAnimation.value, + child: FractionalTranslation( + translation: offsetAnimation.value, + child: widget.child, + ), + ), + ), + ); + } +} + +class OneTwoTransition extends StatefulWidget { + const OneTwoTransition({ + super.key, + required this.animation, + required this.one, + required this.two, + }); + + final Animation animation; + final Widget one; + final Widget two; + + @override + State createState() => _OneTwoTransitionState(); +} + +class _OneTwoTransitionState extends State { + late final Animation offsetAnimation; + late final Animation widthAnimation; + + @override + void initState() { + super.initState(); + + offsetAnimation = Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(OffsetAnimation(widget.animation)); + + widthAnimation = Tween( + begin: 0, + end: mediumWidthBreakpoint, + ).animate(SizeAnimation(widget.animation)); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Flexible( + flex: mediumWidthBreakpoint.toInt(), + child: widget.one, + ), + if (widthAnimation.value.toInt() > 0) ...[ + Flexible( + flex: widthAnimation.value.toInt(), + child: FractionalTranslation( + translation: offsetAnimation.value, + child: widget.two, + ), + ) + ], + ], + ); + } +} diff --git a/material_3_demo/lib/main.dart b/material_3_demo/lib/main.dart index b36fd8f10..262201921 100644 --- a/material_3_demo/lib/main.dart +++ b/material_3_demo/lib/main.dart @@ -5,69 +5,27 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'color_palettes_screen.dart'; -import 'component_screen.dart'; -import 'elevation_screen.dart'; -import 'typography_screen.dart'; +import 'constants.dart'; +import 'home.dart'; void main() { runApp( - const MaterialApp( - debugShowCheckedModeBanner: false, - home: Material3Demo(), - ), + const App(), ); } -class Material3Demo extends StatefulWidget { - const Material3Demo({super.key}); +class App extends StatefulWidget { + const App({super.key}); @override - State createState() => _Material3DemoState(); -} - -// NavigationRail shows if the screen width is greater or equal to -// screenWidthThreshold; otherwise, NavigationBar is used for navigation. -const double narrowScreenWidthThreshold = 450; - -const double transitionLength = 500; - -enum ColorSeed { - baseColor('M3 Baseline', Color(0xff6750a4)), - indigo('Indigo', Colors.indigo), - blue('Blue', Colors.blue), - teal('Teal', Colors.teal), - green('Green', Colors.green), - yellow('Yellow', Colors.yellow), - orange('Orange', Colors.orange), - deepOrange('Deep Orange', Colors.deepOrange), - pink('Pink', Colors.pink); - - const ColorSeed(this.label, this.color); - final String label; - final Color color; + State createState() => _AppState(); } -enum ScreenSelected { - component(0), - color(1), - typography(2), - elevation(3); - - const ScreenSelected(this.value); - final int value; -} - -class _Material3DemoState extends State - with SingleTickerProviderStateMixin { - final GlobalKey scaffoldKey = GlobalKey(); - late final AnimationController controller; - late final CurvedAnimation railAnimation; - bool controllerInitialized = false; - bool showMediumSizeLayout = false; - bool showLargeSizeLayout = false; +class _AppState extends State { bool useMaterial3 = true; ThemeMode themeMode = ThemeMode.system; + ColorSeed colorSelected = ColorSeed.baseColor; + bool get useLightMode { switch (themeMode) { case ThemeMode.system: @@ -80,67 +38,6 @@ class _Material3DemoState extends State } } - ColorSeed colorSelected = ColorSeed.baseColor; - int screenIndex = ScreenSelected.component.value; - - @override - initState() { - super.initState(); - controller = AnimationController( - duration: Duration(milliseconds: transitionLength.toInt() * 2), - value: 0, - vsync: this, - ); - railAnimation = CurvedAnimation( - parent: controller, - curve: const Interval(0.5, 1.0), - ); - } - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - final double width = MediaQuery.of(context).size.width; - final AnimationStatus status = controller.status; - if (width > 1000) { - if (width > 1500) { - showMediumSizeLayout = false; - showLargeSizeLayout = true; - } else { - showMediumSizeLayout = true; - showLargeSizeLayout = false; - } - if (status != AnimationStatus.forward && - status != AnimationStatus.completed) { - controller.forward(); - } - } else { - showMediumSizeLayout = false; - showLargeSizeLayout = false; - if (status != AnimationStatus.reverse && - status != AnimationStatus.dismissed) { - controller.reverse(); - } - } - if (!controllerInitialized) { - controllerInitialized = true; - controller.value = width > 1000 ? 1 : 0; - } - } - - void handleScreenChanged(int screenSelected) { - setState(() { - screenIndex = screenSelected; - }); - } - void handleBrightnessChange(bool useLightMode) { setState(() { themeMode = useLightMode ? ThemeMode.light : ThemeMode.dark; @@ -159,134 +56,6 @@ class _Material3DemoState extends State }); } - Widget createScreenFor( - ScreenSelected screenSelected, bool showNavBarExample) { - switch (screenSelected) { - case ScreenSelected.component: - return Expanded( - child: OneTwoTransition( - animation: railAnimation, - one: FirstComponentList( - showNavBottomBar: showNavBarExample, - scaffoldKey: scaffoldKey, - showSecondList: showMediumSizeLayout || showLargeSizeLayout), - two: SecondComponentList( - scaffoldKey: scaffoldKey, - ), - ), - ); - case ScreenSelected.color: - return const ColorPalettesScreen(); - case ScreenSelected.typography: - return const TypographyScreen(); - case ScreenSelected.elevation: - return const ElevationScreen(); - default: - return FirstComponentList( - showNavBottomBar: showNavBarExample, - scaffoldKey: scaffoldKey, - showSecondList: showMediumSizeLayout || showLargeSizeLayout); - } - } - - PreferredSizeWidget createAppBar() { - return AppBar( - title: useMaterial3 ? const Text('Material 3') : const Text('Material 2'), - actions: !showMediumSizeLayout && !showLargeSizeLayout - ? [ - _BrightnessButton( - handleBrightnessChange: handleBrightnessChange, - ), - _Material3Button( - handleMaterialVersionChange: handleMaterialVersionChange, - ), - _ColorSeedButton( - handleColorSelect: handleColorSelect, - colorSelected: colorSelected, - ), - ] - : [Container()], - ); - } - - Widget _expandedTrailingActions() => Container( - constraints: const BoxConstraints.tightFor(width: 250), - padding: const EdgeInsets.symmetric(horizontal: 30), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - const Text('Brightness'), - Expanded(child: Container()), - Switch( - value: useLightMode, - onChanged: (value) { - handleBrightnessChange(value); - }) - ], - ), - Row( - children: [ - useMaterial3 - ? const Text('Material 3') - : const Text('Material 2'), - Expanded(child: Container()), - Switch( - value: useMaterial3, - onChanged: (_) { - handleMaterialVersionChange(); - }) - ], - ), - const Divider(), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200.0), - child: GridView.count( - crossAxisCount: 3, - children: List.generate( - ColorSeed.values.length, - (i) => IconButton( - icon: const Icon(Icons.radio_button_unchecked), - color: ColorSeed.values[i].color, - isSelected: - colorSelected.color == ColorSeed.values[i].color, - selectedIcon: const Icon(Icons.circle), - onPressed: () { - handleColorSelect(i); - }, - )), - ), - ), - ], - ), - ); - - Widget _trailingActions() => Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Flexible( - child: _BrightnessButton( - handleBrightnessChange: handleBrightnessChange, - showTooltipBelow: false, - ), - ), - Flexible( - child: _Material3Button( - handleMaterialVersionChange: handleMaterialVersionChange, - showTooltipBelow: false, - ), - ), - Flexible( - child: _ColorSeedButton( - handleColorSelect: handleColorSelect, - colorSelected: colorSelected, - ), - ), - ], - ); - @override Widget build(BuildContext context) { return MaterialApp( @@ -303,435 +72,14 @@ class _Material3DemoState extends State useMaterial3: useMaterial3, brightness: Brightness.dark, ), - home: AnimatedBuilder( - animation: controller, - builder: (context, child) { - return NavigationTransition( - scaffoldKey: scaffoldKey, - animationController: controller, - railAnimation: railAnimation, - appBar: createAppBar(), - body: createScreenFor( - ScreenSelected.values[screenIndex], controller.value == 1), - navigationRail: NavigationRail( - extended: showLargeSizeLayout, - destinations: navRailDestinations, - selectedIndex: screenIndex, - onDestinationSelected: (index) { - setState(() { - screenIndex = index; - handleScreenChanged(screenIndex); - }); - }, - trailing: Expanded( - child: Padding( - padding: const EdgeInsets.only(bottom: 20), - child: showLargeSizeLayout - ? _expandedTrailingActions() - : _trailingActions(), - ), - ), - ), - navigationBar: NavigationBars( - onSelectItem: (index) { - setState(() { - screenIndex = index; - handleScreenChanged(screenIndex); - }); - }, - selectedIndex: screenIndex, - isExampleBar: false, - ), - ); - }, - ), - ); - } -} - -class _BrightnessButton extends StatelessWidget { - const _BrightnessButton({ - required this.handleBrightnessChange, - this.showTooltipBelow = true, - }); - - final Function handleBrightnessChange; - final bool showTooltipBelow; - - @override - Widget build(BuildContext context) { - final isBright = Theme.of(context).brightness == Brightness.light; - return Tooltip( - preferBelow: showTooltipBelow, - message: 'Toggle brightness', - child: IconButton( - icon: isBright - ? const Icon(Icons.dark_mode_outlined) - : const Icon(Icons.light_mode_outlined), - onPressed: () => handleBrightnessChange(!isBright), - ), - ); - } -} - -class _Material3Button extends StatelessWidget { - const _Material3Button({ - required this.handleMaterialVersionChange, - this.showTooltipBelow = true, - }); - - final void Function() handleMaterialVersionChange; - final bool showTooltipBelow; - - @override - Widget build(BuildContext context) { - final useMaterial3 = Theme.of(context).useMaterial3; - return Tooltip( - preferBelow: showTooltipBelow, - message: 'Switch to Material ${useMaterial3 ? 2 : 3}', - child: IconButton( - icon: useMaterial3 - ? const Icon(Icons.filter_2) - : const Icon(Icons.filter_3), - onPressed: handleMaterialVersionChange, - ), - ); - } -} - -class _ColorSeedButton extends StatelessWidget { - const _ColorSeedButton({ - required this.handleColorSelect, - required this.colorSelected, - }); - - final void Function(int) handleColorSelect; - final ColorSeed colorSelected; - - @override - Widget build(BuildContext context) { - return PopupMenuButton( - icon: Icon( - Icons.palette_outlined, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - tooltip: 'Select a seed color', - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - itemBuilder: (context) { - return List.generate(ColorSeed.values.length, (index) { - ColorSeed currentColor = ColorSeed.values[index]; - - return PopupMenuItem( - value: index, - enabled: currentColor != colorSelected, - child: Wrap( - children: [ - Padding( - padding: const EdgeInsets.only(left: 10), - child: Icon( - currentColor == colorSelected - ? Icons.color_lens - : Icons.color_lens_outlined, - color: currentColor.color, - ), - ), - Padding( - padding: const EdgeInsets.only(left: 20), - child: Text(currentColor.label), - ), - ], - ), - ); - }); - }, - onSelected: handleColorSelect, - ); - } -} - -class NavigationTransition extends StatefulWidget { - const NavigationTransition( - {super.key, - required this.scaffoldKey, - required this.animationController, - required this.railAnimation, - required this.navigationRail, - required this.navigationBar, - required this.appBar, - required this.body}); - - final GlobalKey scaffoldKey; - final AnimationController animationController; - final CurvedAnimation railAnimation; - final Widget navigationRail; - final Widget navigationBar; - final PreferredSizeWidget appBar; - final Widget body; - - @override - State createState() => _NavigationTransitionState(); -} - -class _NavigationTransitionState extends State { - late final AnimationController controller; - late final CurvedAnimation railAnimation; - late final ReverseAnimation barAnimation; - bool controllerInitialized = false; - bool showDivider = false; - - @override - void initState() { - super.initState(); - - controller = widget.animationController; - railAnimation = widget.railAnimation; - - barAnimation = ReverseAnimation( - CurvedAnimation( - parent: controller, - curve: const Interval(0.0, 0.5), - ), - ); - } - - @override - Widget build(BuildContext context) { - final ColorScheme colorScheme = Theme.of(context).colorScheme; - - return Scaffold( - key: widget.scaffoldKey, - appBar: widget.appBar, - body: Row( - children: [ - RailTransition( - animation: railAnimation, - backgroundColor: colorScheme.surface, - child: widget.navigationRail, - ), - widget.body, - ], - ), - bottomNavigationBar: BarTransition( - animation: barAnimation, - backgroundColor: colorScheme.surface, - child: widget.navigationBar, - ), - endDrawer: const NavigationDrawerSection(), - ); - } -} - -final List navRailDestinations = appBarDestinations - .map( - (destination) => NavigationRailDestination( - icon: Tooltip( - message: destination.label, - child: destination.icon, - ), - selectedIcon: Tooltip( - message: destination.label, - child: destination.selectedIcon, - ), - label: Text(destination.label), - ), - ) - .toList(); - -class SizeAnimation extends CurvedAnimation { - SizeAnimation(Animation parent) - : super( - parent: parent, - curve: const Interval( - 0.2, - 0.8, - curve: Curves.easeInOutCubicEmphasized, - ), - reverseCurve: Interval( - 0, - 0.2, - curve: Curves.easeInOutCubicEmphasized.flipped, - ), - ); -} - -class OffsetAnimation extends CurvedAnimation { - OffsetAnimation(Animation parent) - : super( - parent: parent, - curve: const Interval( - 0.4, - 1.0, - curve: Curves.easeInOutCubicEmphasized, - ), - reverseCurve: Interval( - 0, - 0.2, - curve: Curves.easeInOutCubicEmphasized.flipped, - ), - ); -} - -class RailTransition extends StatefulWidget { - const RailTransition( - {super.key, - required this.animation, - required this.backgroundColor, - required this.child}); - - final Animation animation; - final Widget child; - final Color backgroundColor; - - @override - State createState() => _RailTransition(); -} - -class _RailTransition extends State { - late Animation offsetAnimation; - late Animation widthAnimation; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - // The animations are only rebuilt by this method when the text - // direction changes because this widget only depends on Directionality. - final bool ltr = Directionality.of(context) == TextDirection.ltr; - - widthAnimation = Tween( - begin: 0, - end: 1, - ).animate(SizeAnimation(widget.animation)); - - offsetAnimation = Tween( - begin: ltr ? const Offset(-1, 0) : const Offset(1, 0), - end: Offset.zero, - ).animate(OffsetAnimation(widget.animation)); - } - - @override - Widget build(BuildContext context) { - return ClipRect( - child: DecoratedBox( - decoration: BoxDecoration(color: widget.backgroundColor), - child: Align( - alignment: Alignment.topLeft, - widthFactor: widthAnimation.value, - child: FractionalTranslation( - translation: offsetAnimation.value, - child: widget.child, - ), - ), - ), - ); - } -} - -class BarTransition extends StatefulWidget { - const BarTransition( - {super.key, - required this.animation, - required this.backgroundColor, - required this.child}); - - final Animation animation; - final Color backgroundColor; - final Widget child; - - @override - State createState() => _BarTransition(); -} - -class _BarTransition extends State { - late final Animation offsetAnimation; - late final Animation heightAnimation; - - @override - void initState() { - super.initState(); - - offsetAnimation = Tween( - begin: const Offset(0, 1), - end: Offset.zero, - ).animate(OffsetAnimation(widget.animation)); - - heightAnimation = Tween( - begin: 0, - end: 1, - ).animate(SizeAnimation(widget.animation)); - } - - @override - Widget build(BuildContext context) { - return ClipRect( - child: DecoratedBox( - decoration: BoxDecoration(color: widget.backgroundColor), - child: Align( - alignment: Alignment.topLeft, - heightFactor: heightAnimation.value, - child: FractionalTranslation( - translation: offsetAnimation.value, - child: widget.child, - ), - ), + home: Home( + useLightMode: useLightMode, + useMaterial3: useMaterial3, + colorSelected: colorSelected, + handleBrightnessChange: handleBrightnessChange, + handleMaterialVersionChange: handleMaterialVersionChange, + handleColorSelect: handleColorSelect, ), ); } } - -class OneTwoTransition extends StatefulWidget { - const OneTwoTransition({ - super.key, - required this.animation, - required this.one, - required this.two, - }); - - final Animation animation; - final Widget one; - final Widget two; - - @override - State createState() => _OneTwoTransitionState(); -} - -class _OneTwoTransitionState extends State { - late final Animation offsetAnimation; - late final Animation widthAnimation; - - @override - void initState() { - super.initState(); - - offsetAnimation = Tween( - begin: const Offset(1, 0), - end: Offset.zero, - ).animate(OffsetAnimation(widget.animation)); - - widthAnimation = Tween( - begin: 0, - end: 1000, - ).animate(SizeAnimation(widget.animation)); - } - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Flexible( - flex: 1000, - child: widget.one, - ), - if (widthAnimation.value.toInt() > 0) ...[ - Flexible( - flex: widthAnimation.value.toInt(), - child: FractionalTranslation( - translation: offsetAnimation.value, - child: widget.two, - ), - ) - ], - ], - ); - } -} diff --git a/material_3_demo/pubspec.yaml b/material_3_demo/pubspec.yaml index 09c335396..d4891c1b0 100644 --- a/material_3_demo/pubspec.yaml +++ b/material_3_demo/pubspec.yaml @@ -22,6 +22,8 @@ dev_dependencies: sdk: flutter flutter_lints: ^2.0.1 + integration_test: + sdk: flutter flutter: uses-material-design: true diff --git a/material_3_demo/test/color_screen_test.dart b/material_3_demo/test/color_screen_test.dart index a29b089f7..64efe5884 100644 --- a/material_3_demo/test/color_screen_test.dart +++ b/material_3_demo/test/color_screen_test.dart @@ -16,7 +16,7 @@ void main() { 'on NavigationBar', (tester) async { widgetSetup(tester, 449); addTearDown(tester.binding.window.clearPhysicalSizeTestValue); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); expect(find.text('Light ColorScheme'), findsNothing); expect(find.text('Dark ColorScheme'), findsNothing); @@ -45,7 +45,7 @@ void main() { widgetSetup( tester, 1200); // NavigationRail shows only when width is > 1000. addTearDown(tester.binding.window.clearPhysicalSizeTestValue); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); await tester.pumpAndSettle(); expect(find.text('Light ColorScheme'), findsNothing); expect(find.text('Dark ColorScheme'), findsNothing); diff --git a/material_3_demo/test/component_screen_test.dart b/material_3_demo/test/component_screen_test.dart index e5f820995..550547fad 100644 --- a/material_3_demo/test/component_screen_test.dart +++ b/material_3_demo/test/component_screen_test.dart @@ -11,7 +11,7 @@ import 'package:material_3_demo/main.dart'; void main() { testWidgets('Default main page shows all M3 components', (tester) async { widgetSetup(tester, 800, windowHeight: 7000); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); // Elements on the app bar expect(find.text('Material 3'), findsOneWidget); @@ -131,7 +131,7 @@ void main() { 'NavigationRail doesn\'t show when width value is small than 1000 ' '(in Portrait mode or narrow screen)', (tester) async { widgetSetup(tester, 999, windowHeight: 7000); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); await tester.pumpAndSettle(); // When screen width is less than 1000, NavigationBar will show. At the same @@ -152,7 +152,7 @@ void main() { 'NavigationRail shows when width value is greater than or equal ' 'to 1000 (in Landscape mode or wider screen)', (tester) async { widgetSetup(tester, 1001, windowHeight: 3000); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); await tester.pumpAndSettle(); // When screen width is greater than or equal to 1000, NavigationRail will show. @@ -178,7 +178,7 @@ void main() { 'Material version switches between Material3 and Material2 when' 'the version icon is clicked', (tester) async { widgetSetup(tester, 450, windowHeight: 7000); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); BuildContext defaultElevatedButton = tester.firstElement(find.byType(ElevatedButton)); BuildContext defaultIconButton = @@ -244,7 +244,7 @@ void main() { testWidgets( 'Other screens become Material2 mode after changing mode from ' 'main screen', (tester) async { - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); Finder appbarM2Icon = find.descendant( of: find.byType(AppBar), matching: find.widgetWithIcon(IconButton, Icons.filter_2)); @@ -279,7 +279,7 @@ void main() { testWidgets( 'Brightness mode switches between dark and light when' 'the brightness icon is clicked', (tester) async { - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); Finder lightIcon = find.descendant( of: find.byType(AppBar), matching: find.widgetWithIcon(IconButton, Icons.light_mode_outlined)); @@ -314,7 +314,7 @@ void main() { (tester) async { Color m3BaseColor = const Color(0xff6750a4); await tester.pumpWidget(Container()); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); await tester.pump(); Finder menuIcon = find.descendant( of: find.byType(AppBar), diff --git a/material_3_demo/test/elevation_screen_test.dart b/material_3_demo/test/elevation_screen_test.dart index 737a355a6..95d886e5f 100644 --- a/material_3_demo/test/elevation_screen_test.dart +++ b/material_3_demo/test/elevation_screen_test.dart @@ -16,7 +16,7 @@ void main() { 'selected on NavigationBar', (tester) async { widgetSetup(tester, 449); addTearDown(tester.binding.window.clearPhysicalSizeTestValue); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); expect(find.text('Surface Tint Color Only'), findsNothing); expect(find.byType(NavigationBar), findsOneWidget); @@ -41,7 +41,7 @@ void main() { widgetSetup( tester, 1200); // NavigationRail shows only when width is > 1000. addTearDown(tester.binding.window.clearPhysicalSizeTestValue); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); expect(find.text('Surface Tint Color Only'), findsNothing); Finder tintIconOnRail = find.descendant( of: find.byType(NavigationRail), diff --git a/material_3_demo/test/typography_screen_test.dart b/material_3_demo/test/typography_screen_test.dart index 234c14b3d..fff11f9ec 100644 --- a/material_3_demo/test/typography_screen_test.dart +++ b/material_3_demo/test/typography_screen_test.dart @@ -16,7 +16,7 @@ void main() { 'selected on NavigationBar', (tester) async { widgetSetup(tester, 449); addTearDown(tester.binding.window.clearPhysicalSizeTestValue); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); expect(find.text('Display Large'), findsNothing); expect(find.byType(NavigationBar), findsOneWidget); @@ -40,7 +40,7 @@ void main() { widgetSetup( tester, 1200); // NavigationRail shows only when width is > 1000. addTearDown(tester.binding.window.clearPhysicalSizeTestValue); - await tester.pumpWidget(const MaterialApp(home: Material3Demo())); + await tester.pumpWidget(const App()); expect(find.text('Display Large'), findsNothing); Finder textIconOnRail = find.descendant( of: find.byType(NavigationRail),