Animations - wurzelsand/flutter-memos GitHub Wiki
Ich baue das Flutter-Tutorial mit Cubits um: Das Bild einer Eule wird angezeigt, darunter ein Button. Nach einem Druck des Buttons tauchen langsam (Fade-In-Animation) unten Details zur Eule auf. Nach einem erneuten Druck des Buttons verschwinden die Details wieder langsam.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
void main() {
runApp(
const MyApp(),
);
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Center(
child: MyPage(),
),
),
);
}
}
class MyPage extends StatelessWidget {
const MyPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => FadeCubit(Opacity.transparent),
child: const FadeInDemo(),
);
}
}
class FadeInDemo extends StatelessWidget {
const FadeInDemo({Key? key}) : super(key: key);
static const owlUrl =
'https://raw.githubusercontent.com/flutter/website/master/src/images/owl.jpg';
@override
Widget build(BuildContext context) {
return BlocBuilder<FadeCubit, Opacity>(
builder: (context, state) {
return Column(children: <Widget>[
Image.network(owlUrl),
if (state == Opacity.transparent)
TextButton(
onPressed: context.read<FadeCubit>().fadeIn,
child: const Text(
'Show Details',
style: TextStyle(color: Colors.blueAccent),
),
)
else
TextButton(
onPressed: context.read<FadeCubit>().fadeOut,
child: const Text(
'Hide Details',
style: TextStyle(color: Colors.blueAccent),
),
),
AnimatedOpacity(
duration: const Duration(seconds: 2),
opacity: state.opacity,
child: Column(
children: const [
Text('Type: Owl'),
Text('Age: 39'),
Text('Employment: None'),
],
),
)
]);
},
);
}
}
enum Opacity {
opaque(opacity: 1.0),
transparent(opacity: 0.0);
const Opacity({required this.opacity});
final double opacity;
}
class FadeCubit extends Cubit<Opacity> {
FadeCubit(Opacity opacity) : super(opacity);
void fadeIn() => emit(Opacity.opaque);
void fadeOut() => emit(Opacity.transparent);
}
-
Um das Programm auch unter MacOS laufen zu lassen (
OS Error: Operation not permitted
) gibt es zwei Dateien im Projekt unter masos/Runner/Configs: DebugProfile.entitlements und Release.entitlements. Darin ergänze ich:<key>com.apple.security.network.client</key> <true/>
-
Ich möchte eigentlich lieber, dass die neuen Label
Show Details
undHide Details
erst dann angezeigt werden, wenn die Animation vorbei ist: owl_animation.dart. Hier füge ich ein Cubit für das Button-Label hinzu. Zusätzlich benutze ich denonEnd
-Parameter desAnimatedOpacity
-Widgets.
Zwei Animationen sollen gleichzeitig an dem Dart-Logo ausgeführt werden: Zwischen durchsichtig und undurchsichtig soll gewechselt werden und zwischen groß und klein. Die Animation soll in einer Endlosschleife verlaufen.
import 'package:flutter/material.dart';
void main() {
runApp(const LogoApp());
}
class LogoApp extends StatefulWidget {
const LogoApp({Key? key}) : super(key: key);
@override
State<LogoApp> createState() => _LogoAppState();
}
class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
// #1
late final Animation<double> animation;
late final AnimationController controller;
@override
void initState() {
super.initState();
controller =
AnimationController(duration: const Duration(seconds: 2), vsync: this);
animation =
CurvedAnimation(parent: controller, curve: Curves.slowMiddle) // #2
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.reverse();
} else if (status == AnimationStatus.dismissed) {
controller.forward();
}
});
controller.forward();
}
@override
Widget build(BuildContext context) {
return SizeOpacityTransition(
animation: animation,
child: const FlutterLogo(),
);
}
@override
void dispose() {
controller.dispose(); // #3
super.dispose();
}
}
class SizeOpacityTransition extends StatelessWidget {
const SizeOpacityTransition(
{Key? key, required this.child, required this.animation})
: super(key: key);
final Widget child;
final Animation<double> animation;
static final _opacityTween = Tween<double>(begin: 0.1, end: 1);
static final _sizeTween = Tween<double>(begin: 0, end: 300);
@override
Widget build(BuildContext context) {
return Center(
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Opacity(
opacity: _opacityTween.evaluate(animation),
child: SizedBox(
height: _sizeTween.evaluate(animation),
width: _sizeTween.evaluate(animation),
child: child,
),
);
},
child: child),
);
}
}
-
SingleTickerProviderStateMixin
: Für denvsync
-Parameter desAnimationController
-Konstruktors und für die Methodedispose
. -
Wenn nur ein linearer Verlauf benötigt wird, könnte ich hier auch den
AnimationController
übergeben, denn einAnimationController
ist einAnimation
mit zusätzlichen Eigenschaften. -
Der
AnimationController
muss explizit frei gegeben werden.
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({Key? key}) : super(key: key);
@override
State createState() => _MyAppState();
}
class _MyAppState extends State with SingleTickerProviderStateMixin {
late Animation<double> animation;
late AnimationController controller;
@override
void initState() {
super.initState();
controller =
AnimationController(duration: const Duration(seconds: 2), vsync: this);
animation = CurvedAnimation(parent: controller, curve: Curves.easeInOut);
}
void run() {
switch (controller.status) {
case AnimationStatus.forward:
case AnimationStatus.completed:
controller.reverse();
break;
default:
controller.forward();
}
}
@override
Widget build(BuildContext context) {
return AnimatedFlutterLogo(animation: animation, onTap: run);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
class AnimatedFlutterLogo extends StatelessWidget {
const AnimatedFlutterLogo(
{Key? key, required this.animation, required this.onTap})
: super(key: key);
final Animation<double> animation;
final VoidCallback onTap;
static final _sizeTween = Tween<double>(begin: 100, end: 300);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Center(
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
return SizedBox(
width: _sizeTween.evaluate(animation),
height: _sizeTween.evaluate(animation),
child: child);
},
child: ClipOval(
child: Material( // #1
color: Theme.of(context).primaryColor.withOpacity(0.25),
child: InkWell(onTap: onTap, child: const FlutterLogo())))),
),
);
}
}
- Why
Material
? Because ofInkWell
: Must have an ancestor [Material] widget in which to cause ink reactions.
import 'package:flutter/material.dart';
import 'dart:math' as math;
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({Key? key}) : super(key: key);
@override
State createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with SingleTickerProviderStateMixin {
late Animation<double> animation;
late AnimationController controller;
@override
void initState() {
super.initState();
controller =
AnimationController(duration: const Duration(seconds: 3), vsync: this);
animation = CurvedAnimation(parent: controller, curve: Curves.easeInOut);
}
void run() {
switch (controller.status) {
case AnimationStatus.forward:
case AnimationStatus.completed:
controller.reverse();
break;
default:
controller.forward();
}
}
@override
Widget build(BuildContext context) {
return AnimatedFlutterLogo(animation: animation, onTap: run);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
class AnimatedFlutterLogo extends StatelessWidget {
const AnimatedFlutterLogo(
{Key? key, required this.animation, required this.onTap})
: super(key: key);
final Animation<double> animation;
final VoidCallback onTap;
static const _minRadius = 32.0;
static const _maxRadius = 128.0;
static const _clipRectExtent = 2.0 * (_maxRadius / math.sqrt2);
static final _radiusTween = Tween<double>(begin: _minRadius, end: _maxRadius);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: AnimatedBuilder( // #1
animation: animation,
builder: (context, child) {
return Center(
child: SizedBox(
width: 2 * _radiusTween.evaluate(animation),
height: 2 * _radiusTween.evaluate(animation),
child: ClipOval(
child: Center(
child: SizedBox(
width: _clipRectExtent,
height: _clipRectExtent,
child: ClipRect(
child: child,
),
),
),
),
),
);
},
child: Material(
color: Theme.of(context).primaryColor.withOpacity(0.25),
child: InkWell(
onTap: onTap, child: const FittedBox(child: Text('Hello'))))),
);
}
}
-
AnimatedBuilder
containsSizedBox
, whose size grows up to twice the maximum radius (_maxRadius = 128.0
). Thus the radius of the containedClipOval
grows up to the maximum radius. After that, aCenter
widget ensures that the nextSizedBox
is an independent size that remains the same throughout the animation. This size is the maximum size that the lastchild
("Hello"
-text) reaches.
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show timeDilation;
class InkText extends StatelessWidget {
const InkText({super.key, required this.label, this.onTap});
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
// Slightly opaque color appears where the image has transparency.
// Makes it possible to see the radial transformation's boundary.
color: Theme.of(context).primaryColor.withOpacity(0.25),
child: InkWell(onTap: onTap, child: FittedBox(child: Text(label))),
);
}
}
// RadialExpansion will clip its `child` depending on how it is constrained. For
// example, if `RadialExpansion` is constrained by a `SizedBox` whose dimensions
// are small, the `child` will be circularly clipped. If its dimensions are
// large, the `child` will appear as a square. If its dimensions are in between,
// the clipping results in a combination of square and circle.
class RadialExpansion extends StatelessWidget {
const RadialExpansion({
super.key,
required this.maxRadius,
this.child,
}) : clipRectExtent = 2.0 * (maxRadius / math.sqrt2);
final double maxRadius;
final double clipRectExtent;
final Widget? child;
@override
Widget build(BuildContext context) {
// The ClipOval matches the RadialExpansion widget's bounds,
// which change per the Hero's bounds as the Hero flies to
// the new route, while the ClipRect's bounds are always fixed.
return ClipOval(
child: Center(
child: SizedBox(
width: clipRectExtent,
height: clipRectExtent,
child: ClipRect(
child: child,
),
),
),
);
}
}
class RadialExpansionDemo extends StatelessWidget {
const RadialExpansionDemo({super.key});
static double kMinRadius = 32.0;
static double kMaxRadius = 128.0;
static Widget _buildPage(BuildContext context, String label) { // #3
return Container(
color: Theme.of(context).canvasColor,
alignment: Alignment.topCenter,
child: SizedBox(
width: kMaxRadius * 2.0,
height: kMaxRadius * 2.0,
child: Hero(
tag: label,
child: RadialExpansion(
maxRadius: kMaxRadius,
child: InkText(
label: label,
onTap: () {
Navigator.of(context).pop();
},
),
),
),
),
);
}
Widget _buildHero(BuildContext context, String label) { // #2
return SizedBox(
width: kMinRadius * 2.0,
height: kMinRadius * 2.0,
child: Hero(
tag: label,
child: RadialExpansion(
maxRadius: kMaxRadius,
child: InkText(
label: label,
onTap: () {
Navigator.of(context).push(
PageRouteBuilder<void>(
pageBuilder: (__context, animation, __secondaryAnimation) {
return _buildPage(context, label);
},
),
);
},
),
),
),
);
}
@override
Widget build(BuildContext context) {
timeDilation = 20.0; // 1.0 is normal animation speed.
return Scaffold(
appBar: AppBar(
title: const Text('Basic Radial Hero Animation Demo'),
),
body: Container(
padding: const EdgeInsets.all(32.0),
alignment: Alignment.bottomLeft,
child: Row( // #1
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildHero(context, 'Cu'),
_buildHero(context, 'Ag'),
_buildHero(context, 'Au'),
],
),
),
);
}
}
void main() {
runApp(
const MaterialApp(
home: RadialExpansionDemo(),
),
);
}
- If a new
Hero
widget is to be built, it first remembers the position and size at which its subtree is to be built. Then it searches for an already existingHero
widget with the sametag
. If one is found, the newHero
widget takes over its position and dimensions as the start for the subsequentHero
flight. The oldHero
subtree disappears immediately. Then the newHero
widget flies to its destination, which it had remembered before. During this flight, theHero
widget is scaled, as if it were embedded in aSizedBox
whose size is animated. How this scaling animation proceeds can be specified with thecreateRectTween
parameter ofHero
. By default the width is scaled first and then the height. We will improve this in the next example. However, even if height and width are scaled at the same time so that the circle does not become elliptical in flight, the transition is a bit bumpy: the transition from circle to square starts too late. Also, it is noticeable that when the oldHero
disappears, the page of the oldRoute
is abruptly covered by the page of the newRoute
. This should also be improved, for example by having the page of the newroute
gradually cover the old page. - flutter.dev: Hero class
- With
_buildHero
three widgets are built in a row. Since the widgets are inRow
, they can determine their own size. - The initial
Hero
is in a relatively smallSizedBox
and aRadialExpansion
widget with a largemaxRadius
. Therefore it is circular. - The target
Hero
is located in a relatively largeSizedBox
and aRadialExpansion
widget with the samemaxRadius
as the initialHero
. So the targetHero
will be square after its flight.
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show timeDilation;
void main() {
runApp(
const MaterialApp(
home: RadialExpansionDemo(),
),
);
}
class RadialExpansionDemo extends StatelessWidget {
const RadialExpansionDemo({super.key});
static const double kMinRadius = 32.0;
static const double kMaxRadius = 128.0;
static const opacityCurve =
Interval(0.0, 0.75, curve: Curves.fastOutSlowIn); // #1
// static?
RectTween _createRectTween(Rect? begin, Rect? end) {
return MaterialRectCenterArcTween(begin: begin, end: end); // #2
}
// static?
Widget _buildPage(BuildContext context, String label) {
return Container(
color: Theme.of(context).canvasColor,
alignment: Alignment.topCenter,
child: SizedBox(
width: kMaxRadius * 2.0,
height: kMaxRadius * 2.0,
child: Hero(
tag: label,
createRectTween: _createRectTween,
child: RadialExpansion(
minRadius: kMinRadius,
maxRadius: kMaxRadius,
child: InkText(
label: label,
onTap: () {
Navigator.of(context).pop();
},
),
),
),
),
);
}
Widget _buildHero(BuildContext context, String label) {
return SizedBox(
width: kMinRadius * 2.0,
height: kMinRadius * 2.0,
child: Hero(
tag: label,
createRectTween: _createRectTween,
child: RadialExpansion(
minRadius: kMinRadius,
maxRadius: kMaxRadius,
child: InkText(
label: label,
onTap: () {
Navigator.of(context).push(
PageRouteBuilder<void>(
pageBuilder: (context, animation, secondaryAnimation) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Opacity(
opacity: opacityCurve.transform(animation.value),
child: _buildPage(context, label),
);
},
);
},
),
);
},
),
),
),
);
}
@override
Widget build(BuildContext context) {
timeDilation = 20.0; // 1.0 is normal animation speed.
return Scaffold(
appBar: AppBar(
title: const Text('Radial Hero Animation Demo'),
),
body: Container(
padding: const EdgeInsets.all(32.0),
alignment: Alignment.bottomLeft,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildHero(context, 'Cu'),
_buildHero(context, 'Ag'),
_buildHero(context, 'Au'),
],
),
),
);
}
}
// RadialExpansion will clip its `child` depending on how it is constrained. For
// example, if `RadialExpansion` is constrained by a `SizedBox` whose dimensions
// are small, the `child` will be circularly clipped. If its dimensions are
// large, the `child` will appear as a square. If its dimensions are in between,
// the clipping results in a combination of square and circle.
class RadialExpansion extends StatelessWidget {
RadialExpansion({
super.key,
required this.minRadius,
required this.maxRadius,
this.child,
}) : clipTween = Tween<double>(
begin: 2 * minRadius, end: 2 * (maxRadius / math.sqrt2));
final double minRadius;
final double maxRadius;
final Tween<double> clipTween;
final Widget? child;
@override
Widget build(BuildContext context) {
// The ClipOval matches the RadialExpansion widget's bounds,
// which change per the Hero's bounds as the Hero flies to
// the new route, while the ClipRect's bounds depend on the constraints of
// the LayoutBuilder: the larger the allowed extents, the larger ClipRect
// will be.
return LayoutBuilder(
builder: (context, constraints) {
final double t =
(constraints.maxWidth / 2 - minRadius) / (maxRadius - minRadius);
final clipRectExtent = clipTween.transform(t); // #3
return ClipOval(
child: Center(
child: SizedBox(
width: clipRectExtent,
height: clipRectExtent,
child: ClipRect(
child: child,
),
),
),
);
},
);
}
}
class InkText extends StatelessWidget {
const InkText({super.key, required this.label, this.onTap});
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
// Slightly opaque color appears where the image has transparency.
// Makes it possible to see the radial transformation's boundary.
color: Theme.of(context).primaryColor.withOpacity(0.25),
child: InkWell(
onTap: onTap,
child: FittedBox(
child: Text(label),
),
),
);
}
}
-
A curve that has already reached its final value after 75 percent of the animation.
-
A
RectTween
forHero
: The constraint boundaries are now scaled quadratically. -
t
indicates what percentage of the animation has already run.0.5
would mean that half of the animation is already over and thereforeclipRectExtent
must be the average ofclipTween.begin
andclipTween.end
(linear interpolation withtransform
). How much percent of the animation must have already passed can be seen by how much percent of the total distance betweenmaxRadius
andminRadius
has already passed throughconstraints
.To test
RadialExpansion
seperately: radial_expansion_test.dart