Animations - wurzelsand/flutter-memos GitHub Wiki

Animations

FadeIn und FadeOut mit Cubits

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.

Ausführung

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

Anmerkungen

  • 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/>
    

    flutter.dev: setting up entitlements

  • Ich möchte eigentlich lieber, dass die neuen Label Show Details und Hide 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 den onEnd-Parameter des AnimatedOpacity-Widgets.

Transition

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.

Ausführung

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),
    );
  }
}

Anmerkungen

  1. SingleTickerProviderStateMixin: Für den vsync-Parameter des AnimationController-Konstruktors und für die Methode dispose.

  2. Wenn nur ein linearer Verlauf benötigt wird, könnte ich hier auch den AnimationController übergeben, denn ein AnimationController ist ein Animation mit zusätzlichen Eigenschaften.

  3. Der AnimationController muss explizit frei gegeben werden.

Flutter logo explicit animation

Implementation

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())))),
      ),
    );
  }
}
  1. Why Material? Because of InkWell: Must have an ancestor [Material] widget in which to cause ink reactions.

Expand circle to square

Implementation

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'))))),
    );
  }
}

Notes

  1. AnimatedBuilder contains SizedBox, whose size grows up to twice the maximum radius (_maxRadius = 128.0). Thus the radius of the contained ClipOval grows up to the maximum radius. After that, a Center widget ensures that the next SizedBox is an independent size that remains the same throughout the animation. This size is the maximum size that the last child ("Hello"-text) reaches.

First basic radial hero animation

Implementation

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(),
    ),
  );
}

Notes

  • 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 existing Hero widget with the same tag. If one is found, the new Hero widget takes over its position and dimensions as the start for the subsequent Hero flight. The old Hero subtree disappears immediately. Then the new Hero widget flies to its destination, which it had remembered before. During this flight, the Hero widget is scaled, as if it were embedded in a SizedBox whose size is animated. How this scaling animation proceeds can be specified with the createRectTween parameter of Hero. 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 old Hero disappears, the page of the old Route is abruptly covered by the page of the new Route. This should also be improved, for example by having the page of the new route gradually cover the old page.
  • flutter.dev: Hero class
  1. With _buildHero three widgets are built in a row. Since the widgets are in Row, they can determine their own size.
  2. The initial Hero is in a relatively small SizedBox and a RadialExpansion widget with a large maxRadius. Therefore it is circular.
  3. The target Hero is located in a relatively large SizedBox and a RadialExpansion widget with the same maxRadius as the initial Hero. So the target Hero will be square after its flight.

Improved radial hero animation

Implementation

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),
        ),
      ),
    );
  }
}

Notes

  1. A curve that has already reached its final value after 75 percent of the animation.

  2. A RectTween for Hero: The constraint boundaries are now scaled quadratically.

  3. t indicates what percentage of the animation has already run. 0.5 would mean that half of the animation is already over and therefore clipRectExtent must be the average of clipTween.begin and clipTween.end (linear interpolation with transform). How much percent of the animation must have already passed can be seen by how much percent of the total distance between maxRadius and minRadius has already passed through constraints.

    To test RadialExpansion seperately: radial_expansion_test.dart

⚠️ **GitHub.com Fallback** ⚠️