Awesome Flutter Theme Animation
A neat way to animate theme changes with some visual flourish
Deliberate practice in Flutter App design to reach awesome status is about what we do to brand an app (James Clear has a book series on deliberate practice). And conveniently it happens to cover all the aspects of Flutter widgets, navigation, animation, state management, etc.
The Flutter GDE's are not covering it this way. Even the top Flutter book recommended by Google is not covering it this way. I know from re-learning after getting my ADHD under nootropics control that this is the way to cover how to master flutter.
In this article, I am covering:
-clippers
-inherited widget
-training wheel state notifiers (observers)
-FlutterView
-Animation Controllers
-Global BuildContext
Design Strategy For Theme Animation
We want some way to display the dark or light theme environment of the app in a way to be able to clip animate the dark to light theme change with visual shape animation. The flutter framework has this handy feature where we can generate an image of the widget.
We can use that feature performance wise as since Flutter 2.0 the heavy-duty performance draining high clip intensive stuff is turned off by default on all widgets.
The other part of the design will be to manage state and the observing notifications so as the theme changes the correct widget is then flipped in the stack of the widget and image of the widget.
Note, since I had to clean up a library code base, I am re-using that code in this article. Let's begin with clip basics.
Flutter Clip Basics
In Flutter, we use clippers to change the shape of widgets. The particular API docs are:
You can see operation of two shape clippers in the Youtube short:
First, we need an abstract class that provides two methods, getClip and shouldReclip:
| // Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // | |
| // Originally authored by Kherel as animated theme switcher | |
| // under MIT Licenes 2020 | |
| import 'package:flutter/material.dart'; | |
| abstract class ThemeSwitcherClipper { | |
| Path getClip(Size size, Offset offset, double sizeRate); | |
| bool shouldReclip(CustomClipper<Path> oldClipper, Offset offset, double sizeRate); | |
| } |
So why do we need the shouldReclip method? Its more efficient to update the animation via supplying a reclip to the constructor of the CustomClipper. Which then will instruct the custom object to listen to the animation and update the clip on animation ticks which also avoids the build and layout phases. This is why we can clip the whole screen and clip even the image of the widget itself without draining performance.
And the two default clippers are:
Box Clipper
| // Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // | |
| // Originally authored by Kherel as animated theme switcher | |
| // under MIT Licenes 2020 | |
| import 'package:brand_theme/clippers/theme_switcher_clipper.dart'; | |
| import 'package:flutter/material.dart'; | |
| class ThemeSwitcherBoxClipper implements ThemeSwitcherClipper { | |
| const ThemeSwitcherBoxClipper(); | |
| @override | |
| Path getClip(Size size, Offset offset, double sizeRate) { | |
| return Path() | |
| ..addRect( | |
| Rect.fromCenter( | |
| center: offset, | |
| width: size.width * 2 * sizeRate, | |
| height: size.height * 2 * sizeRate, | |
| ), | |
| ); | |
| } | |
| @override | |
| bool shouldReclip(CustomClipper<Path> oldClipper, Offset? offset, double? sizeRate) { | |
| return true; | |
| } | |
| } |
Circle Clipper
| // Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // | |
| // Originally authored by Kherel as animated theme switcher | |
| // under MIT Licenes 2020 | |
| import 'dart:math'; | |
| import 'dart:ui'; | |
| import 'package:brand_theme/clippers/theme_switcher_clipper.dart'; | |
| import 'package:flutter/material.dart'; | |
| class ThemeSwitcherCircleClipper implements ThemeSwitcherClipper { | |
| const ThemeSwitcherCircleClipper(); | |
| @override | |
| Path getClip(Size size, Offset? offset, double? sizeRate) { | |
| return Path() | |
| ..addOval( | |
| Rect.fromCircle( | |
| center: offset!, | |
| radius: lerpDouble(0, _calcMaxRadius(size, offset), sizeRate!)!, | |
| ), | |
| ); | |
| } | |
| @override | |
| bool shouldReclip(CustomClipper<Path> oldClipper, Offset? offset, double? sizeRate) { | |
| return true; | |
| } | |
| static double _calcMaxRadius(Size size, Offset center) { | |
| final w = max(center.dx, size.width - center.dx); | |
| final h = max(center.dy, size.height - center.dy); | |
| return sqrt(w * w + h * h); | |
| } | |
| } |
Both clippers are using the Dart UI Rect class methods:
The Dart UI Rect class is responsible for providing an immutable, 2D, axis aligned, rectangle whose coordinates are relative to a given origin point. One last thing we need in the above video showing the animation the switch part inverted its clip and color. To create that effect, we need a clipper bridge:
| // Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // | |
| // Originally authored by Kherel as animated theme switcher | |
| // under MIT Licenes 2020 | |
| import 'package:brand_theme/clippers/theme_switcher_clipper.dart'; | |
| import 'package:flutter/material.dart'; | |
| class ThemeSwitcherClipperBridge extends CustomClipper<Path> { | |
| ThemeSwitcherClipperBridge({required this.sizeRate, required this.offset, required this.clipper}); | |
| final double sizeRate; | |
| final Offset offset; | |
| final ThemeSwitcherClipper clipper; | |
| @override | |
| Path getClip(Size size) { | |
| return clipper.getClip(size, offset, sizeRate); | |
| } | |
| @override | |
| bool shouldReclip(CustomClipper<Path> oldClipper) { | |
| return clipper.shouldReclip(oldClipper, offset, sizeRate); | |
| } | |
| } |
Because it's a bridge, its constructor has to ask for sizeRate, offset, and the clipper used.
Now, the state management part via inherited widget.
Inherited Widget Basics
Basically, an InheritedWidget just propagate information down the widget tree:
In the theme animation use case, we need to pass the state of the theme switcher down the widget tree:
| // Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // | |
| // Originally authored by Kherel as animated theme switcher | |
| // under MIT License 2020 | |
| import 'package:brand_theme/theme_switcher.dart'; | |
| import 'package:flutter/material.dart'; | |
| class InheritedThemeSwitcher extends InheritedWidget { | |
| final ThemeSwitcherState data; | |
| const InheritedThemeSwitcher({ | |
| required this.data, | |
| super.key, | |
| required super.child, | |
| }); | |
| @override | |
| bool updateShouldNotify(InheritedThemeSwitcher oldWidget) { | |
| return true; | |
| } | |
| } |
Next up, we need a notifier.
Notifier Basics
Notifiers in Flutter are just observers, i.e. listeners. In our use case we are using an InheritedNotifier which is an InheritedWidget that subclasses the Listener class:
In our theme animation use case, we need to listen for ThemeModel changes:
| // Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // | |
| // Originally authored by Kherel as animated theme switcher | |
| // under MIT License 2020 | |
| import 'package:brand_theme/theme_model.dart'; | |
| import 'package:flutter/material.dart'; | |
| class ThemeModelInheritedNotifier extends InheritedNotifier<ThemeModel> { | |
| const ThemeModelInheritedNotifier({ | |
| super.key, | |
| required ThemeModel super.notifier, | |
| required super.child, | |
| }); | |
| static ThemeModel of(BuildContext context) { | |
| return context.dependOnInheritedWidgetOfExactType<ThemeModelInheritedNotifier>()!.notifier!; | |
| } | |
| } |
That also implies we have a Rich-Model in that our theme model has some business logic in it and the ThemeModel itself is a change notifier:
| // Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // | |
| // Originally authored by Kherel as animated theme switcher | |
| // under MIT License 2020 | |
| import 'dart:async'; | |
| import 'dart:ui' as ui; | |
| import 'package:brand_theme/clippers/theme_switcher_circle_clipper.dart'; | |
| import 'package:brand_theme/clippers/theme_switcher_clipper.dart'; | |
| import 'package:brand_theme/navigation_service.dart'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/rendering.dart'; | |
| class ThemeModel extends ChangeNotifier { | |
| ThemeData _theme; | |
| late GlobalKey switcherGlobalKey; | |
| ui.Image? image; | |
| final previewContainer = GlobalKey(); | |
| Timer? timer; | |
| ThemeSwitcherClipper clipper = const ThemeSwitcherCircleClipper(); | |
| final AnimationController controller; | |
| ThemeModel({ | |
| required ThemeData startTheme, | |
| required this.controller, | |
| }) : _theme = startTheme; | |
| ThemeData get theme => _theme; | |
| ThemeData? oldTheme; | |
| bool isReversed = false; | |
| late Offset switcherOffset; | |
| void changeTheme({ | |
| required ThemeData theme, | |
| required GlobalKey key, | |
| ThemeSwitcherClipper? clipper, | |
| required bool isReversed, | |
| Offset? offset, | |
| VoidCallback? onAnimationFinish, | |
| }) async { | |
| if (controller.isAnimating) { | |
| return; | |
| } | |
| if (clipper != null) { | |
| this.clipper = clipper; | |
| } | |
| this.isReversed = isReversed; | |
| oldTheme = _theme; | |
| _theme = theme; | |
| switcherOffset = _getSwitcherCoordinates(key, offset); | |
| await _saveScreenshot(); | |
| if (isReversed) { | |
| await controller.reverse(from: 1.0).then((value) => onAnimationFinish?.call()); | |
| } else { | |
| await controller.forward(from: 0.0).then((value) => onAnimationFinish?.call()); | |
| } | |
| } | |
| // Orig package has this wrong as with the change to foldables bought a new FlutterView set up | |
| // to deal with that and the android multi windows feature. Thus have to access devicePixelRatio via | |
| // View.of(context).devicePixelRatio which means we need the MaterialApp global context via | |
| // declaring a nav key. | |
| Future<void> _saveScreenshot() async { | |
| final boundary = previewContainer.currentContext!.findRenderObject() as RenderRepaintBoundary; | |
| image = await boundary.toImage(pixelRatio: View.of(NavigationService.navigatorKey.currentContext as BuildContext).devicePixelRatio); | |
| notifyListeners(); | |
| } | |
| @override | |
| void dispose() { | |
| timer?.cancel(); | |
| super.dispose(); | |
| } | |
| Offset _getSwitcherCoordinates(GlobalKey<State<StatefulWidget>> switcherGlobalKey, [Offset? tapOffset]) { | |
| final renderObject = switcherGlobalKey.currentContext!.findRenderObject()! as RenderBox; | |
| final size = renderObject.size; | |
| return renderObject.localToGlobal(Offset.zero).translate( | |
| tapOffset?.dx ?? (size.width / 2), | |
| tapOffset?.dy ?? (size.height / 2), | |
| ); | |
| } | |
| } |
The ThemeModel constructor takes one ThemeData and an AnimationController. The changeTheme method is the main business logic of the model in making sure the theme is switched, getting the switcher offset and key and getting the screenshot image of the whole screen.
Note, when Flutter changed to support foldables and multi-windows it changed how we access window attributes as the window stuff is depreciated. Instead we have to access the global build context and supply it the View.of method to get the device PixelRatio.
To do that, create a NavigationService class:
And that navigation key gets supplied as a parameter to the MaterialApp widget.
And now for a dependence injection and management trick.
TickerProviderMixin Dependency Injection
Now, let's jump into Flutter animation! A Ticker class calls its callback once per animation frame:
To use Ticker objects and a control we have to use a TickerProviderStateMixin:
Rather than have the end design (developer) to re-write their scaffold declaration, one can instead just create a provider class that takes the scaffold widget as a child:
| // Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // | |
| // Originally authored by Kherel as animated theme switcher | |
| // under MIT License 2020 | |
| import 'package:brand_theme/theme_model.dart'; | |
| import 'package:brand_theme/theme_model_inherited_notifier.dart'; | |
| import 'package:flutter/material.dart'; | |
| typedef ThemeBuilder = Widget Function(BuildContext, ThemeData theme); | |
| class ThemeProvider extends StatefulWidget { | |
| const ThemeProvider({ | |
| super.key, | |
| this.builder, | |
| this.child, | |
| required this.initTheme, | |
| this.duration = const Duration(milliseconds: 300), | |
| }); | |
| final ThemeBuilder? builder; | |
| final Widget? child; | |
| final ThemeData initTheme; | |
| final Duration duration; | |
| @override | |
| State<ThemeProvider> createState() => ThemeProviderState(); | |
| } | |
| class ThemeProviderState extends State<ThemeProvider> with TickerProviderStateMixin { | |
| late AnimationController _controller; | |
| late var model; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _controller = AnimationController( | |
| duration: widget.duration, | |
| vsync: this, | |
| ); | |
| model = ThemeModel( | |
| startTheme: widget.initTheme, | |
| controller: _controller, | |
| ); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return ThemeModelInheritedNotifier( | |
| notifier: model, | |
| child: Builder(builder: (context) { | |
| var model = ThemeModelInheritedNotifier.of(context); | |
| return RepaintBoundary( | |
| key: model.previewContainer, | |
| child: widget.child ?? widget.builder!(context, model.theme), | |
| ); | |
| }), | |
| ); | |
| } | |
| } |
By wrapping it in a ThemeModelNotifier it allows triggering of the Builder each time the model changes.
And we come to another performance tweak. The RepaintBoundary class allows the paint pass to concentrate only on the areas that changed:
The next part is the switcher itself, which supplies the methods we use for the theme switch buttons.
Theme Switcher Class
Here is the theme switcher class:
| // Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // | |
| // Originally authored by Kherel as animated theme switcher | |
| // under MIT License 2020 | |
| import 'package:brand_theme/clippers/theme_switcher_circle_clipper.dart'; | |
| import 'package:brand_theme/clippers/theme_switcher_clipper.dart'; | |
| import 'package:brand_theme/inherited_theme_switcher.dart'; | |
| import 'package:brand_theme/theme_model_inherited_notifier.dart'; | |
| import 'package:flutter/material.dart'; | |
| typedef ChangeTheme = void Function(ThemeData theme); | |
| typedef BuilderWithSwitcher = Widget Function(BuildContext, ThemeSwitcherState switcher); | |
| typedef BuilderWithTheme = Widget Function(BuildContext, ThemeSwitcherState switcher, ThemeData theme); | |
| class ThemeSwitcher extends StatefulWidget { | |
| const ThemeSwitcher({ | |
| super.key, | |
| this.clipper = const ThemeSwitcherCircleClipper(), | |
| required this.builder, | |
| }); | |
| factory ThemeSwitcher.switcher({ | |
| Key? key, | |
| clipper = const ThemeSwitcherCircleClipper(), | |
| required BuilderWithSwitcher builder, | |
| }) => | |
| ThemeSwitcher( | |
| key: key, | |
| clipper: clipper, | |
| builder: (ctx) => builder(ctx, ThemeSwitcher.of(ctx)), | |
| ); | |
| factory ThemeSwitcher.withTheme({ | |
| Key? key, | |
| clipper = const ThemeSwitcherCircleClipper(), | |
| required BuilderWithTheme builder, | |
| }) => | |
| ThemeSwitcher.switcher( | |
| key: key, | |
| clipper: clipper, | |
| builder: (ctx, s) => builder(ctx, s, ThemeModelInheritedNotifier.of(ctx).theme), | |
| ); | |
| final Widget Function(BuildContext) builder; | |
| final ThemeSwitcherClipper clipper; | |
| @override | |
| ThemeSwitcherState createState() => ThemeSwitcherState(); | |
| static ThemeSwitcherState of(BuildContext context) { | |
| final inherited = context.dependOnInheritedWidgetOfExactType<InheritedThemeSwitcher>()!; | |
| return inherited.data; | |
| } | |
| } | |
| class ThemeSwitcherState extends State<ThemeSwitcher> { | |
| final GlobalKey _globalKey = GlobalKey(); | |
| @override | |
| Widget build(BuildContext context) { | |
| return InheritedThemeSwitcher( | |
| data: this, | |
| child: Builder( | |
| key: _globalKey, | |
| builder: widget.builder, | |
| ), | |
| ); | |
| } | |
| void changeTheme({ | |
| required ThemeData theme, | |
| bool isReversed = false, | |
| Offset? offset, | |
| VoidCallback? onAnimationFinish, | |
| }) { | |
| ThemeModelInheritedNotifier.of(context).changeTheme( | |
| theme: theme, | |
| key: _globalKey, | |
| clipper: widget.clipper, | |
| isReversed: isReversed, | |
| offset: offset, | |
| onAnimationFinish: onAnimationFinish); | |
| } | |
| } |
So the way to read this is the ThemeSwitcher constructor is the wrapper around a button triggering the theme change. This is why the build method of the State class has the InheritedThemeSwitcher declaration as we need access to ThemeModel in the widget tree. The individual button needed methods are in the stateful widget above the state class.
Finally, the changeTheme method with right notyifier wrapper to be used in the TpaDown button.
Now we need some way to mark the area that is being animated with ThemeSwitchingArea
ThemeSwitchingArea
ThemeSwitchingArea contains the logic about switching the widget between the widget and image. It also contains the AnimationBuilder wrapper and where the clip path is being applied:
| // Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // | |
| // Originally authored by Kherel as animated theme switcher | |
| // under MIT License 2020 | |
| import 'package:brand_theme/clippers/theme_switcher_clipper_bridge.dart'; | |
| import 'package:brand_theme/theme_model_inherited_notifier.dart'; | |
| import 'package:flutter/material.dart'; | |
| class ThemeSwitchingArea extends StatelessWidget { | |
| const ThemeSwitchingArea({ | |
| super.key, | |
| required this.child, | |
| }); | |
| final Widget child; | |
| @override | |
| Widget build(BuildContext context) { | |
| final model = ThemeModelInheritedNotifier.of(context); | |
| // Widget resChild; | |
| Widget child; | |
| if (model.oldTheme == null || model.oldTheme == model.theme) { | |
| child = _getPage(model.theme); | |
| } else { | |
| late final Widget firstWidget, animWidget; | |
| if (model.isReversed) { | |
| firstWidget = _getPage(model.theme); | |
| animWidget = RawImage(image: model.image); | |
| } else { | |
| firstWidget = RawImage(image: model.image); | |
| animWidget = _getPage(model.theme); | |
| } | |
| child = Stack( | |
| children: [ | |
| Container( | |
| key: const ValueKey('ThemeSwitchingAreaFirstChild'), | |
| child: firstWidget, | |
| ), | |
| AnimatedBuilder( | |
| key: const ValueKey('ThemeSwitchingAreaSecondChild'), | |
| animation: model.controller, | |
| child: animWidget, | |
| builder: (_, child) { | |
| return ClipPath( | |
| clipper: ThemeSwitcherClipperBridge( | |
| clipper: model.clipper, | |
| offset: model.switcherOffset, | |
| sizeRate: model.controller.value, | |
| ), | |
| child: child, | |
| ); | |
| }, | |
| ), | |
| ], | |
| ); | |
| } | |
| return Material(child: child); | |
| } | |
| Widget _getPage(ThemeData brandTheme) { | |
| return Theme( | |
| key: const ValueKey('ThemeSwitchingAreaPage'), | |
| data: brandTheme, | |
| child: child, | |
| ); | |
| } | |
| } |
And here is where you see the stack trick of stacking then widget being animated on top of the firstWidget which is a raw image of the widget depending upon whether the animation is reversed or not.
So how do we use it?
Actual App Usage
For visual reference, here it is in action as a YouTube short:
First, we need a theme configuration:
| import 'package:flutter/material.dart'; | |
| ThemeData lightTheme = ThemeData.light(); | |
| ThemeData darkTheme = ThemeData.dark(); | |
| ThemeData pinkTheme = lightTheme.copyWith( | |
| primaryColor: const Color(0xFFF49FB6), | |
| scaffoldBackgroundColor: const Color(0xFFFAF8F0), | |
| floatingActionButtonTheme: const FloatingActionButtonThemeData( | |
| foregroundColor: Color(0xFF24737c), | |
| backgroundColor: Color(0xFFA6E0DE), | |
| ), | |
| textTheme: const TextTheme( | |
| bodyText1: TextStyle( | |
| color: Colors.black87, | |
| ), | |
| )); | |
| ThemeData halloweenTheme = lightTheme.copyWith( | |
| primaryColor: const Color(0xFF55705A), | |
| scaffoldBackgroundColor: const Color(0xFFE48873), | |
| floatingActionButtonTheme: const FloatingActionButtonThemeData( | |
| foregroundColor: Color(0xFFea8e71), | |
| backgroundColor: Color(0xFF2b2119), | |
| ), | |
| ); | |
| ThemeData darkBlueTheme = ThemeData.dark().copyWith( | |
| primaryColor: const Color(0xFF1E1E2C), | |
| scaffoldBackgroundColor: const Color(0xFF2D2D44), | |
| textTheme: const TextTheme( | |
| bodyText1: TextStyle( | |
| color: Color(0xFF33E1Ed), | |
| ), | |
| ), | |
| ); |
Just a simple declaration of some themes.
Then we use ThemeProvider to wrap the MyApp MaterialApp widget:
| // Copyright 2023 Fredrick Allan Grott. 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:brand_theme/my_home_page.dart'; | |
| import 'package:brand_theme/navigation_service.dart'; | |
| import 'package:brand_theme/theme_config.dart'; | |
| import 'package:brand_theme/theme_provider.dart'; | |
| import 'package:flutter/material.dart'; | |
| class MyApp extends StatelessWidget { | |
| const MyApp({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| // with change to FlutterView to support foldables and multi window etc | |
| // window was depreciated so need to grab it via platformDispatcher | |
| final isPlatformDark = WidgetsBinding.instance.platformDispatcher.platformBrightness == Brightness.dark; | |
| final initTheme = isPlatformDark ? darkTheme : lightTheme; | |
| return ThemeProvider( | |
| initTheme: initTheme, | |
| builder: (_, myTheme) { | |
| return MaterialApp( | |
| debugShowCheckedModeBanner: false, | |
| navigatorKey: NavigationService.navigatorKey, | |
| title: 'Flutter Demo', | |
| theme: myTheme, | |
| home: const MyHomePage(), | |
| ); | |
| }, | |
| ); | |
| } | |
| } |
The TapDown widget used in the My Home Page is this:
| import 'package:flutter/material.dart'; | |
| class TapDownButton extends StatelessWidget { | |
| const TapDownButton({ | |
| super.key, | |
| required this.onTap, | |
| required this.child, | |
| }); | |
| final void Function(TapDownDetails details) onTap; | |
| final Widget child; | |
| @override | |
| Widget build(BuildContext context) { | |
| return GestureDetector( | |
| onTapDown: onTap, | |
| child: Container( | |
| padding: const EdgeInsets.symmetric( | |
| vertical: 16.0, | |
| horizontal: 24.0, | |
| ), | |
| decoration: BoxDecoration( | |
| borderRadius: const BorderRadius.all( | |
| Radius.circular(24.0), | |
| ), | |
| border: Border.all(width: 1.0), | |
| ), | |
| child: child, | |
| ), | |
| ); | |
| } | |
| } |
And the My Home Page with the Theme Switcher method calls:
| // Copyright 2023 Fredrick Allan Grott. 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:brand_theme/clippers/theme_switcher_box_clipper.dart'; | |
| import 'package:brand_theme/clippers/theme_switcher_circle_clipper.dart'; | |
| import 'package:brand_theme/tap_down_button.dart'; | |
| import 'package:brand_theme/theme_config.dart'; | |
| import 'package:brand_theme/theme_model_inherited_notifier.dart'; | |
| import 'package:brand_theme/theme_switcher.dart'; | |
| import 'package:brand_theme/theme_switching_area.dart'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/scheduler.dart'; | |
| class MyHomePage extends StatefulWidget { | |
| const MyHomePage({super.key}); | |
| @override | |
| State<MyHomePage> createState() => MyHomePageState(); | |
| } | |
| class MyHomePageState extends State<MyHomePage> { | |
| int counter = 0; | |
| void _incrementCounter() { | |
| setState(() { | |
| counter++; | |
| }); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return ThemeSwitchingArea( | |
| child: Scaffold( | |
| drawer: Drawer( | |
| child: SafeArea( | |
| child: Stack( | |
| children: <Widget>[ | |
| Align( | |
| alignment: Alignment.topRight, | |
| child: ThemeSwitcher.withTheme( | |
| builder: (_, switcher, theme) { | |
| return IconButton( | |
| onPressed: () => switcher.changeTheme( | |
| theme: theme.brightness == Brightness.light ? darkTheme : lightTheme, | |
| ), | |
| icon: const Icon(Icons.brightness_3, size: 25), | |
| ); | |
| }, | |
| ) as Widget, | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| appBar: AppBar( | |
| title: const Text( | |
| 'Flutter Demo Home Page', | |
| ), | |
| ), | |
| body: Center( | |
| child: Column( | |
| mainAxisAlignment: MainAxisAlignment.spaceAround, | |
| children: <Widget>[ | |
| const Text( | |
| 'You have pushed the button this many times:', | |
| ), | |
| Text( | |
| '$counter', | |
| style: const TextStyle(fontSize: 200), | |
| ), | |
| CheckboxListTile( | |
| title: const Text('Slow Animation'), | |
| value: timeDilation == 5.0, | |
| onChanged: (value) { | |
| setState(() { | |
| timeDilation = value != null && value ? 5.0 : 1.0; | |
| }); | |
| }, | |
| ), | |
| Row( | |
| mainAxisAlignment: MainAxisAlignment.spaceEvenly, | |
| children: [ | |
| ThemeSwitcher.switcher( | |
| clipper: const ThemeSwitcherBoxClipper(), | |
| builder: (BuildContext context, switcher) { | |
| return TapDownButton( | |
| child: const Text('Box Animation'), | |
| onTap: (details) { | |
| switcher.changeTheme( | |
| theme: ThemeModelInheritedNotifier.of(context).theme.brightness == Brightness.light | |
| ? darkTheme | |
| : lightTheme, | |
| offset: details.localPosition, | |
| ); | |
| }, | |
| ); | |
| }, | |
| ) as Widget, | |
| ThemeSwitcher( | |
| clipper: const ThemeSwitcherCircleClipper(), | |
| builder: (BuildContext context) { | |
| return TapDownButton( | |
| child: const Text('Circle Animation'), | |
| onTap: (details) { | |
| ThemeSwitcher.of(context).changeTheme( | |
| theme: ThemeModelInheritedNotifier.of(context).theme.brightness == Brightness.light | |
| ? darkTheme | |
| : lightTheme, | |
| offset: details.localPosition, | |
| ); | |
| }, | |
| ); | |
| }, | |
| ) as Widget | |
| ], | |
| ), | |
| Row( | |
| mainAxisAlignment: MainAxisAlignment.spaceEvenly, | |
| children: <Widget>[ | |
| ThemeSwitcher( | |
| clipper: const ThemeSwitcherBoxClipper(), | |
| builder: (BuildContext context) { | |
| return TapDownButton( | |
| child: const Text('Box (Reverse)'), | |
| onTap: (details) { | |
| var brightness = ThemeModelInheritedNotifier.of(context).theme.brightness; | |
| ThemeSwitcher.of(context).changeTheme( | |
| theme: brightness == Brightness.light ? darkTheme : lightTheme, | |
| offset: details.localPosition, | |
| isReversed: brightness == Brightness.dark ? true : false, | |
| ); | |
| }, | |
| ); | |
| }, | |
| ) as Widget, | |
| ThemeSwitcher( | |
| clipper: const ThemeSwitcherCircleClipper(), | |
| builder: (BuildContext context) { | |
| return TapDownButton( | |
| child: const Text('Circle (Reverse)'), | |
| onTap: (details) { | |
| var brightness = ThemeModelInheritedNotifier.of(context).theme.brightness; | |
| ThemeSwitcher.of(context).changeTheme( | |
| theme: brightness == Brightness.light ? darkTheme : lightTheme, | |
| offset: details.localPosition, | |
| isReversed: brightness == Brightness.dark ? true : false, | |
| ); | |
| }, | |
| ); | |
| }, | |
| ) as Widget | |
| ], | |
| ), | |
| Row( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: <Widget>[ | |
| ThemeSwitcher( | |
| builder: (BuildContext context) { | |
| return Checkbox( | |
| value: ThemeModelInheritedNotifier.of(context).theme == pinkTheme, | |
| onChanged: (needPink) { | |
| ThemeSwitcher.of(context).changeTheme( | |
| theme: needPink != null && needPink ? pinkTheme : lightTheme, | |
| ); | |
| }, | |
| ); | |
| }, | |
| ) as Widget, | |
| ThemeSwitcher( | |
| builder: (BuildContext context) { | |
| return Checkbox( | |
| value: ThemeModelInheritedNotifier.of(context).theme == darkBlueTheme, | |
| onChanged: (needDarkBlue) { | |
| ThemeSwitcher.of(context).changeTheme( | |
| theme: needDarkBlue != null && needDarkBlue ? darkBlueTheme : lightTheme, | |
| ); | |
| }, | |
| ); | |
| }, | |
| ) as Widget, | |
| ThemeSwitcher( | |
| builder: (BuildContext context) { | |
| return Checkbox( | |
| value: ThemeModelInheritedNotifier.of(context).theme == halloweenTheme, | |
| onChanged: (needBlue) { | |
| ThemeSwitcher.of(context).changeTheme( | |
| theme: needBlue != null && needBlue ? halloweenTheme : lightTheme, | |
| ); | |
| }, | |
| ); | |
| }, | |
| ) as Widget, | |
| ], | |
| ), | |
| ], | |
| ), | |
| ), | |
| floatingActionButton: FloatingActionButton( | |
| onPressed: _incrementCounter, | |
| tooltip: 'Increment', | |
| child: const Icon( | |
| Icons.add, | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } |
Storing Theme State
To integrate it into shared preferences would be something like this:
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/scheduler.dart' show timeDilation; | |
| import 'package:shared_preferences/shared_preferences.dart'; | |
| import 'package:animated_theme_switcher/animated_theme_switcher.dart'; | |
| import 'theme_config.dart'; | |
| void main() async { | |
| WidgetsFlutterBinding.ensureInitialized(); | |
| final themeService = await ThemeService.instance; | |
| var initTheme = themeService.initial; | |
| runApp(MyApp(theme: initTheme)); | |
| } | |
| class ThemeService { | |
| ThemeService._(); | |
| static late SharedPreferences prefs; | |
| static ThemeService? _instance; | |
| static Future<ThemeService> get instance async { | |
| if (_instance == null) { | |
| prefs = await SharedPreferences.getInstance(); | |
| _instance = ThemeService._(); | |
| } | |
| return _instance!; | |
| } | |
| final allThemes = <String, ThemeData>{ | |
| 'dark': darkTheme, | |
| 'light': lightTheme, | |
| 'pink': pinkTheme, | |
| 'darkBlue': darkBlueTheme, | |
| 'halloween': halloweenTheme, | |
| }; | |
| String get previousThemeName { | |
| String? themeName = prefs.getString('previousThemeName'); | |
| if (themeName == null) { | |
| final isPlatformDark = | |
| WidgetsBinding.instance.window.platformBrightness == Brightness.dark; | |
| themeName = isPlatformDark ? 'light' : 'dark'; | |
| } | |
| return themeName; | |
| } | |
| get initial { | |
| String? themeName = prefs.getString('theme'); | |
| if (themeName == null) { | |
| final isPlatformDark = | |
| WidgetsBinding.instance.window.platformBrightness == Brightness.dark; | |
| themeName = isPlatformDark ? 'dark' : 'light'; | |
| } | |
| return allThemes[themeName]; | |
| } | |
| save(String newThemeName) { | |
| var currentThemeName = prefs.getString('theme'); | |
| if (currentThemeName != null) { | |
| prefs.setString('previousThemeName', currentThemeName); | |
| } | |
| prefs.setString('theme', newThemeName); | |
| } | |
| ThemeData getByName(String name) { | |
| return allThemes[name]!; | |
| } | |
| } | |
| class MyApp extends StatelessWidget { | |
| const MyApp({ | |
| Key? key, | |
| required this.theme, | |
| }) : super(key: key); | |
| final ThemeData theme; | |
| @override | |
| Widget build(BuildContext context) { | |
| return ThemeProvider( | |
| initTheme: theme, | |
| builder: (_, theme) { | |
| return MaterialApp( | |
| title: 'Flutter Demo', | |
| theme: theme, | |
| home: const MyHomePage(), | |
| ); | |
| }, | |
| ); | |
| } | |
| } | |
| class MyHomePage extends StatefulWidget { | |
| const MyHomePage({Key? key}) : super(key: key); | |
| @override | |
| State<MyHomePage> createState() => _MyHomePageState(); | |
| } | |
| class _MyHomePageState extends State<MyHomePage> { | |
| int _counter = 0; | |
| void _incrementCounter() { | |
| setState(() { | |
| _counter++; | |
| }); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return ThemeSwitchingArea( | |
| child: Scaffold( | |
| drawer: Drawer( | |
| child: SafeArea( | |
| child: Stack( | |
| children: <Widget>[ | |
| Align( | |
| alignment: Alignment.topRight, | |
| child: ThemeSwitcher( | |
| builder: (context) { | |
| return IconButton( | |
| onPressed: () async { | |
| final themeSwitcher = ThemeSwitcher.of(context); | |
| final themeName = | |
| ThemeModelInheritedNotifier.of(context) | |
| .theme | |
| .brightness == | |
| Brightness.light | |
| ? 'dark' | |
| : 'light'; | |
| final service = await ThemeService.instance | |
| ..save(themeName); | |
| final theme = service.getByName(themeName); | |
| themeSwitcher.changeTheme(theme: theme); | |
| }, | |
| icon: const Icon(Icons.brightness_3, size: 25), | |
| ); | |
| }, | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| appBar: AppBar( | |
| title: const Text( | |
| 'Flutter Demo Home Page', | |
| ), | |
| ), | |
| body: Center( | |
| child: Column( | |
| mainAxisAlignment: MainAxisAlignment.spaceAround, | |
| children: <Widget>[ | |
| const Text( | |
| 'You have pushed the button this many times:', | |
| ), | |
| Text( | |
| '$_counter', | |
| style: const TextStyle(fontSize: 200), | |
| ), | |
| CheckboxListTile( | |
| title: const Text('Slow Animation'), | |
| value: timeDilation == 5.0, | |
| onChanged: (value) { | |
| setState(() { | |
| timeDilation = value! ? 5.0 : 1.0; | |
| }); | |
| }, | |
| ), | |
| Row( | |
| mainAxisAlignment: MainAxisAlignment.spaceEvenly, | |
| children: <Widget>[ | |
| ThemeSwitcher( | |
| clipper: const ThemeSwitcherBoxClipper(), | |
| builder: (context) { | |
| return OutlinedButton( | |
| child: const Text('Box Animation'), | |
| onPressed: () async { | |
| final themeSwitcher = ThemeSwitcher.of(context); | |
| final themeName = | |
| ThemeModelInheritedNotifier.of(context) | |
| .theme | |
| .brightness == | |
| Brightness.light | |
| ? 'dark' | |
| : 'light'; | |
| final service = await ThemeService.instance | |
| ..save(themeName); | |
| final theme = service.getByName(themeName); | |
| themeSwitcher.changeTheme(theme: theme); | |
| }, | |
| ); | |
| }, | |
| ), | |
| ThemeSwitcher( | |
| clipper: const ThemeSwitcherCircleClipper(), | |
| builder: (context) { | |
| return OutlinedButton( | |
| child: const Text('Circle Animation'), | |
| onPressed: () async { | |
| final themeSwitcher = ThemeSwitcher.of(context); | |
| final themeName = | |
| ThemeModelInheritedNotifier.of(context) | |
| .theme | |
| .brightness == | |
| Brightness.light | |
| ? 'dark' | |
| : 'light'; | |
| final service = await ThemeService.instance | |
| ..save(themeName); | |
| final theme = service.getByName(themeName); | |
| themeSwitcher.changeTheme(theme: theme); | |
| }, | |
| ); | |
| }, | |
| ) | |
| ], | |
| ), | |
| Row( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: <Widget>[ | |
| ThemeSwitcher( | |
| builder: (context) { | |
| return Checkbox( | |
| value: ThemeModelInheritedNotifier.of(context).theme == | |
| pinkTheme, | |
| onChanged: (needPink) async { | |
| final themeSwitcher = ThemeSwitcher.of(context); | |
| final service = await ThemeService.instance; | |
| ThemeData theme; | |
| if (needPink!) { | |
| service.save('pink'); | |
| theme = service.getByName('pink'); | |
| } else { | |
| final previousThemeName = service.previousThemeName; | |
| service.save(previousThemeName); | |
| theme = service.getByName(previousThemeName); | |
| } | |
| themeSwitcher.changeTheme(theme: theme); | |
| }, | |
| ); | |
| }, | |
| ), | |
| ThemeSwitcher( | |
| builder: (context) { | |
| return Checkbox( | |
| value: ThemeModelInheritedNotifier.of(context).theme == | |
| darkBlueTheme, | |
| onChanged: (needDarkBlue) async { | |
| final themeSwitcher = ThemeSwitcher.of(context); | |
| final service = await ThemeService.instance; | |
| ThemeData theme; | |
| if (needDarkBlue!) { | |
| service.save('darkBlue'); | |
| theme = service.getByName('darkBlue'); | |
| } else { | |
| var previousThemeName = service.previousThemeName; | |
| service.save(previousThemeName); | |
| theme = service.getByName(previousThemeName); | |
| } | |
| themeSwitcher.changeTheme(theme: theme); | |
| }, | |
| ); | |
| }, | |
| ), | |
| ThemeSwitcher( | |
| builder: (context) { | |
| return Checkbox( | |
| value: ThemeModelInheritedNotifier.of(context).theme == | |
| halloweenTheme, | |
| onChanged: (needHalloween) async { | |
| final themeSwitcher = ThemeSwitcher.of(context); | |
| final service = await ThemeService.instance; | |
| ThemeData theme; | |
| if (needHalloween!) { | |
| service.save('halloween'); | |
| theme = service.getByName('halloween'); | |
| } else { | |
| final previousThemeName = service.previousThemeName; | |
| service.save(previousThemeName); | |
| theme = service.getByName(previousThemeName); | |
| } | |
| themeSwitcher.changeTheme(theme: theme); | |
| }, | |
| ); | |
| }, | |
| ), | |
| ], | |
| ), | |
| ], | |
| ), | |
| ), | |
| floatingActionButton: FloatingActionButton( | |
| onPressed: _incrementCounter, | |
| tooltip: 'Increment', | |
| child: const Icon( | |
| Icons.add, | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } |



