Skip to content

transition widget#3344

Open
B0ney wants to merge 19 commits into
iced-rs:masterfrom
B0ney:transition_widget
Open

transition widget#3344
B0ney wants to merge 19 commits into
iced-rs:masterfrom
B0ney:transition_widget

Conversation

@B0ney
Copy link
Copy Markdown
Contributor

@B0ney B0ney commented May 29, 2026

This pr introduces a new transition widget to help make animated views much more ergonomic to implement.

The current approach to adding animations requires storing some Animation<T> in app state, subscribing to window redraws via window::frames and then updating the animation.

Furthermore, since interpolating a value also requires Instant, if you're not using application::timed, it's fairly common to store Instant in app state, often-times in multiple places, or even bundled together with Animation<T> in another struct.

The plumbing can get quite cumbersome if you want to animate several parts of your app. It's not the kind that could be made easier with helper functions.

API Rundown

The transition function is quite simple:

// somewhere in view code...
let animated_widget = transition(init, target_value, view);
  • init - A closure to initialize the animation.
  • target_value - the value the animation will transition to.
  • view - A closure that takes Animation and Instant to produce an element. This gets called every frame until the animation is complete.

Example

A transition widget can be used to make the progress_bar smooth:

use std::convert::identity;
use std::time::Duration;

use iced::widget::{column, progress_bar, slider, transition};
use iced::{Animation, Center, Element};

pub fn main() -> iced::Result {
    iced::run(Progress::update, Progress::view)
}

#[derive(Default)]
struct Progress {
    slider: f32,
}

#[derive(Debug, Clone, Copy)]
enum Message {
    Slider(f32),
}

impl Progress {
    fn update(&mut self, message: Message) {
        match message {
            Message::Slider(s) => self.slider = s,
        }
    }

    fn view(&self) -> Element<'_, Message> {
        column![
            slider(0.0..=1.0, self.slider, Message::Slider).step(0.1),
            transition(init, self.slider, move |animate, instant| {
                progress_bar(0.0..=1.0, animate.interpolate_with(identity, instant)).into()
            })
        ]
        .padding(20)
        .align_x(Center)
        .into()
    }
}

fn init() -> Animation<f32> {
    Animation::new(0.)
        .duration(Duration::from_secs_f32(0.4))
        .easing(iced::animation::Easing::EaseOut)
}
smooth-progress-bar.mp4

Grouping Multiple Animations

Eventually, there will be a point where we may want to apply several animations to different parts of the same widget/view. You could nest multiple transition widgets, but that gets horrible quickly.

To remediate this, I've introduced a transition::grouped helper.

The API is the same as before:

transition::grouped(init, target_value, view);

The only difference is that init can return any type so long as it implements transition::Program:

trait Program: 'static {
  type Target: Copy + 'static;
  
  fn tick(&mut self, target_value: Self::Target, now: Instant);
  
  fn is_animating(&self, now: Instant) -> bool;
}

It's the implementor's responsibility to ensure that every animation used is updated properly. Consequently, this approach makes it fairly easy to forget to call .go_mut and .is_animating for every animation we add.

Admittedly, this part could do with more work. We could explore using closures for tick and is_animating to permit borrowing from app state instead of traits.

Example

Let's update our smooth progress bar so that it turns green when it reaches 100% and let's also make it pulse.

use std::convert::identity;
use std::time::Duration;

use iced::widget::{column, progress_bar, slider, transition};
use iced::{Animation, Center, Element};

pub fn main() -> iced::Result {
    iced::run(Slider::update, Slider::view)
}

#[derive(Default)]
struct Slider {
    slider: f32,
}

#[derive(Debug, Clone, Copy)]
enum Message {
    Slider(f32),
}

impl Slider {
    fn update(&mut self, message: Message) {
        match message {
            Message::Slider(s) => self.slider = s,
        }
    }

    fn view(&self) -> Element<'_, Message> {
        column![
            slider(0.0..=1.0, self.slider, Message::Slider).step(0.1),
            transition::grouped(
                Animations::init,
                (self.slider, self.slider == 1.0),
                move |animate, instant| {
                    let color = animate.color.interpolate(0.0, 1.0, instant);
                    let pulse = animate.pulse.interpolate(0.0, 1.0, instant);
                    let progress = animate.progress.interpolate_with(identity, instant);

                    progress_bar(0.0..=1.0, progress)
                        .style(move |t| {
                            let primary_style = progress_bar::primary(t);

                            let positive = t.palette().success.base.color;
                            let base = t.palette().primary.base.color;

                            progress_bar::Style {
                                bar: base.mix(positive, color).into(),
                                border: iced::Border {
                                    width: pulse * 4.0,
                                    ..primary_style.border
                                },
                                ..primary_style
                            }
                        })
                        .into()
                }
            )
        ]
        .padding(20)
        .align_x(Center)
        .into()
    }
}

struct Animations {
    progress: Animation<f32>,
    color: Animation<bool>,
    pulse: Animation<bool>,
}

impl Animations {
    fn init() -> Self {
        Self {
            progress: Animation::new(0.)
                .duration(Duration::from_secs_f32(0.4))
                .easing(iced::animation::Easing::EaseOut),
            color: Animation::new(false)
                .duration(Duration::from_secs_f32(0.3))
                .easing(iced::animation::Easing::EaseInCubic),
            pulse: Animation::new(false)
                .duration(Duration::from_secs_f32(0.2))
                .easing(iced::animation::Easing::EaseIn)
                .auto_reverse()
                .repeat_forever(),
        }
    }
}

impl transition::Program for Animations {
    type Target = (f32, bool);

    fn tick(&mut self, (progress, is_complete): Self::Target, now: std::time::Instant) {
        self.progress.go_mut(progress, now);
        self.color.go_mut(is_complete, now);
        self.pulse.go_mut(true, now);
    }

    fn is_animating(&self, now: std::time::Instant) -> bool {
        self.progress.is_animating(now)
            || self.color.is_animating(now)
            || self.pulse.is_animating(now)
    }
}
updated-progress-bar.mp4

Resetting an Animation

The animation can be reset under 3 circumstances:

  • The widget tree changes.
  • .key(value) is added, and value changes.
  • Returning transition::reset(id) to the runtime, given that the widget is given matching IDs.

I personally haven't found a use case where I would need to explicitly reset the internal state, but since we no longer directly control the Animation, this could be useful.

Limitations (so far)

  • The Animation provided by the view closure has a shorter lifetime, therefore it cannot be passed directly to styling closures. You'll need to interpolate the value beforehand.
  • Buttons will appear Disabled when it's animated since they don't store Status in the widget tree.
  • Complex animations are uncharted territory.

Testing

I haven't done anything non-trivial with this widget yet, so there's plenty of room for more testing and exploration.

@hecrj hecrj force-pushed the transition_widget branch from 4b4227c to 986075e Compare June 1, 2026 06:46
@hecrj hecrj force-pushed the transition_widget branch from 915cab1 to 7967b98 Compare June 1, 2026 07:14
@hecrj
Copy link
Copy Markdown
Member

hecrj commented Jun 2, 2026

Made some changes:

  • Unified grouped and transition together, since both signatures are compatible thanks to the Program trait.
  • Renamed target_value to value and Target to Value. A bit nitpicky, but I feel target conveys additional meaning that may be confusing.
  • Renamed the tick method to go. It better represents the transition idea (and ideally we should only call this when value changes).

It's a bit unfortunate that transition needs a width and height of its own, instead of inheriting the sizing strategy of its contents. This is a limitation of the current layout architecture; so I'm going to explore a different approach that could unlock this.

@hecrj
Copy link
Copy Markdown
Member

hecrj commented Jun 4, 2026

It's a bit unfortunate that transition needs a width and height of its own, instead of inheriting the sizing strategy of its contents. This is a limitation of the current layout architecture; so I'm going to explore a different approach that could unlock this.

Thanks to #3348, this is no longer the case!

@hecrj
Copy link
Copy Markdown
Member

hecrj commented Jun 4, 2026

This is looking good.

One of the advantages of a custom widget like this is that we may be able to trigger application-wide layout invalidation only when the contents change sizing strategy. This should make small local animations a lot more efficient, as they will only trigger a local relayout and a redraw.

It's a bit tricky to get it right, however. But let me cook.

@B0ney
Copy link
Copy Markdown
Contributor Author

B0ney commented Jun 4, 2026

Sounds good 👍, I'll leave the rest to you.

@hecrj
Copy link
Copy Markdown
Member

hecrj commented Jun 4, 2026

I cooked something in 597aa3b. It isn't pretty, but it should get the job done; hopefully.

@hecrj hecrj force-pushed the transition_widget branch from cec5665 to 597aa3b Compare June 4, 2026 11:46
@hecrj hecrj added this to the 0.15 milestone Jun 4, 2026
@hecrj hecrj added feature New feature or request animation labels Jun 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants