flutter_animate
flutter_animate copied to clipboard
Triggering the animation when a condition is met
A common use-cases is to trigger an animation stored in a widget when an event is triggered, such as
- on the press of a button for a visual feedback. E.g. Shake something on the press of a button
Since, the documentation doesn't cover this use-case, the one simple solution I could think of is to make the parent widget stateful to capture the state of a toggle flag. The flag is set to true on the press of a button and false once the playback is complete. While this works, the code is not concise and certainly doesn't make for a good reading.
Is there a better way to accomplish this that may be documented?
@thesensibledev I am about to embark on this same mission. This could be handled through the NotificationListener. It means we'll have to create a base widget for taps that emits a notification when tapped. The library can have a widget that is "EventDriven" and expects a specific notification type. When that's received the animation is triggered.
I'm going to implement this in my project now, if I like it I will report back. Otherwise I'll wait around here to see what @gskinner recommends 👀
We have been just dealing with that as a state change, either at the widget level, or via a builder using, for example a ChangeNotifier.
Related, there's also some work going on to add adapters that can animate between states, which could potentially enable this (just pass a new value into the adapter). See: https://github.com/gskinner/flutter_animate/pull/9
To turn this around, how would you like this to work? For example, maybe write a little code sketch that shows what you'd like to see within (to a reasonable extent) the norms of Flutter.
@gskinner a state change means there's going to be values manually tracking if the button has been pressed or not. i.e. Storing a value for every touchable widget on the screen. If I understand it correctly.
There's 2 type of tap animations I have in mind.
- Repeat the same animation on tap: Similar to Flutter Bounce
- Forward and reverse animations on tap: As shown in #9
I'm referring to no. 1 when asking the question so my example would be something like.
AnimateOnTap(
effects: [...],
child: Text('I pulse when tapped'),
)
For no. 2 the state change track will work given that if your button is currently not animated but changes a state you're already tracking the state change so the interpolation between those states on a curve will be a great addition to the package.
I'm hesitant to bind the interaction to the animation so directly, because there will be cases where you want to animate something other than what was clicked.
With the changes in PR #9 , I think this should be pretty straightforward. Quick sketch:
var adapter = ValueAdapter(0.0, animate: true);
// ...
Text('animate me').animate(adapter: adapter).fadeIn();
// ...
Button(onPress: () => adapter.value = 1.0); // this would trigger the animation
You could of course also create more robust adapters that would do things like "bounce" the animation once when activated.
You could also create simple widgets (like your AnimateOnTap) that would reduce boilerplate for common cases.
Does that make sense? Cover the use case?
What about adding animations only at runtime if a certain event occurs, like this:
class TestView extends StatelessWidget {
TestView({Key? key}) : super(key: key);
late Text text;
@override
Widget build(BuildContext context) {
return Center(
child: DefaultTextStyle(
style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold),
child: GestureDetector(
onTap: () => _onTap(),
child: text = const Text("Hello World"),
),
),
);
}
_onTap() {
text.animate()
.slide(curve: Curves.easeOutCubic)
.fadeOut();
}
}
@gskinner Yeah that should work as well. It's verbose and will require state tracking per UI element that needs to update but it should be good enough for now. Thanks for the prompt responses. I appreciate it.
I'll keep an eye out for when #9 is live and I'll see how it feels using it.
Rather than use an adapter, its likely more straightforward for event driven animations, to cache the controller itself:
Text('animate me').animate(
onPlay: (c) {
_fadeController = c;
_c.stop();
}
).fadeIn();
Then:
onPress: () => _fadeController.forward(from: 0)
Existence of autoplay would make this more declarative:
Text('animate me').animate(
autoPlay: false,
onPlay: (c) =>_fadeController = c;
).fadeIn();
But the new onPlay naming feels wrong in this case, onInit would be more accurate :/
@gskinner I will try and close the issue with an example when I can validate the solution. Two issues are
- The PR doesn't include the
animateparameter in the ValueAdapter - The solution is a one-shot trigger. It can't be retriggered unless the value is set back to zero. But setting it back to 0 would cause the widget to first animate in reverse
Till then the work around is to use an animation controller. such as
_controller = AnimationController(
vsync: this, // the SingleTickerProviderStateMixin
value: 1.0); // Display the widget in the end state
Pass the controller in animate
Icon(Icons.favorite, size: 50, color: Colors.red)
.animate(controller: _controller)
.shake(hz: 5, duration: 1.seconds),
And finally on the triggering widget
onPressed: () {
_controller.forward(from: 0.0);
}
@esDotDev that's definitely an option too, if you don't mind storing the controller in the local state. It can be even a tiny bit easier (or at least more compact):
foo.animate(onPlay: (c) => _fadeController = c..stop())
Wouldn't the adapter in this case need to be hoisted out into local state as well? Otherwise it could lose state if the parent is ever rebuilt...?
eg:
var adapter = ValueAdapter(0.0, animate: true);
return Button(onPress: (){
adapter.value = 1.0
setState((){});
});
Pretty sure that would blow out the old adapter and break playback if it's not cached in a stateful class field? So then it really just becomes extra code that is serving as a 2nd controller, when you could just use the primary controller.
True. The adapter solution is potentially reasonable for "transient" animations where losing state isn't really an issue (ex. hover), but it will lose state if the widget is rebuilt.
Another approach, I tried is
Animate(
adapter: TriggerAdapter(triggerCondition),
effects: triggeredEffects,
child: const Icon(Icons.favorite, size: 50, color: Colors.red),
),
This allowed me to decouple and differ the programming of effects to a button such as,
ElevatedButton(
onPressed: () {
setState(() {
triggeredEffects
..clear()
//The effect to apply to the Animated Icon defined earlier
..add(ShakeEffect(hz: 5, duration: 1.seconds));
triggerCondition = true;
});
},
child: const Text("Flutter"),
),
And to achieve this I defined a TriggerAdapter like so,
@immutable
class TriggerAdapter extends Adapter {
TriggerAdapter(this.triggerCondition);
final bool triggerCondition;
@override
void init(AnimationController controller) {
if (triggerCondition) controller.forward(from: 0);
}
}
Output

Do you see any drawbacks to this approach?
Both of these options seem like more work and complication than just doing onPlay: (c) => controller = c..stop() and then onPressed: () => controller.forward().
I guess the latter saves you storing off each controller, so if you had many controllers and didn't want to create a field for each one, it might be nice?
I just added a new parameter to Animate that handles many of these cases, and I would love feedback before I roll it into a v2.1 release.
You can now set target, and it will automatically animate to that position (0=beginning, 1=end) of the animation.
MyButton().animate(target: _over ? 1 : 0)
.fade(end: 0.8).scaleXY(end: 1.1)
We debated the naming a lot (ex. target, position, value), but reluctantly settled on target since that's what animateTo uses, and it's the closest analog. Open to feedback on this.
I'd also love feedback on:
- Whether this addresses your needs
- Does the documentation for it (and the
Reactive Animationsection near the end of the README) make sense - How do you see this working with
AnimateList(if at all) - It currently modifies the timing based on the delta — ex. animating from 0.9-1 takes 10% as long as 0-1. Does that make sense? It's a bit different than say AnimatedOpacity, which always uses the full duration, but I think the end result is nicer for things like roll-over effects where it might change quickly in response to user input.
Hoping to publish in the next week.
The target param is now available in v2.1. Let me know if it does what you need.
https://github.com/gskinner/flutter_animate#reacting-to-state-changes
@gskinner
The
targetparam is now available in v2.1. Let me know if it does what you need. https://github.com/gskinner/flutter_animate#reacting-to-state-changes
the target param does help to animate to that point. But I have a specific requirement which might be common. I want to be able to have an animation effect not render at all until triggered. ie, Say i have an IconButton and I want to do a scale animation on it. I want to do this only when I press it (onPress) and not when its normally rendered.
target=1.0 doesnt work as it animates when rendering as well (not desired). I tried c.stop() but that also isnt working and it still animates the iconbutton on render (without any onpress). Is this a bug or am i doing something wrong here?
IconButton( icon: Icon(Icons.thumb_up), onPressed: () { if(thumbsupcontroller!=null) { thumbsupcontroller!.forward(from: 0); } }, ).animate(target: 1,onPlay: (controller) {thumbsupcontroller=controller; controller.stop();}).scale());
If you just set target to 0 initially, it should not animate.
If you just set target to 0 initially, it should not animate.
@gskinner Thanks for the quick response :)
I did that as the first thing, it doesnt animate yes, but it also doesnt show the iconbutton at all, as 0 means scale value is 0, so it comes as blank there
so you want it to start at scale 1, then when the user presses, it jumps to scale=0, and animates back up to scale=1?
so you want it to start at scale 1, then when the user presses, it jumps to scale=0, and animates back up to scale=1?
@gskinner yes correct, thats what it does, what i want is, it should remain in scale 1 in the beginning (so basically not animate) and then onpress go for it.
@gskinner i also tried to set the controller value, controller.value=1.0, that also doesnt work and it just first shows up the whole thing and then again animates it. I also tried controller.duration = Duration(microsecs: 0), that also flickers and does the animation quickly but it still does it, not stopped
maybe something like:
IconButton(
icon: Icon(Icons.thumb_up),
onPressed: _thumbController.reverse(from: 1),
).animate(
autoPlay: false,
onInit:(c) => _thumbController = c,
).scale(end: 0);
Basically set it up so the animation starts paused on the scale you want to show by default, then play it in reverse when the action happens.
@gskinner nice :) That worked, So basically we ask it to end at offset(0,0) and make autoplay off.
As I will have many places where then I will further trigger it via other logic for other things/activites, is this a clean solution?, or should we use TriggerAdapter like @thesensibledev showed. I havent checked that to be working yet for my usecase, but i was planning to check that and also looked like in boundaries of your framework.
I also have two followup questions:
-
why is target=1 and controller.stop() approach not working, is this inherent design?
-
why animate(onPlay(c) { c.value=1.0,}).scale() is not acknowledged by scale? Is this inherent by design, ie, internal controller's manipulation wont propagate further to the chain of effects?
Thanks for the awesome library, love your work!
I haven't looked at TriggerAdapter in depth, but definitely feel free to test it out.
- Two reasons: First, setting
targetstarts an animation (viaanimateTo), so if you immediatelystopit, it won't "jump" to 1, it'll just stop on the current value. Second, settingtargetbypassesonPlay— I'll think about adding a warning for that, actually. - That should work. Update: Confirmed, I just tested and it works for me — you must have something else going on. All effects that comprise an animation are driven by the single controller.
Text("Playground 🛝")
.animate(onPlay:(controller) => controller.value = 1,)
.slideY(duration: 900.ms, curve: Curves.easeOutCubic)
.fadeIn(),
If you wanted to avoid the one frame flicker, use onInit instead (with autoPlay=false):
Text("Playground 🛝")
.animate(autoPlay: false, onInit: (controller) => controller.value = 1,)
.slideY(duration: 900.ms, curve: Curves.easeOutCubic)
.fadeIn(),
@gskinner I also checked it works. I got the issue, why it didnt work for me.
I initially was making
IconButton(icon: Icon(Icons.thumb_up),onPressed: _thumbController.reverse(from: 1), ).animate(autoPlay: false, onPlay:(c) {_thumbcontroller=c; c.value = 1.0;}).scale();
Here, the onPlay was never called, I didnt know that onPlay is skipped with autoplay=false.
Then I went on with target=1.0 as another way, but there also I get now that it does a animateTo(..) and bypasses onPlay as well, so that also didnt work.
If i did, autoplay=false with onInit call, it would have worked. Then I got trying other combinations :)
Thanks for the help Grant. I think its a good idea to document that (Also the below behavior makes sense, just possible a warning like you said): -If using target, it inherently always does a animateTo and skips onPlay -If autoPlay=false, it skips onPlay