From 220901e3a128cb7c01e708bd2845fe5a892ccf2c Mon Sep 17 00:00:00 2001 From: LazyTanuki <43273245+lazytanuki@users.noreply.github.com> Date: Fri, 31 Jan 2025 21:16:53 +0100 Subject: [PATCH 1/2] Animate `Toggler` --- core/src/animation.rs | 11 ++++ core/src/theme/palette.rs | 6 +- widget/Cargo.toml | 1 + widget/src/toggler.rs | 118 +++++++++++++++++++++++++++++++------- 4 files changed, 114 insertions(+), 22 deletions(-) diff --git a/core/src/animation.rs b/core/src/animation.rs index 14cbb5c39c..ed14856ab4 100644 --- a/core/src/animation.rs +++ b/core/src/animation.rs @@ -101,6 +101,17 @@ where self.raw.transition(new_state, Instant::now()); } + /// Instantaneously transitions the [`Animation`] from its current state to the given new state. + pub fn force(mut self, new_state: T) -> Self { + self.force_mut(new_state); + self + } + + /// Instantaneously transitions the [`Animation`] from its current state to the given new state, by reference. + pub fn force_mut(&mut self, new_state: T) { + self.raw.transition_instantaneous(new_state, Instant::now()); + } + /// Returns true if the [`Animation`] is currently in progress. /// /// An [`Animation`] is in progress when it is transitioning to a different state. diff --git a/core/src/theme/palette.rs b/core/src/theme/palette.rs index b69f99b195..9c841cc86e 100644 --- a/core/src/theme/palette.rs +++ b/core/src/theme/palette.rs @@ -623,7 +623,8 @@ fn lighten(color: Color, amount: f32) -> Color { from_hsl(hsl) } -fn deviate(color: Color, amount: f32) -> Color { +/// Lighten dark colors and darken light ones by the specefied amount. +pub fn deviate(color: Color, amount: f32) -> Color { if is_dark(color) { lighten(color, amount) } else { @@ -631,7 +632,8 @@ fn deviate(color: Color, amount: f32) -> Color { } } -fn mix(a: Color, b: Color, factor: f32) -> Color { +/// Mix with another color with the given ratio (from 0 to 1) +pub fn mix(a: Color, b: Color, factor: f32) -> Color { let a_lin = Rgb::from(a).into_linear(); let b_lin = Rgb::from(b).into_linear(); diff --git a/widget/Cargo.toml b/widget/Cargo.toml index 6d1f054e56..c1795fa4d6 100644 --- a/widget/Cargo.toml +++ b/widget/Cargo.toml @@ -27,6 +27,7 @@ wgpu = ["iced_renderer/wgpu"] markdown = ["dep:pulldown-cmark", "dep:url"] highlighter = ["dep:iced_highlighter"] advanced = [] +animations = [] [dependencies] iced_renderer.workspace = true diff --git a/widget/src/toggler.rs b/widget/src/toggler.rs index b711432e10..b2df642c88 100644 --- a/widget/src/toggler.rs +++ b/widget/src/toggler.rs @@ -31,14 +31,18 @@ //! } //! ``` use crate::core::alignment; +use crate::core::animation::Easing; use crate::core::layout; use crate::core::mouse; use crate::core::renderer; use crate::core::text; +use crate::core::theme::palette::mix; +use crate::core::time::Instant; use crate::core::touch; use crate::core::widget; use crate::core::widget::tree::{self, Tree}; use crate::core::window; +use crate::core::Animation; use crate::core::{ Border, Clipboard, Color, Element, Event, Layout, Length, Pixels, Rectangle, Shell, Size, Theme, Widget, @@ -102,6 +106,28 @@ pub struct Toggler< last_status: Option, } +/// The state of the [`Toggler`] +#[derive(Debug)] +pub struct State +where + Paragraph: text::Paragraph, +{ + now: Instant, + transition: Animation, + text_state: widget::text::State, +} + +impl State +where + Paragraph: text::Paragraph, +{ + /// This check is meant to fix cases when we get a tainted state from another + /// ['Toggler'] widget by finding impossible cases. + fn is_animation_state_tainted(&self, is_toggled: bool) -> bool { + is_toggled != self.transition.value() + } +} + impl<'a, Message, Theme, Renderer> Toggler<'a, Message, Theme, Renderer> where Theme: Catalog, @@ -256,7 +282,11 @@ where } fn state(&self) -> tree::State { - tree::State::new(widget::text::State::::default()) + tree::State::new(State { + now: Instant::now(), + transition: Animation::new(self.is_toggled).easing(Easing::EaseOut), + text_state: widget::text::State::::default(), + }) } fn size(&self) -> Size { @@ -280,12 +310,11 @@ where |_| layout::Node::new(Size::new(2.0 * self.size, self.size)), |limits| { if let Some(label) = self.label.as_deref() { - let state = tree - .state - .downcast_mut::>(); + let state = + tree.state.downcast_mut::>(); widget::text::layout( - state, + &mut state.text_state, renderer, limits, self.width, @@ -308,7 +337,7 @@ where fn update( &mut self, - _state: &mut Tree, + tree: &mut Tree, event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, @@ -327,6 +356,14 @@ where let mouse_over = cursor.is_over(layout.bounds()); if mouse_over { + let state = + tree.state.downcast_mut::>(); + if cfg!(feature = "animations") { + state.transition.go_mut(!self.is_toggled); + } else { + state.transition.force_mut(!self.is_toggled); + } + shell.request_redraw(); shell.publish(on_toggle(!self.is_toggled)); shell.capture_event(); } @@ -334,19 +371,35 @@ where _ => {} } + let state = tree.state.downcast_mut::>(); + + let animation_progress = + state.transition.interpolate(0.0, 1.0, Instant::now()); let current_status = if self.on_toggle.is_none() { Status::Disabled } else if cursor.is_over(layout.bounds()) { Status::Hovered { is_toggled: self.is_toggled, + animation_progress, } } else { Status::Active { is_toggled: self.is_toggled, + animation_progress, } }; - if let Event::Window(window::Event::RedrawRequested(_now)) = event { + if let Event::Window(window::Event::RedrawRequested(now)) = event { + state.now = *now; + + // Reset animation on tainted state + if state.is_animation_state_tainted(self.is_toggled) { + state.transition.force_mut(self.is_toggled); + } + + if state.transition.is_animating(*now) { + shell.request_redraw(); + } self.last_status = Some(current_status); } else if self .last_status @@ -394,11 +447,14 @@ where let mut children = layout.children(); let toggler_layout = children.next().unwrap(); + let state = tree.state.downcast_ref::>(); if self.label.is_some() { let label_layout = children.next().unwrap(); - let state: &widget::text::State = - tree.state.downcast_ref(); + let state: &widget::text::State = &tree + .state + .downcast_ref::>() + .text_state; crate::text::draw( renderer, @@ -437,13 +493,10 @@ where style.background, ); + let x_ratio = state.transition.interpolate(0.0, 1.0, state.now); let toggler_foreground_bounds = Rectangle { x: bounds.x - + if self.is_toggled { - bounds.width - 2.0 * space - (bounds.height - (4.0 * space)) - } else { - 2.0 * space - }, + + (2.0 * space + (x_ratio * (bounds.width - bounds.height))), y: bounds.y + (2.0 * space), width: bounds.height - (4.0 * space), height: bounds.height - (4.0 * space), @@ -479,17 +532,21 @@ where } /// The possible status of a [`Toggler`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum Status { /// The [`Toggler`] can be interacted with. Active { /// Indicates whether the [`Toggler`] is toggled. is_toggled: bool, + /// Current progress of the transition animation + animation_progress: f32, }, /// The [`Toggler`] is being hovered. Hovered { /// Indicates whether the [`Toggler`] is toggled. is_toggled: bool, + /// Current progress of the transition animation + animation_progress: f32, }, /// The [`Toggler`] is disabled. Disabled, @@ -546,25 +603,46 @@ pub fn default(theme: &Theme, status: Status) -> Style { let palette = theme.extended_palette(); let background = match status { - Status::Active { is_toggled } | Status::Hovered { is_toggled } => { + Status::Active { + is_toggled, + animation_progress, + } + | Status::Hovered { + is_toggled, + animation_progress, + } => { if is_toggled { - palette.primary.strong.color + mix( + palette.primary.strong.color, + palette.background.strong.color, + 1.0 - animation_progress, + ) } else { - palette.background.strong.color + mix( + palette.background.strong.color, + palette.primary.strong.color, + animation_progress, + ) } } Status::Disabled => palette.background.weak.color, }; let foreground = match status { - Status::Active { is_toggled } => { + Status::Active { + is_toggled, + animation_progress: _, + } => { if is_toggled { palette.primary.strong.text } else { palette.background.base.color } } - Status::Hovered { is_toggled } => { + Status::Hovered { + is_toggled, + animation_progress: _, + } => { if is_toggled { Color { a: 0.5, From 9f68641b7647ff8e78ecc7c1a06cc508a59f7000 Mon Sep 17 00:00:00 2001 From: LazyTanuki <43273245+lazytanuki@users.noreply.github.com> Date: Sat, 1 Feb 2025 01:22:08 +0100 Subject: [PATCH 2/2] Add animations example --- Cargo.lock | 7 +++++ Cargo.toml | 2 ++ examples/animations/Cargo.toml | 10 +++++++ examples/animations/README.md | 12 ++++++++ examples/animations/src/main.rs | 49 +++++++++++++++++++++++++++++++++ widget/src/toggler.rs | 18 ++++++++++-- 6 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 examples/animations/Cargo.toml create mode 100644 examples/animations/README.md create mode 100644 examples/animations/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 072f8e6106..7d9652bea2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,13 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "animations" +version = "0.1.0" +dependencies = [ + "iced", +] + [[package]] name = "anstyle" version = "1.0.10" diff --git a/Cargo.toml b/Cargo.toml index 364f1b5c2a..59e9dbea85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,8 @@ auto-detect-theme = ["iced_core/auto-detect-theme"] strict-assertions = ["iced_renderer/strict-assertions"] # Redraws on every runtime event, and not only when a widget requests it unconditional-rendering = ["iced_winit/unconditional-rendering"] +# Enables widget animations +animations = ["iced_widget/animations"] [dependencies] iced_core.workspace = true diff --git a/examples/animations/Cargo.toml b/examples/animations/Cargo.toml new file mode 100644 index 0000000000..d08ab4094e --- /dev/null +++ b/examples/animations/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "animations" +version = "0.1.0" +authors = ["LazyTanuki"] +edition = "2021" +publish = false + +[dependencies] +iced.workspace = true +iced.features = ["animations"] diff --git a/examples/animations/README.md b/examples/animations/README.md new file mode 100644 index 0000000000..e952e8d8d4 --- /dev/null +++ b/examples/animations/README.md @@ -0,0 +1,12 @@ +# Animations + +An application to showcase Iced widgets that have default animations. + +The __[`main`]__ file contains all the code of the example. + +You can run it with `cargo run`: +``` +cargo run --package animations +``` + +[`main`]: src/main.rs diff --git a/examples/animations/src/main.rs b/examples/animations/src/main.rs new file mode 100644 index 0000000000..54a30c7a83 --- /dev/null +++ b/examples/animations/src/main.rs @@ -0,0 +1,49 @@ +use iced::{ + widget::{column, Toggler}, + Element, Task, Theme, +}; + +pub fn main() -> iced::Result { + iced::application("Animated widgets", Animations::update, Animations::view) + .theme(Animations::theme) + .run() +} + +#[derive(Default)] +struct Animations { + toggled: bool, +} + +#[derive(Debug, Clone)] +enum Message { + Toggle(bool), +} + +impl Animations { + fn update(&mut self, message: Message) -> Task { + match message { + Message::Toggle(t) => { + self.toggled = t; + Task::none() + } + } + } + + fn view(&self) -> Element { + let main_text = iced::widget::text( + "You can find all widgets with default animations here.", + ); + let toggle = Toggler::new(self.toggled) + .label("Toggle me!") + .on_toggle(Message::Toggle); + column![main_text, toggle] + .spacing(10) + .padding(50) + .max_width(800) + .into() + } + + fn theme(&self) -> Theme { + Theme::Light + } +} diff --git a/widget/src/toggler.rs b/widget/src/toggler.rs index b2df642c88..c1489e4c48 100644 --- a/widget/src/toggler.rs +++ b/widget/src/toggler.rs @@ -447,7 +447,6 @@ where let mut children = layout.children(); let toggler_layout = children.next().unwrap(); - let state = tree.state.downcast_ref::>(); if self.label.is_some() { let label_layout = children.next().unwrap(); @@ -493,7 +492,7 @@ where style.background, ); - let x_ratio = state.transition.interpolate(0.0, 1.0, state.now); + let x_ratio = style.foreground_bounds_horizontal_progress; let toggler_foreground_bounds = Rectangle { x: bounds.x + (2.0 * space + (x_ratio * (bounds.width - bounds.height))), @@ -567,6 +566,8 @@ pub struct Style { pub foreground_border_width: f32, /// The [`Color`] of the foreground border of the toggler. pub foreground_border_color: Color, + /// The horizontal progress ratio of the foreground bounds of the toggler. + pub foreground_bounds_horizontal_progress: f32, } /// The theme catalog of a [`Toggler`]. @@ -655,6 +656,18 @@ pub fn default(theme: &Theme, status: Status) -> Style { Status::Disabled => palette.background.base.color, }; + let foreground_bounds_horizontal_progress = match status { + Status::Active { + is_toggled: _, + animation_progress, + } => animation_progress, + Status::Hovered { + is_toggled: _, + animation_progress, + } => animation_progress, + Status::Disabled => 0.0, + }; + Style { background, foreground, @@ -662,5 +675,6 @@ pub fn default(theme: &Theme, status: Status) -> Style { foreground_border_color: Color::TRANSPARENT, background_border_width: 0.0, background_border_color: Color::TRANSPARENT, + foreground_bounds_horizontal_progress, } }