flutter_animate icon indicating copy to clipboard operation
flutter_animate copied to clipboard

Triggering the animation when a condition is met

Open thesensibledev opened this issue 3 years ago • 13 comments
trafficstars

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 avatar Sep 07 '22 23:09 thesensibledev

@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 👀

FilledStacks avatar Sep 08 '22 12:09 FilledStacks

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 avatar Sep 08 '22 23:09 gskinner

@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.

  1. Repeat the same animation on tap: Similar to Flutter Bounce
  2. 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.

FilledStacks avatar Sep 09 '22 01:09 FilledStacks

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?

gskinner avatar Sep 09 '22 03:09 gskinner

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

rivella50 avatar Sep 09 '22 08:09 rivella50

@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.

FilledStacks avatar Sep 09 '22 09:09 FilledStacks

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

esDotDev avatar Sep 09 '22 14:09 esDotDev

@gskinner I will try and close the issue with an example when I can validate the solution. Two issues are

  1. The PR doesn't include the animate parameter in the ValueAdapter
  2. 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);
}

thesensibledev avatar Sep 09 '22 14:09 thesensibledev

@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())

gskinner avatar Sep 09 '22 15:09 gskinner

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.

esDotDev avatar Sep 09 '22 15:09 esDotDev

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.

gskinner avatar Sep 09 '22 15:09 gskinner

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 animation

Do you see any drawbacks to this approach?

thesensibledev avatar Sep 22 '22 06:09 thesensibledev

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?

esDotDev avatar Sep 30 '22 03:09 esDotDev

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:

  1. Whether this addresses your needs
  2. Does the documentation for it (and the Reactive Animation section near the end of the README) make sense
  3. How do you see this working with AnimateList (if at all)
  4. 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.

gskinner avatar Dec 24 '22 18:12 gskinner

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 avatar Jan 18 '23 17:01 gskinner

@gskinner

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

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

khuntia avatar Feb 28 '23 00:02 khuntia

If you just set target to 0 initially, it should not animate.

gskinner avatar Feb 28 '23 00:02 gskinner

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

khuntia avatar Feb 28 '23 00:02 khuntia

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 avatar Feb 28 '23 00:02 gskinner

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.

khuntia avatar Feb 28 '23 00:02 khuntia

@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

khuntia avatar Feb 28 '23 00:02 khuntia

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 avatar Feb 28 '23 00:02 gskinner

@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:

  1. why is target=1 and controller.stop() approach not working, is this inherent design?

  2. 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!

khuntia avatar Feb 28 '23 00:02 khuntia

I haven't looked at TriggerAdapter in depth, but definitely feel free to test it out.

  1. Two reasons: First, setting target starts an animation (via animateTo), so if you immediately stop it, it won't "jump" to 1, it'll just stop on the current value. Second, setting target bypasses onPlay — I'll think about adding a warning for that, actually.
  2. 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 avatar Feb 28 '23 15:02 gskinner

@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

khuntia avatar Feb 28 '23 19:02 khuntia