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 DetailsundHide Detailserst 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 einAnimationControllerist einAnimationmit zusätzlichen Eigenschaften. -
Der
AnimationControllermuss 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'))))),
);
}
}-
AnimatedBuildercontainsSizedBox, whose size grows up to twice the maximum radius (_maxRadius = 128.0). Thus the radius of the containedClipOvalgrows up to the maximum radius. After that, aCenterwidget ensures that the nextSizedBoxis 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
Herowidget 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 existingHerowidget with the sametag. If one is found, the newHerowidget takes over its position and dimensions as the start for the subsequentHeroflight. The oldHerosubtree disappears immediately. Then the newHerowidget flies to its destination, which it had remembered before. During this flight, theHerowidget is scaled, as if it were embedded in aSizedBoxwhose size is animated. How this scaling animation proceeds can be specified with thecreateRectTweenparameter 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 oldHerodisappears, the page of the oldRouteis abruptly covered by the page of the newRoute. This should also be improved, for example by having the page of the newroutegradually cover the old page. - flutter.dev: Hero class
- With
_buildHerothree widgets are built in a row. Since the widgets are inRow, they can determine their own size. - The initial
Herois in a relatively smallSizedBoxand aRadialExpansionwidget with a largemaxRadius. Therefore it is circular. - The target
Herois located in a relatively largeSizedBoxand aRadialExpansionwidget with the samemaxRadiusas the initialHero. So the targetHerowill 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
RectTweenforHero: The constraint boundaries are now scaled quadratically. -
tindicates what percentage of the animation has already run.0.5would mean that half of the animation is already over and thereforeclipRectExtentmust be the average ofclipTween.beginandclipTween.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 betweenmaxRadiusandminRadiushas already passed throughconstraints.To test
RadialExpansionseperately: radial_expansion_test.dart