how to add animation for theme switching in flutter?

3,680

Solution 1

It’s not hard, but you need to do several things.

  1. You need to create your own theme styles. I’ve used inherited widget to do it. (If you change ThemeData widget it will animate the change, and we don’t need it, that’s why I’m saving Colors in another class)
  2. Find the button (or in my case switcher) coordinates.
  3. Run animation.

enter image description here

update! I've converted our code to a package with simple api.

import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BrandTheme(
      child: Builder(builder: (context) {
        return MaterialApp(
          title: 'Flutter Demo',
          theme: BrandTheme.of(context).themeData,
          home: MyHomePage(),
        );
      }),
    );
  }
}

GlobalKey switherGlobalKey = GlobalKey();

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key}) : super(key: key);

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  AnimationController _controller;

  @override
  void initState() {
    _controller = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );

    _controller.forward();
    super.initState();
  }

  @override
  void dispose() {
    _controller.dispose();

    super.dispose();
  }

  int _counter = 0;
  BrandThemeModel oldTheme;
  Offset switcherOffset;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  _getPage(brandTheme, {isFirst = false}) {
    return Scaffold(
      backgroundColor: brandTheme.color2,
      appBar: AppBar(
        backgroundColor: brandTheme.color1,
        title: Text(
          'Flutter Demo Home Page',
          style: TextStyle(color: brandTheme.textColor2),
        ),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
              style: TextStyle(
                color: brandTheme.textColor1,
              ),
            ),
            Text(
              '$_counter',
              style: TextStyle(color: brandTheme.textColor1, fontSize: 200),
            ),
            Switch(
              key: isFirst ? switherGlobalKey : null,
              onChanged: (needDark) {
                oldTheme = brandTheme;
                BrandTheme.instanceOf(context).changeTheme(
                  needDark ? BrandThemeKey.dark : BrandThemeKey.light,
                );
              },
              value: BrandTheme.of(context).brightness == Brightness.dark,
            )
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(
          Icons.add,
        ),
      ),
    );
  }

  @override
  void didUpdateWidget(Widget oldWidget) {
    var theme = BrandTheme.of(context);
    if (theme != oldTheme) {
      _getSwitcherCoodinates();
      _controller.reset();
      _controller.forward().then(
        (_) {
          oldTheme = theme;
        },
      );
    }
    super.didUpdateWidget(oldWidget);
  }

  void _getSwitcherCoodinates() {
    RenderBox renderObject = switherGlobalKey.currentContext.findRenderObject();
    switcherOffset = renderObject.localToGlobal(Offset.zero);
  }

  @override
  Widget build(BuildContext context) {
    var brandTheme = BrandTheme.of(context);

    if (oldTheme == null) {
      return _getPage(brandTheme, isFirst: true);
    }
    return Stack(
      children: <Widget>[
        if(oldTheme != null) _getPage(oldTheme),
        AnimatedBuilder(
          animation: _controller,
          child: _getPage(brandTheme, isFirst: true),
          builder: (_, child) {
            return ClipPath(
              clipper: MyClipper(
                sizeRate: _controller.value,
                offset: switcherOffset.translate(30, 15),
              ),
              child: child,
            );
          },
        ),
      ],
    );
  }
}

class MyClipper extends CustomClipper<Path> {
  MyClipper({this.sizeRate, this.offset});
  final double sizeRate;
  final Offset offset;

  @override
  Path getClip(Size size) {
    var path = Path()
      ..addOval(
        Rect.fromCircle(center: offset, radius: size.height * sizeRate),
      );

    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) => true;
}

class BrandTheme extends StatefulWidget {
  final Widget child;

  BrandTheme({
    Key key,
    @required this.child,
  }) : super(key: key);

  @override
  BrandThemeState createState() => BrandThemeState();

  static BrandThemeModel of(BuildContext context) {
    final inherited =
        (context.dependOnInheritedWidgetOfExactType<_InheritedBrandTheme>());
    return inherited.data.brandTheme;
  }

  static BrandThemeState instanceOf(BuildContext context) {
    final inherited =
        (context.dependOnInheritedWidgetOfExactType<_InheritedBrandTheme>());
    return inherited.data;
  }
}

class BrandThemeState extends State<BrandTheme> {
  BrandThemeModel _brandTheme;

  BrandThemeModel get brandTheme => _brandTheme;

  @override
  void initState() {
    final isPlatformDark =
        WidgetsBinding.instance.window.platformBrightness == Brightness.dark;
    final themeKey = isPlatformDark ? BrandThemeKey.dark : BrandThemeKey.light;
    _brandTheme = BrandThemes.getThemeFromKey(themeKey);
    super.initState();
  }

  void changeTheme(BrandThemeKey themeKey) {
    setState(() {
      _brandTheme = BrandThemes.getThemeFromKey(themeKey);
    });
  }

  @override
  Widget build(BuildContext context) {
    return _InheritedBrandTheme(
      data: this,
      child: widget.child,
    );
  }
}

class _InheritedBrandTheme extends InheritedWidget {
  final BrandThemeState data;

  _InheritedBrandTheme({
    this.data,
    Key key,
    @required Widget child,
  }) : super(key: key, child: child);

  @override
  bool updateShouldNotify(_InheritedBrandTheme oldWidget) {
    return true;
  }
}

ThemeData defaultThemeData = ThemeData(
  floatingActionButtonTheme: FloatingActionButtonThemeData(
    shape: RoundedRectangleBorder(),
  ),
);

class BrandThemeModel extends Equatable {
  final Color color1;
  final Color color2;

  final Color textColor1;
  final Color textColor2;
  final ThemeData themeData;
  final Brightness brightness;

  BrandThemeModel({
    @required this.color1,
    @required this.color2,
    @required this.textColor1,
    @required this.textColor2,
    @required this.brightness,
  }) : themeData = defaultThemeData.copyWith(brightness: brightness);

  @override
  List<Object> get props => [
        color1,
        color2,
        textColor1,
        textColor2,
        themeData,
        brightness,
      ];
}

enum BrandThemeKey { light, dark }

class BrandThemes {
  static BrandThemeModel getThemeFromKey(BrandThemeKey themeKey) {
    switch (themeKey) {
      case BrandThemeKey.light:
        return lightBrandTheme;
      case BrandThemeKey.dark:
        return darkBrandTheme;
      default:
        return lightBrandTheme;
    }
  }
}

BrandThemeModel lightBrandTheme = BrandThemeModel(
  brightness: Brightness.light,
  color1: Colors.blue,
  color2: Colors.white,
  textColor1: Colors.black,
  textColor2: Colors.white,
);

BrandThemeModel darkBrandTheme = BrandThemeModel(
  brightness: Brightness.dark,
  color1: Colors.red,
  color2: Colors.black,
  textColor1: Colors.blue,
  textColor2: Colors.yellow,
);

class ThemeRoute extends PageRouteBuilder {
  ThemeRoute(this.widget)
      : super(
          pageBuilder: (
            context,
            animation,
            secondaryAnimation,
          ) =>
              widget,
          transitionsBuilder: transitionsBuilder,
        );

  final Widget widget;
}

Widget transitionsBuilder(
  BuildContext context,
  Animation<double> animation,
  Animation<double> secondaryAnimation,
  Widget child,
) {
  var _animation = Tween<double>(
    begin: 0,
    end: 100,
  ).animate(animation);
  return SlideTransition(
    position: Tween<Offset>(
      begin: const Offset(0, 1),
      end: Offset.zero,
    ).animate(animation),
    child: Container(
      child: child,
    ),
  );
}

Solution 2

While @Kherel's answer above works perfectly fine, I wanted to share my version of this effect.

class DarkTransition extends StatefulWidget {
  const DarkTransition(
      {required this.childBuilder,
      Key? key,
      this.offset = Offset.zero,
      this.themeController,
      this.radius,
      this.duration = const Duration(milliseconds: 400),
      this.isDark = false})
      : super(key: key);

  /// Deinfe the widget that will be transitioned
  /// int index is either 1 or 2 to identify widgets, 2 is the top widget
  final Widget Function(BuildContext, int) childBuilder;

  /// the current state of the theme
  final bool isDark;

  /// optional animation controller to controll the animation
  final AnimationController? themeController;

  /// centeral point of the circular transition
  final Offset offset;

  /// optional radius of the circle defaults to [max(height,width)*1.5])
  final double? radius;

  /// duration of animation defaults to 400ms
  final Duration? duration;

  @override
  _DarkTransitionState createState() => _DarkTransitionState();
}

class _DarkTransitionState extends State<DarkTransition>
    with SingleTickerProviderStateMixin {
  @override
  void dispose() {
    _darkNotifier.dispose();
    super.dispose();
  }

  final _darkNotifier = ValueNotifier<bool>(false);

  @override
  void initState() {
    super.initState();
    if (widget.themeController == null) {
      _animationController =
          AnimationController(vsync: this, duration: widget.duration);
    } else {
      _animationController = widget.themeController!;
    }
  }

  double _radius(Size size) {
    final maxVal = max(size.width, size.height);
    return maxVal * 1.5;
  }

  late AnimationController _animationController;
  double x = 0;
  double y = 0;
  bool isDark = false;
  // bool isBottomThemeDark = true;
  bool isDarkVisible = false;
  late double radius;
  Offset position = Offset.zero;

  ThemeData getTheme(bool dark) {
    if (dark)
      return ThemeData.dark();
    else
      return ThemeData.light();
  }

  @override
  void didUpdateWidget(DarkTransition oldWidget) {
    super.didUpdateWidget(oldWidget);
    _darkNotifier.value = widget.isDark;
    if (widget.isDark != oldWidget.isDark) {
      if (isDark) {
        _animationController.reverse();
        _darkNotifier.value = false;
      } else {
        _animationController.reset();
        _animationController.forward();
        _darkNotifier.value = true;
      }
      position = widget.offset;
    }
    if (widget.radius != oldWidget.radius) {
      _updateRadius();
    }
    if (widget.duration != oldWidget.duration) {
      _animationController.duration = widget.duration;
    }
  }

  @override
  void didChangeDependencies() {
    // TODO: implement didChangeDependencies
    super.didChangeDependencies();
    _updateRadius();
  }

  void _updateRadius() {
    final size = MediaQuery.of(context).size;
    if (widget.radius == null)
      radius = _radius(size);
    else
      radius = widget.radius!;
  }

  @override
  Widget build(BuildContext context) {
    isDark = _darkNotifier.value;
    Widget _body(int index) {
      return ValueListenableBuilder<bool>(
          valueListenable: _darkNotifier,
          builder: (BuildContext context, bool isDark, Widget? child) {
            return Theme(
                data: index == 2
                    ? getTheme(!isDarkVisible)
                    : getTheme(isDarkVisible),
                child: widget.childBuilder(context, index));
          });
    }

    return AnimatedBuilder(
        animation: _animationController,
        builder: (BuildContext context, Widget? child) {
          return Stack(
            children: [
              _body(1),
              ClipPath(
                  clipper: CircularClipper(
                      _animationController.value * radius, position),
                  child: _body(2)),
            ],
          );
        });
  }
}

class CircularClipper extends CustomClipper<Path> {
  const CircularClipper(this.radius, this.center);
  final double radius;
  final Offset center;

  @override
  Path getClip(Size size) {
    final Path path = Path();
    path.addOval(Rect.fromCircle(radius: radius, center: center));
    return path;
  }

  @override
  bool shouldReclip(covariant CustomClipper<Path> oldClipper) {
    return true;
  }
}

enter image description here

Here's my medium blog post explaining this effect

You can find a complete code sample with Usage here https://gist.github.com/maheshmnj/815642f5576ebef0a0747db6854c2a74

Share:
3,680
Peyman
Author by

Peyman

Just ordinary

Updated on December 17, 2022

Comments

  • Peyman
    Peyman over 1 year

    I want to add animation for switching theme from light to dark or vice versa in flutter like telegram do :

    telegram's switch animation

    telegram's switch animation

    source

    can't see any way to do it in flutter, is it possible in flutter?

    thx for any answer

  • Peyman
    Peyman about 4 years
    thx for answer and your nice approach, i think using Theme Widget is a better way to handle theme of widgets in main page, created a PR in your github page check it out.
  • Kherel
    Kherel about 4 years
    I've converted our code to the library: pub.dev/packages/animated_theme_switcher
  • Frostbourn
    Frostbourn over 3 years
    Is any react alternative available?
  • Hassan Ansari
    Hassan Ansari over 3 years
    I'm trying to create a similar animation in my sliverappbar, I don't want to switch theme I just want to change widgets, that is, I'll create a drawer.
  • chitgoks
    chitgoks over 2 years
    this used to work ok. but ever since flutter 2.8, when switching theme happens, the whole app gets rebuilt. it doesnt work as it used to be