Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Transition animations for UI elements #15725

Open
viridia opened this issue Oct 8, 2024 · 8 comments
Open

Transition animations for UI elements #15725

viridia opened this issue Oct 8, 2024 · 8 comments
Labels
A-UI Graphical user interfaces, styles, layouts, and widgets C-Feature A new feature, making something new possible D-Complex Quite challenging from either a design or technical perspective. Ask for help! S-Needs-Design This issue requires design work to think about how it would best be accomplished

Comments

@viridia
Copy link
Contributor

viridia commented Oct 8, 2024

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 viridia added C-Feature A new feature, making something new possible S-Needs-Triage This issue needs to be labelled labels Oct 8, 2024
@LiamGallagher737 LiamGallagher737 added A-UI Graphical user interfaces, styles, layouts, and widgets D-Complex Quite challenging from either a design or technical perspective. Ask for help! S-Needs-Design This issue requires design work to think about how it would best be accomplished and removed S-Needs-Triage This issue needs to be labelled labels Oct 8, 2024
@viridia
Copy link
Contributor Author

viridia commented Oct 8, 2024

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

@UkoeHB
Copy link
Contributor

UkoeHB commented Oct 8, 2024

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.

@mockersf
Copy link
Member

mockersf commented Oct 8, 2024

@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

@viridia
Copy link
Contributor Author

viridia commented Oct 8, 2024

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
Copy link
Contributor Author

viridia commented Oct 8, 2024

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
Copy link
Contributor Author

viridia commented Oct 13, 2024

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.

@JMS55
Copy link
Contributor

JMS55 commented Dec 1, 2024

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.

@Kees-van-Beilen
Copy link
Contributor

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-UI Graphical user interfaces, styles, layouts, and widgets C-Feature A new feature, making something new possible D-Complex Quite challenging from either a design or technical perspective. Ask for help! S-Needs-Design This issue requires design work to think about how it would best be accomplished
Projects
None yet
Development

No branches or pull requests

6 participants