bevy icon indicating copy to clipboard operation
bevy copied to clipboard

Transition animations for UI elements

Open viridia opened this issue 1 year ago • 7 comments

What problem does this solve or what need does it fill?

Most animations in games are based on a timeline with keyframes: that is, you have some parameter which is being animated over a curve. Your options are to start, stop, or reverse the animation, but the control points are usually fixed.

However, a different kind of animation - one often seen in CSS - is an interpolation to a target value, often modulated by some mathematical easing function. (There are also physics-based animations, like mechanical spring simulations). These animations don't have a fixed timeline of keyframes, and in fact the target value can be changed in mid-animation.

These kinds of animations are very useful in UI work for things like popup menus, dialogs, sliding drawers, basically anything that has an "enter" and "exit" transition. "Interpolate to target" animations have an advantage over keyframe-based approaches because the animation is frequently interrupted: the popup menu may be closed before it has finished opening. It also works well for elements that have more than two states, such as a sidebar which might have "hidden", "collapsed" and "expanded" states. The problem with the keyframe approach is that restarting the animation track in mid-animation will cause an unsightly "pop".

A related requirement is that we need some way to poll when the animation is complete. Take for example a dialog or inventory screen: we don't want to keep around the entity hierarchy for the popup when it's not visible. But when the popup closes, we can't despawn the entities right away; we need to wait until the closing animation is complete. Typically this would be done by some conditional expression such as "if open || animation.running" where "running" means that the animation has not reached some quiescent state.

For UI work, it would be ideal to be able to do this by inserting a component into the Node entity, such that this component would continually modify the style and transform properties of the node.

(In my case, I would want to be able to rely on Bevy component change detection for the polling: in other words, I would like to be able to monitor the progress of the animation by looking for changed components. This would let me easily integrate it with my reactive framework.)

The kinds of properties we most frequently want to animate are:

  • scale
  • translation
  • rotation
  • color (background, border, outline or text)
  • alpha

What solution would you like?

I have prototyped a framework for this in bevy_reactor, however this was done over a year ago, and there have been a lot of developments in Bevy since then, and my code doesn't take advantage of any of those improvements. What I would like to see is a solution which integrates all of the various animation ideas that people have had in the last year.

Part of the motivation for this ticket is to start a discussion on what the API might look like.

What alternative(s) have you considered?

I already have a working solution but it's less than ideal.

viridia avatar Oct 08 '24 04:10 viridia

@mockersf - I think this could dovetail with some of the work you have been doing on interpolation curves. @alice-i-cecile @cart @UkoeHB - this is yet another part of the ui puzzle we've been discussing.

viridia avatar Oct 08 '24 05:10 viridia

There is prior art for this in sickle_ui, which has a 'flux interaction' framework for applying dynamic styles (in combination with a pseudostates feature for e.g. Open/Closed states). Here is a copy of the repo.

UkoeHB avatar Oct 08 '24 05:10 UkoeHB

@mockersf - I think this could dovetail with some of the work you have been doing on interpolation curves.

Yup, one of the goal of curves and animations is to be able to be used for this. It's actually already possible, but the API is not a lot friendly for now. I hope to have a better one for the 0.16.

For the current state, you can look at the animated_ui example, and at the animation_event example. I would like a quick way to setup an animation on a field of the current entity, with an event/observer triggered once done. I intend to use the various tweening/easing crates for inspiration on the API, but if you could point me at where you had that in bevy_reactor I'll also take a look

mockersf avatar Oct 08 '24 06:10 mockersf

Here's a quick overview of what I have. Note that this was done early on in my Bevy journey, and I'm not sure I would do things the same way today:

AnimatedTransition<T> is a generic Component, where T is the property being animated: https://github.com/viridia/bevy_reactor/blob/main/crates/bevy_reactor_obsidian/src/animation/mod.rs

So for example, AnimatedTransition::<AnimatedRotation> mutates the transform rotation. The easing curve is currently hard-coded to CubicSpline (see the timing property).

In the current framework, the actual despawning of the entities is handled by a separate timer, which is inside bistable_transition: https://github.com/viridia/bevy_reactor/blob/main/crates/bevy_reactor_obsidian/src/animation/bistable_transition.rs#L84 - this creates a reactive Signal<TransitionState> which goes through the lifecycle of a transition element: EnterStart -> Entering -> Entered -> ExitStart -> Exiting -> Exited.

However, I'd like to get rid of this. Although, one difficulty here is that the logical place to put the transition component is on the entity that is being animated, but that entity may not exist when the dialog is closed. Currently the bistable transition actually lives on the parent, which doesn't go away. (It's a Reaction, which, like Observers, are "owned" by the parent entity).

You can see this in use here:

https://github.com/viridia/bevy_reactor/blob/main/crates/bevy_reactor_obsidian/src/controls/dialog.rs#L138

let state = builder.create_bistable_transition(self.open, TRANSITION_DURATION);

In this code, both 'state' and 'open' are signals. The state signal then feeds into the animation component:

// Animate the opacity of the dialog backdrop
backdrop.effect(
    move |rcx| {
        let state = state.get(rcx);
        // Compute the target opacity from the state
        match state {
            BistableTransitionState::Entering
            | BistableTransitionState::Entered
            | BistableTransitionState::ExitStart => colors::U2.with_alpha(0.7),
            BistableTransitionState::EnterStart
            | BistableTransitionState::Exiting
            | BistableTransitionState::Exited => colors::U2.with_alpha(0.0),
        }
    },
    move |color, ent| {
        // Interpolate the opacity target
        AnimatedTransition::<AnimatedBackgroundColor>::start(
            ent,
            color,
            TRANSITION_DURATION,
        );
    },
)

Note that you don't need to worry about the reactive stuff. I only mention it here because I wanted to show how it ties together.

viridia avatar Oct 08 '24 06:10 viridia

If I were re-implementing this today, I might actually consider dividing each animation into two components: one which contains the target value, duration, and the interpolation type, and a different component which contains the current state, which might be a required component. This would allow changing the animation parameters by overwriting / re-inserting the component, while preserving the current state of the animation. This is somewhat tricky, though, because you might want to animate multiple parameters, which would mean a different state component for each parameter type.

viridia avatar Oct 08 '24 20:10 viridia

I recently made a few more tweaks to my animation framework, and I also wanted to give a clearer explanation about the dialog lifecycle.

The basic constraint is that we don't want the UI nodes for popups (dialogs and menus) to be hanging around, invisible, when they are closed. This not only consumes memory for the nodes themselves, but any resources that they may be hanging on to. So we want to despawn the entities when they are not needed. But when you click the "close" button on a dialog, you don't want to delete the entities immediately - instead, you want to wait until the closing animation completes before despawning the hierarchy.

A dialog typically has two animated elements: the popup itself, and a "backdrop" element which covers the entire screen and grays out the background. The backdrop covers the entire window (window-absolute coordinates), and fades in and out using an animated background color, while the dialog may have a variety of animated transitions: opacity, scale, position, and so on.

(Note that when I say "dialog" I don't just mean the traditional dialog box, but include things like the inventory screen in Skyrim).

Currently the way I handle this is to place animation components on the dialog and the background, but use a separate timer (whose duration is the same as the length of the closing animation) to despawn the dialog elements. This timer is the "bistable transition" that I mentioned earlier. The reason I don't use the animation components for the despawning is because those components are themselves despawned - that is, before the dialog opens, and after it is finished closing, those entities don't exist. This makes change-detection complicated.

When the dialog first opens, we want the animations to smoothly transition from the "closed" state to the "open" state - however, we can't use a simply "interpolate from previous state" because there is no previous state - the entities don't exist. This is a problem in CSS too, often we need to insert an extra state in at the beginning to represent the "fully closed but opening" state.

Instead, the way I set this up in Bevy is to have the animation "start" method take an optional "initial state" parameter:

animation.transition_to(1.0, Some(0.0));

The initial state parameter is ignored if there is already a transition in progress - it simply continues from wherever the transition is currently at. However, if there is no animation in progress, then the initial value param is used to start a new transition. So for the "open" animation we transition from 0 to 1, and for the closing animation we transition from 1 to 0. (This is the t value that is input to the easing curve). If we change our minds in mid-go (like canceling the dialog before it is fully open), then we go from current t to the new target t.

viridia avatar Oct 13 '24 16:10 viridia

I've also been looking at layout projection https://gist.github.com/taowen/e102cf5731e527cb9ac02574783c4119 which lets you animate UI layouts. E.g. animate from flex-start to flex-end. I don't quite understand how it works yet, but it's something I want to mention.

JMS55 avatar Dec 01 '24 01:12 JMS55

Here's a quick overview of what I have. Note that this was done early on in my Bevy journey, and I'm not sure I would do things the same way today:

AnimatedTransition<T> is a generic Component, where T is the property being animated: https://github.com/viridia/bevy_reactor/blob/main/crates/bevy_reactor_obsidian/src/animation/mod.rs

So for example, AnimatedTransition::<AnimatedRotation> mutates the transform rotation. The easing curve is currently hard-coded to CubicSpline (see the timing property).

In the current framework, the actual despawning of the entities is handled by a separate timer, which is inside bistable_transition: https://github.com/viridia/bevy_reactor/blob/main/crates/bevy_reactor_obsidian/src/animation/bistable_transition.rs#L84 - this creates a reactive Signal<TransitionState> which goes through the lifecycle of a transition element: EnterStart -> Entering -> Entered -> ExitStart -> Exiting -> Exited.

However, I'd like to get rid of this. Although, one difficulty here is that the logical place to put the transition component is on the entity that is being animated, but that entity may not exist when the dialog is closed. Currently the bistable transition actually lives on the parent, which doesn't go away. (It's a Reaction, which, like Observers, are "owned" by the parent entity).

You can see this in use here:

https://github.com/viridia/bevy_reactor/blob/main/crates/bevy_reactor_obsidian/src/controls/dialog.rs#L138

let state = builder.create_bistable_transition(self.open, TRANSITION_DURATION); In this code, both 'state' and 'open' are signals. The state signal then feeds into the animation component:

// Animate the opacity of the dialog backdrop backdrop.effect( move |rcx| { let state = state.get(rcx); // Compute the target opacity from the state match state { BistableTransitionState::Entering | BistableTransitionState::Entered | BistableTransitionState::ExitStart => colors::U2.with_alpha(0.7), BistableTransitionState::EnterStart | BistableTransitionState::Exiting | BistableTransitionState::Exited => colors::U2.with_alpha(0.0), } }, move |color, ent| { // Interpolate the opacity target AnimatedTransition::<AnimatedBackgroundColor>::start( ent, color, TRANSITION_DURATION, ); }, ) Note that you don't need to worry about the reactive stuff. I only mention it here because I wanted to show how it ties together.

This is also what I've being doing, in a bit of a different way with reflex etc, I +1 the idea of splitting the transition data and target value into two separate components. Thinking about my current code base it would make more sense

Kees-van-Beilen avatar Jan 13 '25 16:01 Kees-van-Beilen

Additional discussion: In UI operations, it is also a common requirement for users to pause/cancel on doing animations. For example, when the user initiates a new gesture operation when the animation is halfway through, the animation should be canceled and the user can continue the operation from the current position.

RedTrait avatar Mar 23 '25 12:03 RedTrait