diff --git a/examples/flutter_gallery/lib/demo/material/fab_motion_demo.dart b/examples/flutter_gallery/lib/demo/material/fab_motion_demo.dart new file mode 100644 index 00000000000..5c92bc34e21 --- /dev/null +++ b/examples/flutter_gallery/lib/demo/material/fab_motion_demo.dart @@ -0,0 +1,147 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +const String _explanatoryText = + "When the Scaffold's floating action button location changes, " + 'the floating action button animates to its new position'; + +class FabMotionDemo extends StatefulWidget { + static const String routeName = '/material/fab-motion'; + + @override + _FabMotionDemoState createState() { + return new _FabMotionDemoState(); + } +} + +class _FabMotionDemoState extends State { + static const List _floatingActionButtonLocations = const [ + FloatingActionButtonLocation.endFloat, + FloatingActionButtonLocation.centerFloat, + const _TopStartFloatingActionButtonLocation(), + ]; + + bool _showFab = true; + FloatingActionButtonLocation _floatingActionButtonLocation = FloatingActionButtonLocation.endFloat; + + @override + Widget build(BuildContext context) { + final Widget floatingActionButton = _showFab + ? new Builder(builder: (BuildContext context) { + // We use a widget builder here so that this inner context can find the Scaffold. + // This makes it possible to show the snackbar. + return new FloatingActionButton( + backgroundColor: Colors.yellow.shade900, + onPressed: () => _showSnackbar(context), + child: const Icon(Icons.add), + ); + }) + : null; + return new Scaffold( + appBar: new AppBar( + title: const Text('FAB Location'), + // Add 48dp of space onto the bottom of the appbar. + // This gives space for the top-start location to attach to without + // blocking the 'back' button. + bottom: const PreferredSize( + preferredSize: const Size.fromHeight(48.0), + child: const SizedBox(), + ), + ), + floatingActionButtonLocation: _floatingActionButtonLocation, + floatingActionButton: floatingActionButton, + body: new Center( + child: new Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + new RaisedButton( + onPressed: _moveFab, + child: const Text('MOVE FAB'), + ), + new Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Toggle FAB'), + new Switch(value: _showFab, onChanged: _toggleFab), + ], + ), + ], + ), + ), + ); + } + + void _moveFab() { + setState(() { + _floatingActionButtonLocation = _floatingActionButtonLocations[(_floatingActionButtonLocations.indexOf(_floatingActionButtonLocation) + 1) % _floatingActionButtonLocations.length]; + }); + } + + void _toggleFab(bool showFab) { + setState(() { + _showFab = showFab; + }); + } + + void _showSnackbar(BuildContext context) { + Scaffold.of(context).showSnackBar(const SnackBar(content: const Text(_explanatoryText))); + } +} + +// Places the Floating Action Button at the top of the content area of the +// app, on the border between the body and the app bar. +class _TopStartFloatingActionButtonLocation extends FloatingActionButtonLocation { + const _TopStartFloatingActionButtonLocation(); + + @override + Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) { + // First, we'll place the X coordinate for the Floating Action Button + // at the start of the screen, based on the text direction. + double fabX; + assert(scaffoldGeometry.textDirection != null); + switch (scaffoldGeometry.textDirection) { + case TextDirection.rtl: + // In RTL layouts, the start of the screen is on the right side, + // and the end of the screen is on the left. + // + // We need to align the right edge of the floating action button with + // the right edge of the screen, then move it inwards by the designated padding. + // + // The Scaffold's origin is at its top-left, so we need to offset fabX + // by the Scaffold's width to get the right edge of the screen. + // + // The Floating Action Button's origin is at its top-left, so we also need + // to subtract the Floating Action Button's width to align the right edge + // of the Floating Action Button instead of the left edge. + final double startPadding = kFloatingActionButtonMargin + scaffoldGeometry.minInsets.right; + fabX = scaffoldGeometry.scaffoldSize.width - scaffoldGeometry.floatingActionButtonSize.width - startPadding; + break; + case TextDirection.ltr: + // In LTR layouts, the start of the screen is on the left side, + // and the end of the screen is on the right. + // + // Placing the fabX at 0.0 will align the left edge of the + // Floating Action Button with the left edge of the screen, so all + // we need to do is offset fabX by the designated padding. + final double startPadding = kFloatingActionButtonMargin + scaffoldGeometry.minInsets.left; + fabX = startPadding; + break; + } + // Finally, we'll place the Y coordinate for the Floating Action Button + // at the top of the content body. + // + // We want to place the middle of the Floating Action Button on the + // border between the Scaffold's app bar and its body. To do this, + // we place fabY at the scaffold geometry's contentTop, then subtract + // half of the Floating Action Button's height to place the center + // over the contentTop. + // + // We don't have to worry about which way is the top like we did + // for left and right, so we place fabY in this one-liner. + final double fabY = scaffoldGeometry.contentTop - (scaffoldGeometry.floatingActionButtonSize.height / 2.0); + return new Offset(fabX, fabY); + } +} diff --git a/examples/flutter_gallery/lib/demo/material/material.dart b/examples/flutter_gallery/lib/demo/material/material.dart index 975e5523ae8..ddf46e31a5a 100644 --- a/examples/flutter_gallery/lib/demo/material/material.dart +++ b/examples/flutter_gallery/lib/demo/material/material.dart @@ -11,6 +11,7 @@ export 'date_and_time_picker_demo.dart'; export 'dialog_demo.dart'; export 'drawer_demo.dart'; export 'expansion_panels_demo.dart'; +export 'fab_motion_demo.dart'; export 'grid_list_demo.dart'; export 'icons_demo.dart'; export 'leave_behind_demo.dart'; diff --git a/examples/flutter_gallery/lib/gallery/item.dart b/examples/flutter_gallery/lib/gallery/item.dart index 18bdea73c08..52373671fa9 100644 --- a/examples/flutter_gallery/lib/gallery/item.dart +++ b/examples/flutter_gallery/lib/gallery/item.dart @@ -158,6 +158,13 @@ List _buildGalleryItems() { routeName: TabsFabDemo.routeName, buildRoute: (BuildContext context) => new TabsFabDemo(), ), + new GalleryItem( + title: 'Floating action button motion', + subtitle: 'Action buttons with customized positions', + category: 'Material Components', + routeName: FabMotionDemo.routeName, + buildRoute: (BuildContext context) => new FabMotionDemo(), + ), new GalleryItem( title: 'Grid', subtitle: 'Row and column layout', diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 658e6941331..fe38f19ba39 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -49,6 +49,7 @@ export 'src/material/feedback.dart'; export 'src/material/flat_button.dart'; export 'src/material/flexible_space_bar.dart'; export 'src/material/floating_action_button.dart'; +export 'src/material/floating_action_button_location.dart'; export 'src/material/flutter_logo.dart'; export 'src/material/grid_tile.dart'; export 'src/material/grid_tile_bar.dart'; diff --git a/packages/flutter/lib/src/material/floating_action_button_location.dart b/packages/flutter/lib/src/material/floating_action_button_location.dart new file mode 100644 index 00000000000..7634f9fb507 --- /dev/null +++ b/packages/flutter/lib/src/material/floating_action_button_location.dart @@ -0,0 +1,297 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'scaffold.dart'; + +// TODO(hmuller): should be device dependent. +/// The margin that a [FloatingActionButton] should leave between it and the +/// edge of the screen. +/// +/// [FloatingActionButtonLocation.endFloat] uses this to set the appropriate margin +/// between the [FloatingActionButton] and the end of the screen. +const double kFloatingActionButtonMargin = 16.0; + +/// The amount of time the [FloatingActionButton] takes to transition in or out. +/// +/// The [Scaffold] uses this to set the duration of [FloatingActionButton] +/// motion, entrance, and exit animations. +const Duration kFloatingActionButtonSegue = const Duration(milliseconds: 200); + +/// The fraction of a circle the [FloatingActionButton] should turn when it enters. +/// +/// Its value corresponds to 0.125 of a full circle, equivalent to 45 degrees or pi/4 radians. +const double kFloatingActionButtonTurnInterval = 0.125; + +/// An object that defines a position for the [FloatingActionButton] +/// based on the [Scaffold]'s [ScaffoldPrelayoutGeometry]. +/// +/// Flutter provides [FloatingActionButtonLocation]s for the common +/// [FloatingActionButton] placements in Material Design applications. These +/// locations are available as static members of this class. +/// +/// See also: +/// +/// * [FloatingActionButton], which is a circular button typically shown in the +/// bottom right corner of the app. +/// * [FloatingActionButtonAnimator], which is used to animate the +/// [Scaffold.floatingActionButton] from one [FloatingActionButtonLocation] to +/// another. +/// * [ScaffoldPrelayoutGeometry], the geometry that +/// [FloatingActionButtonLocation]s use to position the [FloatingActionButton]. +abstract class FloatingActionButtonLocation { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const FloatingActionButtonLocation(); + + /// End-aligned [FloatingActionButton], floating at the bottom of the screen. + /// + /// This is the default alignment of [FloatingActionButton]s in Material applications. + static const FloatingActionButtonLocation endFloat = const _EndFloatFabLocation(); + + /// Centered [FloatingActionButton], floating at the bottom of the screen. + static const FloatingActionButtonLocation centerFloat = const _CenterFloatFabLocation(); + + /// Places the [FloatingActionButton] based on the [Scaffold]'s layout. + /// + /// This uses a [ScaffoldPrelayoutGeometry], which the [Scaffold] constructs + /// during its layout phase after it has laid out every widget it can lay out + /// except the [FloatingActionButton]. The [Scaffold] uses the [Offset] + /// returned from this method to position the [FloatingActionButton] and + /// complete its layout. + Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry); + + @override + String toString() => '$runtimeType'; +} + +class _CenterFloatFabLocation extends FloatingActionButtonLocation { + const _CenterFloatFabLocation(); + + @override + Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) { + // Compute the x-axis offset. + final double fabX = (scaffoldGeometry.scaffoldSize.width - scaffoldGeometry.floatingActionButtonSize.width) / 2.0; + + // Compute the y-axis offset. + final double contentBottom = scaffoldGeometry.contentBottom; + final double bottomSheetHeight = scaffoldGeometry.bottomSheetSize.height; + final double fabHeight = scaffoldGeometry.floatingActionButtonSize.height; + final double snackBarHeight = scaffoldGeometry.snackBarSize.height; + double fabY = contentBottom - fabHeight - kFloatingActionButtonMargin; + if (snackBarHeight > 0.0) + fabY = math.min(fabY, contentBottom - snackBarHeight - fabHeight - kFloatingActionButtonMargin); + if (bottomSheetHeight > 0.0) + fabY = math.min(fabY, contentBottom - bottomSheetHeight - fabHeight / 2.0); + + return new Offset(fabX, fabY); + } +} + +class _EndFloatFabLocation extends FloatingActionButtonLocation { + const _EndFloatFabLocation(); + + @override + Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) { + // Compute the x-axis offset. + double fabX; + assert(scaffoldGeometry.textDirection != null); + switch (scaffoldGeometry.textDirection) { + case TextDirection.rtl: + // In RTL, the end of the screen is the left. + final double endPadding = scaffoldGeometry.minInsets.left; + fabX = kFloatingActionButtonMargin + endPadding; + break; + case TextDirection.ltr: + // In LTR, the end of the screen is the right. + final double endPadding = scaffoldGeometry.minInsets.right; + fabX = scaffoldGeometry.scaffoldSize.width - scaffoldGeometry.floatingActionButtonSize.width - kFloatingActionButtonMargin - endPadding; + break; + } + + // Compute the y-axis offset. + final double contentBottom = scaffoldGeometry.contentBottom; + final double bottomSheetHeight = scaffoldGeometry.bottomSheetSize.height; + final double fabHeight = scaffoldGeometry.floatingActionButtonSize.height; + final double snackBarHeight = scaffoldGeometry.snackBarSize.height; + + double fabY = contentBottom - fabHeight - kFloatingActionButtonMargin; + if (snackBarHeight > 0.0) + fabY = math.min(fabY, contentBottom - snackBarHeight - fabHeight - kFloatingActionButtonMargin); + if (bottomSheetHeight > 0.0) + fabY = math.min(fabY, contentBottom - bottomSheetHeight - fabHeight / 2.0); + + return new Offset(fabX, fabY); + } +} + +/// Provider of animations to move the [FloatingActionButton] between [FloatingActionButtonLocation]s. +/// +/// The [Scaffold] uses [Scaffold.floatingActionButtonAnimator] to define: +/// +/// * The [Offset] of the [FloatingActionButton] between the old and new +/// [FloatingActionButtonLocation]s as part of the transition animation. +/// * An [Animation] to scale the [FloatingActionButton] during the transition. +/// * An [Animation] to rotate the [FloatingActionButton] during the transition. +/// * Where to start a new animation from if an animation is interrupted. +/// +/// See also: +/// +/// * [FloatingActionButton], which is a circular button typically shown in the +/// bottom right corner of the app. +/// * [FloatingActionButtonLocation], which the [Scaffold] uses to place the +/// [Scaffold.floatingActionButton] within the [Scaffold]'s layout. +abstract class FloatingActionButtonAnimator { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const FloatingActionButtonAnimator(); + + /// Moves the [FloatingActionButton] by scaling out and then in at a new + /// [FloatingActionButtonLocation]. + /// + /// This animator shrinks the [FloatingActionButton] down until it disappears, then + /// grows it back to full size at its new [FloatingActionButtonLocation]. + /// + /// This is the default [FloatingActionButton] motion animation. + static const FloatingActionButtonAnimator scaling = const _ScalingFabMotionAnimator(); + + /// Gets the [FloatingActionButton]'s position relative to the origin of the + /// [Scaffold] based on [progress]. + /// + /// [begin] is the [Offset] provided by the previous + /// [FloatingActionButtonLocation]. + /// + /// [end] is the [Offset] provided by the new + /// [FloatingActionButtonLocation]. + /// + /// [progress] is the current progress of the transition animation. + /// When [progress] is 0.0, the returned [Offset] should be equal to [begin]. + /// when [progress] is 1.0, the returned [Offset] should be equal to [end]. + Offset getOffset({@required Offset begin, @required Offset end, @required double progress}); + + /// Animates the scale of the [FloatingActionButton]. + /// + /// The animation should both start and end with a value of 1.0. + /// + /// For example, to create an animation that linearly scales out and then back in, + /// you could join animations that pass each other: + /// + /// ```dart + /// @override + /// Animation getScaleAnimation({@required Animation parent}) { + /// // The animations will cross at value 0, and the train will return to 1.0. + /// return new TrainHoppingAnimation( + /// Tween(begin: 1.0, end: -1.0).animate(parent), + /// Tween(begin: -1.0, end: 1.0).animate(parent), + /// ); + /// } + /// ``` + Animation getScaleAnimation({@required Animation parent}); + + /// Animates the rotation of [Scaffold.floatingActionButton]. + /// + /// The animation should both start and end with a value of 0.0 or 1.0. + /// + /// The animation values are a fraction of a full circle, with 0.0 and 1.0 + /// corresponding to 0 and 360 degrees, while 0.5 corresponds to 180 degrees. + /// + /// For example, to create a rotation animation that rotates the + /// [FloatingActionButton] through a full circle: + /// + /// ```dart + /// @override + /// Animation getRotationAnimation({@required Animation parent}) { + /// return new Tween(begin: 0.0, end: 1.0).animate(parent); + /// } + /// ``` + Animation getRotationAnimation({@required Animation parent}); + + /// Gets the progress value to restart a motion animation from when the animation is interrupted. + /// + /// [previousValue] is the value of the animation before it was interrupted. + /// + /// The restart of the animation will affect all three parts of the motion animation: + /// offset animation, scale animation, and rotation animation. + /// + /// An interruption triggers if the [Scaffold] is given a new [FloatingActionButtonLocation] + /// while it is still animating a transition between two previous [FloatingActionButtonLocation]s. + /// + /// A sensible default is usually 0.0, which is the same as restarting + /// the animation from the beginning, regardless of the original state of the animation. + double getAnimationRestart(double previousValue) => 0.0; + + @override + String toString() => '$runtimeType'; +} + +class _ScalingFabMotionAnimator extends FloatingActionButtonAnimator { + const _ScalingFabMotionAnimator(); + + @override + Offset getOffset({Offset begin, Offset end, double progress}) { + if (progress < 0.5) { + return begin; + } else { + return end; + } + } + + @override + Animation getScaleAnimation({Animation parent}) { + // Animate the scale down from 1 to 0 in the first half of the animation + // then from 0 back to 1 in the second half. + const Curve curve = const Interval(0.5, 1.0, curve: Curves.ease); + return new _AnimationSwap( + new ReverseAnimation(new CurveTween(curve: curve.flipped).animate(parent)), + new CurveTween(curve: curve).animate(parent), + parent, + 0.5, + ); + } + + @override + Animation getRotationAnimation({Animation parent}) { + // Because we only see the last half of the rotation tween, + // it needs to go twice as far. + final Tween rotationTween = new Tween( + begin: 1.0 - kFloatingActionButtonTurnInterval * 2, + end: 1.0, + ); + // This rotation will turn on the way in, but not on the way out. + return new _AnimationSwap( + rotationTween.animate(parent), + new ReverseAnimation(new CurveTween(curve: const Threshold(0.5)).animate(parent)), + parent, + 0.5, + ); + } + + // If the animation was just starting, we'll continue from where we left off. + // If the animation was finishing, we'll treat it as if we were starting at that point in reverse. + // This avoids a size jump during the animation. + @override + double getAnimationRestart(double previousValue) => math.min(1.0 - previousValue, previousValue); +} + +/// An animation that swaps from one animation to the next when the [parent] passes [swapThreshold]. +/// +/// The [value] of this animation is the value of [first] when [parent.value] < [swapThreshold] +/// and the value of [next] otherwise. +class _AnimationSwap extends CompoundAnimation { + /// Creates an [_AnimationSwap]. + /// + /// Both arguments must be non-null. Either can be an [AnimationMin] itself + /// to combine multiple animations. + _AnimationSwap(Animation first, Animation next, this.parent, this.swapThreshold): super(first: first, next: next); + + final Animation parent; + final double swapThreshold; + + @override + T get value => parent.value < swapThreshold ? first.value : next.value; +} \ No newline at end of file diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index 8aea5899bc7..c5036b35811 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -8,6 +8,7 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'app_bar.dart'; @@ -17,13 +18,13 @@ import 'button_theme.dart'; import 'divider.dart'; import 'drawer.dart'; import 'flexible_space_bar.dart'; +import 'floating_action_button_location.dart'; import 'material.dart'; import 'snack_bar.dart'; import 'theme.dart'; -const double _kFloatingActionButtonMargin = 16.0; // TODO(hmuller): should be device dependent -const Duration _kFloatingActionButtonSegue = const Duration(milliseconds: 200); -final Tween _kFloatingActionButtonTurnTween = new Tween(begin: -0.125, end: 0.0); +const FloatingActionButtonLocation _kDefaultFloatingActionButtonLocation = FloatingActionButtonLocation.endFloat; +const FloatingActionButtonAnimator _kDefaultFloatingActionButtonAnimator = FloatingActionButtonAnimator.scaling; /// Returns a path for a notch in the outline of a shape. /// @@ -56,10 +57,145 @@ enum _ScaffoldSlot { statusBar, } -/// Geometry information for [Scaffold] components. +/// The geometry of the [Scaffold] after all its contents have been laid out +/// except the [FloatingActionButton]. +/// +/// The [Scaffold] passes this prelayout geometry to its +/// [FloatingActionButtonLocation], which produces an [Offset] that the +/// [Scaffold] uses to position the [FloatingActionButton]. +/// +/// For a description of the [Scaffold]'s geometry after it has +/// finished laying out, see the [ScaffoldGeometry]. +@immutable +class ScaffoldPrelayoutGeometry { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const ScaffoldPrelayoutGeometry({ + @required this.bottomSheetSize, + @required this.contentBottom, + @required this.contentTop, + @required this.floatingActionButtonSize, + @required this.minInsets, + @required this.scaffoldSize, + @required this.snackBarSize, + @required this.textDirection, + }); + + /// The [Size] of [Scaffold.floatingActionButton]. + /// + /// If [Scaffold.floatingActionButton] is null, this will be [Size.zero]. + final Size floatingActionButtonSize; + + /// The [Size] of the [Scaffold]'s [BottomSheet]. + /// + /// If the [Scaffold] is not currently showing a [BottomSheet], + /// this will be [Size.zero]. + final Size bottomSheetSize; + + /// The vertical distance from the Scaffold's origin to the bottom of + /// [Scaffold.body]. + /// + /// This is useful in a [FloatingActionButtonLocation] designed to + /// place the [FloatingActionButton] at the bottom of the screen, while + /// keeping it above the [BottomSheet], the [Scaffold.bottomNavigationBar], + /// or the keyboard. + /// + /// Note that [Scaffold.body] is laid out with respect to [minInsets] already. + /// This means that a [FloatingActionButtonLocation] does not need to factor + /// in [minInsets.bottom] when aligning a [FloatingActionButton] to [contentBottom]. + final double contentBottom; + + /// The vertical distance from the [Scaffold]'s origin to the top of + /// [Scaffold.body]. + /// + /// This is useful in a [FloatingActionButtonLocation] designed to + /// place the [FloatingActionButton] at the top of the screen, while + /// keeping it below the [Scaffold.appBar]. + /// + /// Note that [Scaffold.body] is laid out with respect to [minInsets] already. + /// This means that a [FloatingActionButtonLocation] does not need to factor + /// in [minInsets.top] when aligning a [FloatingActionButton] to [contentTop]. + final double contentTop; + + /// The minimum padding to inset the [FloatingActionButton] by for it + /// to remain visible. + /// + /// This value is the result of calling [MediaQuery.padding] in the + /// [Scaffold]'s [BuildContext], + /// and is useful for insetting the [FloatingActionButton] to avoid features like + /// the system status bar or the keyboard. + /// + /// If [Scaffold.resizeToAvoidBottomPadding] is set to false, [minInsets.bottom] + /// will be 0.0 instead of [MediaQuery.padding.bottom]. + final EdgeInsets minInsets; + + /// The [Size] of the whole [Scaffold]. + /// + /// If the [Size] of the [Scaffold]'s contents is modified by values such as + /// [Scaffold.resizeToAvoidBottomPadding] or the keyboard opening, then the + /// [scaffoldSize] will not reflect those changes. + /// + /// This means that [FloatingActionButtonLocation]s designed to reposition + /// the [FloatingActionButton] based on events such as the keyboard popping + /// up should use [minInsets] to make sure that the [FloatingActionButton] is + /// inset by enough to remain visible. + /// + /// See [minInsets] and [MediaQuery.padding] for more information on the appropriate + /// insets to apply. + final Size scaffoldSize; + + /// The [Size] of the [Scaffold]'s [SnackBar]. + /// + /// If the [Scaffold] is not showing a [SnackBar], this will be [Size.zero]. + final Size snackBarSize; + + /// The [TextDirection] of the [Scaffold]'s [BuildContext]. + final TextDirection textDirection; +} + +/// A snapshot of a transition between two [FloatingActionButtonLocation]s. +/// +/// [ScaffoldState] uses this to seamlessly change transition animations +/// when a running [FloatingActionButtonLocation] transition is interrupted by a new transition. +@immutable +class _TransitionSnapshotFabLocation extends FloatingActionButtonLocation { + + const _TransitionSnapshotFabLocation(this.begin, this.end, this.animator, this.progress); + + final FloatingActionButtonLocation begin; + final FloatingActionButtonLocation end; + final FloatingActionButtonAnimator animator; + final double progress; + + @override + Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) { + return animator.getOffset( + begin: begin.getOffset(scaffoldGeometry), + end: end.getOffset(scaffoldGeometry), + progress: progress, + ); + } + + @override + String toString() { + return '$runtimeType(begin: $begin, end: $end, progress: $progress)'; + } +} + +/// Geometry information for [Scaffold] components after layout is finished. /// /// To get a [ValueNotifier] for the scaffold geometry of a given /// [BuildContext], use [Scaffold.geometryOf]. +/// +/// The ScaffoldGeometry is only available during the paint phase, because +/// its value is computed during the animation and layout phases prior to painting. +/// +/// For an example of using the [ScaffoldGeometry], see the [BottomAppBar], +/// which uses the [ScaffoldGeometry] to paint a notch around the +/// [FloatingActionButton]. +/// +/// For information about the [Scaffold]'s geometry that is used while laying +/// out the [FloatingActionButton], see [ScaffoldPrelayoutGeometry]. @immutable class ScaffoldGeometry { /// Create an object that describes the geometry of a [Scaffold]. @@ -69,15 +205,13 @@ class ScaffoldGeometry { this.floatingActionButtonNotch, }); - /// The distance from the scaffold's top edge to the top edge of the - /// rectangle in which the [Scaffold.bottomNavigationBar] bar is being laid - /// out. + /// The distance from the [Scaffold]'s top edge to the top edge of the + /// rectangle in which the [Scaffold.bottomNavigationBar] bar is laid out. /// - /// When there is no [Scaffold.bottomNavigationBar] set, this will be null. + /// Null if [Scaffold.bottomNavigationBar] is null. final double bottomNavigationBarTop; - /// The rectangle in which the scaffold is laying out - /// [Scaffold.floatingActionButton]. + /// The [Scaffold.floatingActionButton]'s bounding rectangle. /// /// This is null when there is no floating action button showing. final Rect floatingActionButtonArea; @@ -141,7 +275,7 @@ class _ScaffoldGeometryNotifier extends ChangeNotifier implements ValueListenabl : assert (context != null); final BuildContext context; - double fabScale; + double floatingActionButtonScale; ScaffoldGeometry geometry; _Closeable computeNotchCloseable; @@ -157,7 +291,7 @@ class _ScaffoldGeometryNotifier extends ChangeNotifier implements ValueListenabl ); return true; }()); - return geometry._scaleFloatingActionButton(fabScale); + return geometry._scaleFloatingActionButton(floatingActionButtonScale); } void _updateWith({ @@ -166,7 +300,7 @@ class _ScaffoldGeometryNotifier extends ChangeNotifier implements ValueListenabl double floatingActionButtonScale, ComputeNotch floatingActionButtonNotch, }) { - fabScale = floatingActionButtonScale ?? fabScale; + this.floatingActionButtonScale = floatingActionButtonScale ?? this.floatingActionButtonScale; geometry = geometry.copyWith( bottomNavigationBarTop: bottomNavigationBarTop, floatingActionButtonArea: floatingActionButtonArea, @@ -194,19 +328,26 @@ class _ScaffoldGeometryNotifier extends ChangeNotifier implements ValueListenabl class _ScaffoldLayout extends MultiChildLayoutDelegate { _ScaffoldLayout({ - @required this.statusBarHeight, - @required this.bottomViewInset, - @required this.endPadding, // for floating action button + @required this.minInsets, @required this.textDirection, @required this.geometryNotifier, - }); + // for floating action button + @required this.previousFloatingActionButtonLocation, + @required this.currentFloatingActionButtonLocation, + @required this.floatingActionButtonMoveAnimationProgress, + @required this.floatingActionButtonMotionAnimator, + }) : assert(previousFloatingActionButtonLocation != null), + assert(currentFloatingActionButtonLocation != null); - final double statusBarHeight; - final double bottomViewInset; - final double endPadding; + final EdgeInsets minInsets; final TextDirection textDirection; final _ScaffoldGeometryNotifier geometryNotifier; + final FloatingActionButtonLocation previousFloatingActionButtonLocation; + final FloatingActionButtonLocation currentFloatingActionButtonLocation; + final double floatingActionButtonMoveAnimationProgress; + final FloatingActionButtonAnimator floatingActionButtonMotionAnimator; + @override void performLayout(Size size) { final BoxConstraints looseConstraints = new BoxConstraints.loose(size); @@ -247,7 +388,7 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { // Set the content bottom to account for the greater of the height of any // bottom-anchored material widgets or of the keyboard or other // bottom-anchored system UI. - final double contentBottom = math.max(0.0, bottom - math.max(bottomViewInset, bottomWidgetsHeight)); + final double contentBottom = math.max(0.0, bottom - math.max(minInsets.bottom, bottomWidgetsHeight)); if (hasChild(_ScaffoldSlot.body)) { final BoxConstraints bodyConstraints = new BoxConstraints( @@ -265,10 +406,10 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { // // If all three elements are present then either the center of the FAB straddles // the top edge of the BottomSheet or the bottom of the FAB is - // _kFloatingActionButtonMargin above the SnackBar, whichever puts the FAB + // kFloatingActionButtonMargin above the SnackBar, whichever puts the FAB // the farthest above the bottom of the parent. If only the FAB is has a // non-zero height then it's inset from the parent's right and bottom edges - // by _kFloatingActionButtonMargin. + // by kFloatingActionButtonMargin. Size bottomSheetSize = Size.zero; Size snackBarSize = Size.zero; @@ -290,27 +431,32 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { Rect floatingActionButtonRect; if (hasChild(_ScaffoldSlot.floatingActionButton)) { final Size fabSize = layoutChild(_ScaffoldSlot.floatingActionButton, looseConstraints); - double fabX; - assert(textDirection != null); - switch (textDirection) { - case TextDirection.rtl: - fabX = _kFloatingActionButtonMargin + endPadding; - break; - case TextDirection.ltr: - fabX = size.width - fabSize.width - _kFloatingActionButtonMargin - endPadding; - break; - } - double fabY = contentBottom - fabSize.height - _kFloatingActionButtonMargin; - if (snackBarSize.height > 0.0) - fabY = math.min(fabY, contentBottom - snackBarSize.height - fabSize.height - _kFloatingActionButtonMargin); - if (bottomSheetSize.height > 0.0) - fabY = math.min(fabY, contentBottom - bottomSheetSize.height - fabSize.height / 2.0); - positionChild(_ScaffoldSlot.floatingActionButton, new Offset(fabX, fabY)); - floatingActionButtonRect = new Offset(fabX, fabY) & fabSize; + + // To account for the FAB position being changed, we'll animate between + // the old and new positions. + final ScaffoldPrelayoutGeometry currentGeometry = new ScaffoldPrelayoutGeometry( + bottomSheetSize: bottomSheetSize, + contentBottom: contentBottom, + contentTop: contentTop, + floatingActionButtonSize: fabSize, + minInsets: minInsets, + scaffoldSize: size, + snackBarSize: snackBarSize, + textDirection: textDirection, + ); + final Offset currentFabOffset = currentFloatingActionButtonLocation.getOffset(currentGeometry); + final Offset previousFabOffset = previousFloatingActionButtonLocation.getOffset(currentGeometry); + final Offset fabOffset = floatingActionButtonMotionAnimator.getOffset( + begin: previousFabOffset, + end: currentFabOffset, + progress: floatingActionButtonMoveAnimationProgress, + ); + positionChild(_ScaffoldSlot.floatingActionButton, fabOffset); + floatingActionButtonRect = fabOffset & fabSize; } if (hasChild(_ScaffoldSlot.statusBar)) { - layoutChild(_ScaffoldSlot.statusBar, fullWidthConstraints.tighten(height: statusBarHeight)); + layoutChild(_ScaffoldSlot.statusBar, fullWidthConstraints.tighten(height: minInsets.top)); positionChild(_ScaffoldSlot.statusBar, Offset.zero); } @@ -332,21 +478,36 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { @override bool shouldRelayout(_ScaffoldLayout oldDelegate) { - return oldDelegate.statusBarHeight != statusBarHeight - || oldDelegate.bottomViewInset != bottomViewInset - || oldDelegate.endPadding != endPadding - || oldDelegate.textDirection != textDirection; + return oldDelegate.minInsets != minInsets + || oldDelegate.textDirection != textDirection + || oldDelegate.floatingActionButtonMoveAnimationProgress != floatingActionButtonMoveAnimationProgress + || oldDelegate.previousFloatingActionButtonLocation != previousFloatingActionButtonLocation + || oldDelegate.currentFloatingActionButtonLocation != currentFloatingActionButtonLocation; } } +/// Handler for scale and rotation animations in the [FloatingActionButton]. +/// +/// Currently, there are two types of [FloatingActionButton] animations: +/// +/// * Entrance/Exit animations, which this widget triggers +/// when the [FloatingActionButton] is added, updated, or removed. +/// * Motion animations, which are triggered by the [Scaffold] +/// when its [FloatingActionButtonLocation] is updated. class _FloatingActionButtonTransition extends StatefulWidget { const _FloatingActionButtonTransition({ Key key, - this.child, - this.geometryNotifier, - }) : super(key: key); + @required this.child, + @required this.fabMoveAnimation, + @required this.fabMotionAnimator, + @required this.geometryNotifier, + }) : assert(fabMoveAnimation != null), + assert(fabMotionAnimator != null), + super(key: key); final Widget child; + final Animation fabMoveAnimation; + final FloatingActionButtonAnimator fabMotionAnimator; final _ScaffoldGeometryNotifier geometryNotifier; @override @@ -354,10 +515,16 @@ class _FloatingActionButtonTransition extends StatefulWidget { } class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTransition> with TickerProviderStateMixin { + // The animations applied to the Floating Action Button when it is entering or exiting. + // Controls the previous widget.child as it exits AnimationController _previousController; + Animation _previousScaleAnimation; + Animation _previousRotationAnimation; + // Controls the current child widget.child as it exits AnimationController _currentController; - CurvedAnimation _previousAnimation; - CurvedAnimation _currentAnimation; + // The animations to run, considering the widget's fabMoveAnimation and the current/previous entrance/exit animations. + Animation _currentScaleAnimation; + Animation _currentRotationAnimation; Widget _previousChild; @override @@ -365,24 +532,16 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr super.initState(); _previousController = new AnimationController( - duration: _kFloatingActionButtonSegue, + duration: kFloatingActionButtonSegue, vsync: this, - )..addStatusListener(_handleAnimationStatusChanged); - _previousAnimation = new CurvedAnimation( - parent: _previousController, - curve: Curves.easeIn - ); - _previousAnimation.addListener(_onProgressChanged); - + )..addStatusListener(_handlePreviousAnimationStatusChanged); + _currentController = new AnimationController( - duration: _kFloatingActionButtonSegue, + duration: kFloatingActionButtonSegue, vsync: this, ); - _currentAnimation = new CurvedAnimation( - parent: _currentController, - curve: Curves.easeIn - ); - _currentAnimation.addListener(_onProgressChanged); + + _updateAnimations(); if (widget.child != null) { // If we start out with a child, have the child appear fully visible instead @@ -410,6 +569,10 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr final bool newChildIsNull = widget.child == null; if (oldChildIsNull == newChildIsNull && oldWidget.child?.key == widget.child?.key) return; + if (oldWidget.fabMotionAnimator != widget.fabMotionAnimator || oldWidget.fabMoveAnimation != oldWidget.fabMoveAnimation) { + // Get the right scale and rotation animations to use for this widget. + _updateAnimations(); + } if (_previousController.status == AnimationStatus.dismissed) { final double currentValue = _currentController.value; if (currentValue == 0.0 || oldWidget.child == null) { @@ -431,7 +594,43 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr } } - void _handleAnimationStatusChanged(AnimationStatus status) { + void _updateAnimations() { + // Get the animations for exit and entrance. + final CurvedAnimation previousExitScaleAnimation = new CurvedAnimation( + parent: _previousController, + curve: Curves.easeIn, + ); + final Animation previousExitRotationAnimation = new Tween(begin: 1.0, end: 1.0).animate( + new CurvedAnimation(parent: _previousController, curve: Curves.easeIn), + ); + + final CurvedAnimation currentEntranceScaleAnimation = new CurvedAnimation( + parent: _currentController, + curve: Curves.easeIn, + ); + final Animation currentEntranceRotationAnimation = new Tween( + begin: 1.0 - kFloatingActionButtonTurnInterval, + end: 1.0, + ).animate( + new CurvedAnimation(parent: _currentController, curve: Curves.easeIn), + ); + + // Get the animations for when the FAB is moving. + final Animation moveScaleAnimation = widget.fabMotionAnimator.getScaleAnimation(parent: widget.fabMoveAnimation); + final Animation moveRotationAnimation = widget.fabMotionAnimator.getRotationAnimation(parent: widget.fabMoveAnimation); + + // Aggregate the animations. + _previousScaleAnimation = new AnimationMin(moveScaleAnimation, previousExitScaleAnimation); + _currentScaleAnimation = new AnimationMin(moveScaleAnimation, currentEntranceScaleAnimation); + + _previousRotationAnimation = new TrainHoppingAnimation(previousExitRotationAnimation, moveRotationAnimation); + _currentRotationAnimation = new TrainHoppingAnimation(currentEntranceRotationAnimation, moveRotationAnimation); + + _currentScaleAnimation.addListener(_onProgressChanged); + _previousScaleAnimation.addListener(_onProgressChanged); + } + + void _handlePreviousAnimationStatusChanged(AnimationStatus status) { setState(() { if (status == AnimationStatus.dismissed) { assert(_currentController.status == AnimationStatus.dismissed); @@ -444,33 +643,27 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr @override Widget build(BuildContext context) { final List children = []; - if (_previousAnimation.status != AnimationStatus.dismissed) { + if (_previousController.status != AnimationStatus.dismissed) { children.add(new ScaleTransition( - scale: _previousAnimation, - child: _previousChild, - )); - } - if (_currentAnimation.status != AnimationStatus.dismissed) { - children.add(new ScaleTransition( - scale: _currentAnimation, + scale: _previousScaleAnimation, child: new RotationTransition( - turns: _kFloatingActionButtonTurnTween.animate(_currentAnimation), - child: widget.child, - ) + turns: _previousRotationAnimation, + child: _previousChild, + ), )); } + children.add(new ScaleTransition( + scale: _currentScaleAnimation, + child: new RotationTransition( + turns: _currentRotationAnimation, + child: widget.child, + ), + )); return new Stack(children: children); } void _onProgressChanged() { - if (_previousAnimation.status != AnimationStatus.dismissed) { - _updateGeometryScale(_previousAnimation.value); - return; - } - if (_currentAnimation.status != AnimationStatus.dismissed) { - _updateGeometryScale(_currentAnimation.value); - return; - } + _updateGeometryScale(math.max(_previousScaleAnimation.value, _currentScaleAnimation.value)); } void _updateGeometryScale(double scale) { @@ -496,6 +689,11 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr /// of an app using the [bottomNavigationBar] property. /// * [FloatingActionButton], which is a circular button typically shown in the /// bottom right corner of the app using the [floatingActionButton] property. +/// * [FloatingActionButtonLocation], which is used to place the +/// [floatingActionButton] within the [Scaffold]'s layout. +/// * [FloatingActionButtonAnimator], which is used to animate the +/// [floatingActionButton] from one [floatingActionButtonLocation] to +/// another. /// * [Drawer], which is a vertical panel that is typically displayed to the /// left of the body (and often hidden on phones) using the [drawer] /// property. @@ -517,6 +715,8 @@ class Scaffold extends StatefulWidget { this.appBar, this.body, this.floatingActionButton, + this.floatingActionButtonLocation, + this.floatingActionButtonAnimator, this.persistentFooterButtons, this.drawer, this.endDrawer, @@ -552,6 +752,16 @@ class Scaffold extends StatefulWidget { /// Typically a [FloatingActionButton]. final Widget floatingActionButton; + /// Responsible for determining where the [floatingActionButton] should go. + /// + /// If null, the [ScaffoldState] will use the default location, [FloatingActionButtonLocation.endFloat]. + final FloatingActionButtonLocation floatingActionButtonLocation; + + /// Animator to move the [floatingActionButton] to a new [floatingActionButtonLocation]. + /// + /// If null, the [ScaffoldState] will use the default animator, [FloatingActionButtonAnimator.scaling]. + final FloatingActionButtonAnimator floatingActionButtonAnimator; + /// A set of buttons that are displayed at the bottom of the scaffold. /// /// Typically this is a list of [FlatButton] widgets. These buttons are @@ -1040,6 +1250,32 @@ class ScaffoldState extends State with TickerProviderStateMixin { return _currentBottomSheet; } + // Floating Action Button API + AnimationController _floatingActionButtonMoveController; + FloatingActionButtonAnimator _floatingActionButtonAnimator; + FloatingActionButtonLocation _previousFloatingActionButtonLocation; + FloatingActionButtonLocation _floatingActionButtonLocation; + + // Moves the Floating Action Button to the new Floating Action Button Location. + void _moveFloatingActionButton(final FloatingActionButtonLocation newLocation) { + FloatingActionButtonLocation previousLocation = _floatingActionButtonLocation; + double restartAnimationFrom = 0.0; + // If the Floating Action Button is moving right now, we need to start from a snapshot of the current transition. + if (_floatingActionButtonMoveController.isAnimating) { + previousLocation = new _TransitionSnapshotFabLocation(_previousFloatingActionButtonLocation, _floatingActionButtonLocation, _floatingActionButtonAnimator, _floatingActionButtonMoveController.value); + restartAnimationFrom = _floatingActionButtonAnimator.getAnimationRestart(_floatingActionButtonMoveController.value); + } + + setState(() { + _previousFloatingActionButtonLocation = previousLocation; + _floatingActionButtonLocation = newLocation; + }); + + // Animate the motion even when the fab is null so that if the exit animation is running, + // the old fab will start the motion transition while it exits instead of jumping to the + // new position. + _floatingActionButtonMoveController.forward(from: restartAnimationFrom); + } // iOS FEATURES - status bar tap, back gesture @@ -1059,7 +1295,6 @@ class ScaffoldState extends State with TickerProviderStateMixin { } } - // INTERNALS _ScaffoldGeometryNotifier _geometryNotifier; @@ -1068,12 +1303,34 @@ class ScaffoldState extends State with TickerProviderStateMixin { void initState() { super.initState(); _geometryNotifier = new _ScaffoldGeometryNotifier(const ScaffoldGeometry(), context); + _floatingActionButtonLocation = widget.floatingActionButtonLocation ?? _kDefaultFloatingActionButtonLocation; + _floatingActionButtonAnimator = widget.floatingActionButtonAnimator ?? _kDefaultFloatingActionButtonAnimator; + _previousFloatingActionButtonLocation = _floatingActionButtonLocation; + _floatingActionButtonMoveController = new AnimationController( + vsync: this, + lowerBound: 0.0, + upperBound: 1.0, + value: 1.0, + duration: kFloatingActionButtonSegue * 2, + ); } + @override + void didUpdateWidget(Scaffold oldWidget) { + // Update the Floating Action Button Animator, and then schedule the Floating Action Button for repositioning. + if (widget.floatingActionButtonAnimator != oldWidget.floatingActionButtonAnimator) { + _floatingActionButtonAnimator = widget.floatingActionButtonAnimator ?? _kDefaultFloatingActionButtonAnimator; + } + if (widget.floatingActionButtonLocation != oldWidget.floatingActionButtonLocation) { + _moveFloatingActionButton(widget.floatingActionButtonLocation ?? _kDefaultFloatingActionButtonLocation); + } + super.didUpdateWidget(oldWidget); + } + + @override void dispose() { _snackBarController?.dispose(); - _snackBarController = null; _snackBarTimer?.cancel(); _snackBarTimer = null; _geometryNotifier.dispose(); @@ -1081,6 +1338,7 @@ class ScaffoldState extends State with TickerProviderStateMixin { bottomSheet.animationController.dispose(); if (_currentBottomSheet != null) _currentBottomSheet._widget.animationController.dispose(); + _floatingActionButtonMoveController.dispose(); super.dispose(); } @@ -1241,6 +1499,8 @@ class ScaffoldState extends State with TickerProviderStateMixin { children, new _FloatingActionButtonTransition( child: widget.floatingActionButton, + fabMoveAnimation: _floatingActionButtonMoveController, + fabMotionAnimator: _floatingActionButtonAnimator, geometryNotifier: _geometryNotifier, ), _ScaffoldSlot.floatingActionButton, @@ -1303,17 +1563,11 @@ class ScaffoldState extends State with TickerProviderStateMixin { ); } - double endPadding; - switch (textDirection) { - case TextDirection.rtl: - endPadding = mediaQuery.padding.left; - break; - case TextDirection.ltr: - endPadding = mediaQuery.padding.right; - break; - } - assert(endPadding != null); - + // The minimum insets for contents of the Scaffold to keep visible. + final EdgeInsets minInsets = mediaQuery.padding.copyWith( + bottom: widget.resizeToAvoidBottomPadding ? mediaQuery.viewInsets.bottom : 0.0, + ); + return new _ScaffoldScope( hasDrawer: hasDrawer, geometryNotifier: _geometryNotifier, @@ -1321,16 +1575,20 @@ class ScaffoldState extends State with TickerProviderStateMixin { controller: _primaryScrollController, child: new Material( color: widget.backgroundColor ?? themeData.scaffoldBackgroundColor, - child: new CustomMultiChildLayout( - children: children, - delegate: new _ScaffoldLayout( - statusBarHeight: mediaQuery.padding.top, - bottomViewInset: widget.resizeToAvoidBottomPadding ? mediaQuery.viewInsets.bottom : 0.0, - endPadding: endPadding, - textDirection: textDirection, - geometryNotifier: _geometryNotifier, - ), - ), + child: new AnimatedBuilder(animation: _floatingActionButtonMoveController, builder: (BuildContext context, Widget child) { + return new CustomMultiChildLayout( + children: children, + delegate: new _ScaffoldLayout( + minInsets: minInsets, + currentFloatingActionButtonLocation: _floatingActionButtonLocation, + floatingActionButtonMoveAnimationProgress: _floatingActionButtonMoveController.value, + floatingActionButtonMotionAnimator: _floatingActionButtonAnimator, + geometryNotifier: _geometryNotifier, + previousFloatingActionButtonLocation: _previousFloatingActionButtonLocation, + textDirection: textDirection, + ), + ); + }), ), ), ); diff --git a/packages/flutter/test/material/floating_action_button_location_test.dart b/packages/flutter/test/material/floating_action_button_location_test.dart new file mode 100644 index 00000000000..a868a2c7cd2 --- /dev/null +++ b/packages/flutter/test/material/floating_action_button_location_test.dart @@ -0,0 +1,215 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Floating action button positioner', () { + Widget build(FloatingActionButton fab, FloatingActionButtonLocation fabLocation, [_GeometryListener listener]) { + return new Directionality( + textDirection: TextDirection.ltr, + child: new MediaQuery( + data: const MediaQueryData( + viewInsets: const EdgeInsets.only(bottom: 200.0), + ), + child: new Scaffold( + appBar: new AppBar(title: const Text('FabLocation Test')), + floatingActionButtonLocation: fabLocation, + floatingActionButton: fab, + body: listener, + ), + ), + ); + } + + const FloatingActionButton fab1 = const FloatingActionButton( + onPressed: null, + child: const Text('1'), + ); + + testWidgets('still animates motion when the floating action button is null', (WidgetTester tester) async { + await tester.pumpWidget(build(null, null)); + + expect(find.byType(FloatingActionButton), findsNothing); + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget(build(null, FloatingActionButtonLocation.endFloat)); + + expect(find.byType(FloatingActionButton), findsNothing); + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pumpWidget(build(null, FloatingActionButtonLocation.centerFloat)); + + expect(find.byType(FloatingActionButton), findsNothing); + expect(tester.binding.transientCallbackCount, greaterThan(0)); + }); + + testWidgets('moves fab from center to end and back', (WidgetTester tester) async { + await tester.pumpWidget(build(fab1, FloatingActionButtonLocation.endFloat)); + + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(756.0, 356.0)); + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget(build(fab1, FloatingActionButtonLocation.centerFloat)); + + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pumpAndSettle(); + + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(400.0, 356.0)); + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget(build(fab1, FloatingActionButtonLocation.endFloat)); + + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pumpAndSettle(); + + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(756.0, 356.0)); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('moves to and from custom-defined positions', (WidgetTester tester) async { + await tester.pumpWidget(build(fab1, _kTopStartFabLocation)); + + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(44.0, 56.0)); + + await tester.pumpWidget(build(fab1, FloatingActionButtonLocation.centerFloat)); + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pumpAndSettle(); + + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(400.0, 356.0)); + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget(build(fab1, _kTopStartFabLocation)); + + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pumpAndSettle(); + + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(44.0, 56.0)); + expect(tester.binding.transientCallbackCount, 0); + + }); + + testWidgets('interrupts in-progress animations without jumps', (WidgetTester tester) async { + final _GeometryListener geometryListener = new _GeometryListener(); + ScaffoldGeometry geometry; + _GeometryListenerState listenerState; + Size previousRect; + // The maximum amounts we expect the fab width and height to change during one step of a transition. + const double maxDeltaWidth = 12.0; + const double maxDeltaHeight = 12.0; + // Measure the delta in width and height of the fab, and check that it never grows + // by more than the expected maximum deltas. + void check() { + geometry = listenerState.cache.value; + final Size currentRect = geometry.floatingActionButtonArea?.size; + // Measure the delta in width and height of the rect, and check that it never grows + // by more than a safe amount. + if (previousRect != null && currentRect != null) { + final double deltaWidth = currentRect.width - previousRect.width; + final double deltaHeight = currentRect.height - previousRect.height; + expect(deltaWidth.abs(), lessThanOrEqualTo(maxDeltaWidth), reason: "The Floating Action Button's width should not change faster than $maxDeltaWidth per animation step."); + expect(deltaHeight.abs(), lessThanOrEqualTo(maxDeltaHeight), reason: "The Floating Action Button's width should not change faster than $maxDeltaHeight per animation step."); + } + previousRect = currentRect; + } + + // We'll listen to the Scaffold's geometry for any 'jumps' to a size of 1 to detect changes in the size and rotation of the fab. + // Creating a scaffold with the fab at endFloat + await tester.pumpWidget(build(fab1, FloatingActionButtonLocation.endFloat, geometryListener)); + + listenerState = tester.state(find.byType(_GeometryListener)); + listenerState.geometryListenable.addListener(check); + + // Moving the fab to centerFloat' + await tester.pumpWidget(build(fab1, FloatingActionButtonLocation.centerFloat, geometryListener)); + await tester.pumpAndSettle(); + + // Moving the fab to the top start after finishing the previous motion + await tester.pumpWidget(build(fab1, _kTopStartFabLocation, geometryListener)); + + // Interrupting motion to move to the end float + await tester.pumpWidget(build(fab1, FloatingActionButtonLocation.endFloat, geometryListener)); + await tester.pumpAndSettle(); + }); + + }); +} + + +class _GeometryListener extends StatefulWidget { + @override + State createState() => new _GeometryListenerState(); +} + +class _GeometryListenerState extends State<_GeometryListener> { + @override + Widget build(BuildContext context) { + return new CustomPaint( + painter: cache + ); + } + + int numNotifications = 0; + ValueListenable geometryListenable; + _GeometryCachePainter cache; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final ValueListenable newListenable = Scaffold.geometryOf(context); + if (geometryListenable == newListenable) + return; + + if (geometryListenable != null) + geometryListenable.removeListener(onGeometryChanged); + + geometryListenable = newListenable; + geometryListenable.addListener(onGeometryChanged); + cache = new _GeometryCachePainter(geometryListenable); + } + + void onGeometryChanged() { + numNotifications += 1; + } +} + + +// The Scaffold.geometryOf() value is only available at paint time. +// To fetch it for the tests we implement this CustomPainter that just +// caches the ScaffoldGeometry value in its paint method. +class _GeometryCachePainter extends CustomPainter { + _GeometryCachePainter(this.geometryListenable) : super(repaint: geometryListenable); + + final ValueListenable geometryListenable; + + ScaffoldGeometry value; + @override + void paint(Canvas canvas, Size size) { + value = geometryListenable.value; + } + + @override + bool shouldRepaint(_GeometryCachePainter oldDelegate) { + return true; + } +} + +const _TopStartFabLocation _kTopStartFabLocation = const _TopStartFabLocation(); + +class _TopStartFabLocation extends FloatingActionButtonLocation { + const _TopStartFabLocation(); + + @override + Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) { + final double fabX = 16.0 + scaffoldGeometry.minInsets.left; + final double fabY = scaffoldGeometry.contentTop - (scaffoldGeometry.floatingActionButtonSize.height / 2.0); + return new Offset(fabX, fabY); + } +} \ No newline at end of file diff --git a/packages/flutter/test/material/scaffold_test.dart b/packages/flutter/test/material/scaffold_test.dart index 72ea8f8ffba..62af36b31eb 100644 --- a/packages/flutter/test/material/scaffold_test.dart +++ b/packages/flutter/test/material/scaffold_test.dart @@ -109,7 +109,7 @@ void main() { expect(bodyBox.size, equals(const Size(800.0, 0.0))); }); - testWidgets('Floating action animation', (WidgetTester tester) async { + testWidgets('Floating action entrance/exit animation', (WidgetTester tester) async { await tester.pumpWidget(new MaterialApp(home: const Scaffold( floatingActionButton: const FloatingActionButton( key: const Key('one'), @@ -131,7 +131,9 @@ void main() { expect(tester.binding.transientCallbackCount, greaterThan(0)); await tester.pumpWidget(new Container()); expect(tester.binding.transientCallbackCount, 0); + await tester.pumpWidget(new MaterialApp(home: const Scaffold())); + expect(tester.binding.transientCallbackCount, 0); await tester.pumpWidget(new MaterialApp(home: const Scaffold( @@ -145,7 +147,7 @@ void main() { expect(tester.binding.transientCallbackCount, greaterThan(0)); }); - testWidgets('Floating action button position', (WidgetTester tester) async { + testWidgets('Floating action button directionality', (WidgetTester tester) async { Widget build(TextDirection textDirection) { return new Directionality( textDirection: textDirection, @@ -168,6 +170,7 @@ void main() { expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(756.0, 356.0)); await tester.pumpWidget(build(TextDirection.rtl)); + expect(tester.binding.transientCallbackCount, 0); expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(44.0, 356.0)); }); @@ -779,13 +782,13 @@ void main() { bottomNavigationBar: new ConstrainedBox( key: key, constraints: const BoxConstraints.expand(height: 80.0), - child: new GeometryListener(), + child: new _GeometryListener(), ), ))); final RenderBox navigationBox = tester.renderObject(find.byKey(key)); final RenderBox appBox = tester.renderObject(find.byType(MaterialApp)); - final GeometryListenerState listenerState = tester.state(find.byType(GeometryListener)); + final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); final ScaffoldGeometry geometry = listenerState.cache.value; expect( @@ -798,11 +801,11 @@ void main() { await tester.pumpWidget(new MaterialApp(home: new Scaffold( body: new ConstrainedBox( constraints: const BoxConstraints.expand(height: 80.0), - child: new GeometryListener(), + child: new _GeometryListener(), ), ))); - final GeometryListenerState listenerState = tester.state(find.byType(GeometryListener)); + final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); final ScaffoldGeometry geometry = listenerState.cache.value; expect( @@ -817,13 +820,13 @@ void main() { body: new Container(), floatingActionButton: new FloatingActionButton( key: key, - child: new GeometryListener(), + child: new _GeometryListener(), onPressed: () {}, ), ))); final RenderBox floatingActionButtonBox = tester.renderObject(find.byKey(key)); - final GeometryListenerState listenerState = tester.state(find.byType(GeometryListener)); + final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); final ScaffoldGeometry geometry = listenerState.cache.value; final Rect fabRect = floatingActionButtonBox.localToGlobal(Offset.zero) & floatingActionButtonBox.size; @@ -838,11 +841,11 @@ void main() { await tester.pumpWidget(new MaterialApp(home: new Scaffold( body: new ConstrainedBox( constraints: const BoxConstraints.expand(height: 80.0), - child: new GeometryListener(), + child: new _GeometryListener(), ), ))); - final GeometryListenerState listenerState = tester.state(find.byType(GeometryListener)); + final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); final ScaffoldGeometry geometry = listenerState.cache.value; expect( @@ -851,12 +854,12 @@ void main() { ); }); - testWidgets('floatingActionButton animation', (WidgetTester tester) async { + testWidgets('floatingActionButton entrance/exit animation', (WidgetTester tester) async { final GlobalKey key = new GlobalKey(); await tester.pumpWidget(new MaterialApp(home: new Scaffold( body: new ConstrainedBox( constraints: const BoxConstraints.expand(height: 80.0), - child: new GeometryListener(), + child: new _GeometryListener(), ), ))); @@ -864,12 +867,12 @@ void main() { body: new Container(), floatingActionButton: new FloatingActionButton( key: key, - child: new GeometryListener(), + child: new _GeometryListener(), onPressed: () {}, ), ))); - final GeometryListenerState listenerState = tester.state(find.byType(GeometryListener)); + final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); await tester.pump(const Duration(milliseconds: 50)); ScaffoldGeometry geometry = listenerState.cache.value; @@ -908,11 +911,11 @@ void main() { await tester.pumpWidget(new MaterialApp(home: new Scaffold( body: new ConstrainedBox( constraints: const BoxConstraints.expand(height: 80.0), - child: new GeometryListener(), + child: new _GeometryListener(), ), ))); - final GeometryListenerState listenerState = tester.state(find.byType(GeometryListener)); + final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); expect(listenerState.numNotifications, greaterThan(numNotificationsAtLastFrame)); numNotificationsAtLastFrame = listenerState.numNotifications; @@ -921,7 +924,7 @@ void main() { body: new Container(), floatingActionButton: new FloatingActionButton( key: key, - child: new GeometryListener(), + child: new _GeometryListener(), onPressed: () {}, ), ))); @@ -946,13 +949,13 @@ void main() { home: new Scaffold( body: new ConstrainedBox( constraints: const BoxConstraints.expand(height: 80.0), - child: new GeometryListener(), + child: new _GeometryListener(), ), - floatingActionButton: new ComputeNotchSetter(computeNotch), + floatingActionButton: new _ComputeNotchSetter(computeNotch), ) )); - final GeometryListenerState listenerState = tester.state(find.byType(GeometryListener)); + final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); ScaffoldGeometry geometry = listenerState.cache.value; expect( @@ -964,7 +967,7 @@ void main() { home: new Scaffold( body: new ConstrainedBox( constraints: const BoxConstraints.expand(height: 80.0), - child: new GeometryListener(), + child: new _GeometryListener(), ), ) )); @@ -985,13 +988,13 @@ void main() { home: new Scaffold( body: new ConstrainedBox( constraints: const BoxConstraints.expand(height: 80.0), - child: new GeometryListener(), + child: new _GeometryListener(), ), - floatingActionButton: new ComputeNotchSetter(computeNotch), + floatingActionButton: new _ComputeNotchSetter(computeNotch), ) )); - final ComputeNotchSetterState computeNotchSetterState = tester.state(find.byType(ComputeNotchSetter)); + final _ComputeNotchSetterState computeNotchSetterState = tester.state(find.byType(_ComputeNotchSetter)); final VoidCallback clearFirstComputeNotch = computeNotchSetterState.clearComputeNotch; @@ -1000,9 +1003,9 @@ void main() { home: new Scaffold( body: new ConstrainedBox( constraints: const BoxConstraints.expand(height: 80.0), - child: new GeometryListener(), + child: new _GeometryListener(), ), - floatingActionButton: new ComputeNotchSetter( + floatingActionButton: new _ComputeNotchSetter( computeNotch2, // We're setting a key to make sure a new ComputeNotchSetterState is // created. @@ -1019,7 +1022,7 @@ void main() { clearFirstComputeNotch(); - final GeometryListenerState listenerState = tester.state(find.byType(GeometryListener)); + final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); final ScaffoldGeometry geometry = listenerState.cache.value; expect( @@ -1030,12 +1033,12 @@ void main() { }); } -class GeometryListener extends StatefulWidget { +class _GeometryListener extends StatefulWidget { @override - State createState() => new GeometryListenerState(); + _GeometryListenerState createState() => new _GeometryListenerState(); } -class GeometryListenerState extends State { +class _GeometryListenerState extends State<_GeometryListener> { @override Widget build(BuildContext context) { return new CustomPaint( @@ -1045,7 +1048,7 @@ class GeometryListenerState extends State { int numNotifications = 0; ValueListenable geometryListenable; - GeometryCachePainter cache; + _GeometryCachePainter cache; @override void didChangeDependencies() { @@ -1059,7 +1062,7 @@ class GeometryListenerState extends State { geometryListenable = newListenable; geometryListenable.addListener(onGeometryChanged); - cache = new GeometryCachePainter(geometryListenable); + cache = new _GeometryCachePainter(geometryListenable); } void onGeometryChanged() { @@ -1070,8 +1073,8 @@ class GeometryListenerState extends State { // The Scaffold.geometryOf() value is only available at paint time. // To fetch it for the tests we implement this CustomPainter that just // caches the ScaffoldGeometry value in its paint method. -class GeometryCachePainter extends CustomPainter { - GeometryCachePainter(this.geometryListenable) : super(repaint: geometryListenable); +class _GeometryCachePainter extends CustomPainter { + _GeometryCachePainter(this.geometryListenable) : super(repaint: geometryListenable); final ValueListenable geometryListenable; @@ -1082,21 +1085,21 @@ class GeometryCachePainter extends CustomPainter { } @override - bool shouldRepaint(GeometryCachePainter oldDelegate) { + bool shouldRepaint(_GeometryCachePainter oldDelegate) { return true; } } -class ComputeNotchSetter extends StatefulWidget { - const ComputeNotchSetter(this.computeNotch, {Key key}): super(key: key); +class _ComputeNotchSetter extends StatefulWidget { + const _ComputeNotchSetter(this.computeNotch, {Key key}): super(key: key); final ComputeNotch computeNotch; @override - State createState() => new ComputeNotchSetterState(); + State createState() => new _ComputeNotchSetterState(); } -class ComputeNotchSetterState extends State { +class _ComputeNotchSetterState extends State<_ComputeNotchSetter> { VoidCallback clearComputeNotch; @override