Created
September 16, 2025 17:20
-
-
Save fredgrott/2a11f0d68845fd4cce1ed94eab323c0d to your computer and use it in GitHub Desktop.
animate icon widget until we get better variable font api for Material 3 Expressive, article at
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Copyright 2025 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. | |
// ignore_for_file: unnecessary_new, avoid_redundant_argument_values | |
import 'dart:math' as math; | |
import 'package:flutter/material.dart'; | |
class AnimateIcons extends StatefulWidget { | |
const AnimateIcons({ | |
/// The IconData that will be visible before animation Starts | |
required this.startIcon, | |
/// The IconData that will be visible after animation ends | |
required this.endIcon, | |
/// The callback on startIcon Press | |
/// It should return a bool | |
/// If true is returned it'll animate to the end icon | |
/// if false is returned it'll not animate to the end icons | |
required this.onStartIconPress, | |
/// The callback on endIcon Press | |
/// /// It should return a bool | |
/// If true is returned it'll animate to the end icon | |
/// if false is returned it'll not animate to the end icons | |
required this.onEndIconPress, | |
/// The size of the icon that are to be shown. | |
this.size, | |
/// AnimateIcons controller | |
required this.controller, | |
/// The color to be used for the [startIcon] | |
this.startIconColor, | |
// The color to be used for the [endIcon] | |
this.endIconColor, | |
/// The duration for which the animation runs | |
this.duration, | |
/// If the animation runs in the clockwise or anticlockwise direction | |
this.clockwise, | |
/// This is the tooltip that will be used for the [startIcon] | |
this.startTooltip, | |
/// This is the tooltip that will be used for the [endIcon] | |
this.endTooltip, | |
}); | |
final IconData startIcon; | |
final IconData endIcon; | |
final bool Function() onStartIconPress; | |
final bool Function() onEndIconPress; | |
final Duration? duration; | |
final bool? clockwise; | |
final double? size; | |
final Color? startIconColor; | |
final Color? endIconColor; | |
final AnimateIconController controller; | |
final String? startTooltip; | |
final String? endTooltip; | |
@override | |
_AnimateIconsState createState() => _AnimateIconsState(); | |
} | |
class _AnimateIconsState extends State<AnimateIcons> with SingleTickerProviderStateMixin { | |
late AnimationController _controller; | |
@override | |
void initState() { | |
_controller = AnimationController( | |
vsync: this, | |
duration: widget.duration ?? const Duration(seconds: 1), | |
lowerBound: 0.0, | |
upperBound: 1.0, | |
); | |
_controller.addListener(() { | |
if (mounted) { | |
setState(() {}); | |
} | |
}); | |
initControllerFunctions(); | |
super.initState(); | |
} | |
@override | |
void dispose() { | |
_controller.dispose(); | |
super.dispose(); | |
} | |
void initControllerFunctions() { | |
widget.controller.animateToEnd = () { | |
if (mounted) { | |
_controller.forward(); | |
return true; | |
} else { | |
return false; | |
} | |
}; | |
widget.controller.animateToStart = () { | |
if (mounted) { | |
_controller.reverse(); | |
return true; | |
} else { | |
return false; | |
} | |
}; | |
widget.controller.isStart = () => _controller.value == 0.0; | |
widget.controller.isEnd = () => _controller.value == 1.0; | |
} | |
void _onStartIconPress() { | |
if (widget.onStartIconPress() && mounted) _controller.forward(); | |
} | |
void _onEndIconPress() { | |
if (widget.onEndIconPress() && mounted) _controller.reverse(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
final double x = _controller.value; | |
final double y = 1.0 - _controller.value; | |
final double angleX = math.pi / 180 * (180 * x); | |
final double angleY = math.pi / 180 * (180 * y); | |
Widget first() { | |
final icon = Icon(widget.startIcon, size: widget.size); | |
return Transform.rotate( | |
angle: widget.clockwise ?? false ? angleX : -angleX, | |
child: Opacity( | |
opacity: y, | |
child: IconButton( | |
iconSize: widget.size ?? 24.0, | |
color: widget.startIconColor ?? Theme.of(context).primaryColor, | |
disabledColor: Colors.grey.shade500, | |
icon: widget.startTooltip == null ? icon : Tooltip(message: widget.startTooltip, child: icon), | |
onPressed: _onStartIconPress, | |
), | |
), | |
); | |
} | |
Widget second() { | |
final icon = Icon(widget.endIcon); | |
return Transform.rotate( | |
angle: widget.clockwise ?? false ? -angleY : angleY, | |
child: Opacity( | |
opacity: x, | |
child: IconButton( | |
iconSize: widget.size ?? 24.0, | |
color: widget.endIconColor ?? Theme.of(context).primaryColor, | |
disabledColor: Colors.grey.shade500, | |
icon: widget.endTooltip == null ? icon : Tooltip(message: widget.endTooltip, child: icon), | |
onPressed: _onEndIconPress, | |
), | |
), | |
); | |
} | |
return Stack( | |
alignment: Alignment.center, | |
children: [if (x == 1 && y == 0) second() else first(), if (x == 0 && y == 1) first() else second()], | |
); | |
} | |
} | |
class AnimateIconController { | |
late bool Function() animateToStart; | |
late bool Function() animateToEnd; | |
late bool Function() isStart; | |
late bool Function() isEnd; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment