diff --git a/core/src/color.rs b/core/src/color.rs index fe0a18569b..6b9cb49f61 100644 --- a/core/src/color.rs +++ b/core/src/color.rs @@ -131,6 +131,22 @@ impl Color { pub fn inverse(self) -> Color { Color::new(1.0f32 - self.r, 1.0f32 - self.g, 1.0f32 - self.b, self.a) } + + /// Mix with another color with the given ratio (from 0 to 1) + pub fn mix(&mut self, other: Color, ratio: f32) { + if ratio >= 1.0 { + self.r = other.r; + self.g = other.g; + self.b = other.b; + self.a = other.a; + } else if ratio > 0.0 { + let self_ratio = 1.0 - ratio; + self.r = self.r * self_ratio + other.r * ratio; + self.g = self.g * self_ratio + other.g * ratio; + self.b = self.b * self_ratio + other.b * ratio; + self.a = self.a * self_ratio + other.a * ratio; + } + } } impl From<[f32; 3]> for Color { @@ -252,5 +268,12 @@ mod tests { a: 1.0 } ); + + // Mix two colors + let white = Color::WHITE; + let black = Color::BLACK; + let mut darkgrey = white; + darkgrey.mix(black, 0.75); + assert_eq!(darkgrey, Color::from_rgba(0.25, 0.25, 0.25, 1.0)); } } diff --git a/examples/animations/Cargo.toml b/examples/animations/Cargo.toml new file mode 100644 index 0000000000..54785cf917 --- /dev/null +++ b/examples/animations/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "animations" +version = "0.1.0" +authors = ["LazyTanuki"] +edition = "2021" +publish = false + +[dependencies] +iced = { path = "../..", features = ["debug"] } +# iced = "0.9" +iced_aw = { git = "https://github.com/iced-rs/iced_aw", branch = "main", features = ["tabs"] } diff --git a/examples/animations/README.md b/examples/animations/README.md new file mode 100644 index 0000000000..2a4e12b3b9 --- /dev/null +++ b/examples/animations/README.md @@ -0,0 +1,8 @@ +## Animations + +An application to showcase Iced widgets that have default animations. + +The __[`main`]__ file contains all the code of the example. + +[`main`]: src/main.rs +[TodoMVC]: http://todomvc.com/ diff --git a/examples/animations/src/main.rs b/examples/animations/src/main.rs new file mode 100644 index 0000000000..5bf5c6f434 --- /dev/null +++ b/examples/animations/src/main.rs @@ -0,0 +1,88 @@ +use iced::widget::{button, column, radio, text_input, Toggler}; +use iced::{Element, Sandbox, Settings}; + +pub fn main() -> iced::Result { + Animations::run(Settings::default()) +} + +struct Animations { + _animation_multiplier: i32, + radio1: Option, + radio2: Option, + input_text: String, + toggled: bool, +} + +#[derive(Debug, Clone)] +enum Message { + ButtonPressed, + RadioPressed(usize), + TextSubmitted, + TextChanged(String), + Toggle(bool), +} + +impl Sandbox for Animations { + type Message = Message; + + fn new() -> Self { + Self { + _animation_multiplier: 0, + radio1: None, + radio2: None, + input_text: "".into(), + toggled: false, + } + } + + fn theme(&self) -> iced::Theme { + iced::Theme::Dark + } + + fn title(&self) -> String { + String::from("Counter - Iced") + } + + fn update(&mut self, message: Message) { + match message { + Message::RadioPressed(i) => match i { + 1 => { + self.radio1 = Some(1); + self.radio2 = None; + } + 2 => { + self.radio2 = Some(2); + self.radio1 = None; + } + _ => {} + }, + Message::TextChanged(txt) => { + self.input_text = txt; + } + Message::Toggle(t) => self.toggled = t, + Message::TextSubmitted | Message::ButtonPressed => {} + } + } + + fn view(&self) -> Element { + column![ + text_input("Insert some text here...", &self.input_text) + .on_submit(Message::TextSubmitted) + .on_input(|txt| Message::TextChanged(txt)), + button("Press me").on_press(Message::ButtonPressed), + radio("Click me 1", 1, self.radio1, |i| { + Message::RadioPressed(i) + }), + radio("Click me 2", 2, self.radio2, |i| { + Message::RadioPressed(i) + }), + Toggler::new(Some("Toggle me".into()), self.toggled, |t| { + Message::Toggle(t) + }) + ] + .spacing(10) + .padding(50) + .max_width(300) + .into() + } +} diff --git a/style/src/animation.rs b/style/src/animation.rs new file mode 100644 index 0000000000..d04a2d2bea --- /dev/null +++ b/style/src/animation.rs @@ -0,0 +1,195 @@ +//! Add animations to widgets. + +/// Hover animation of the widget +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct HoverPressedAnimation { + /// Animation direction: forward means it goes from non-hovered to hovered state + pub direction: AnimationDirection, + /// The instant the animation was started at (`None` if it is not running) + pub started_at: Option, + /// The progress of the animationn, between 0.0 and 1.0 + pub animation_progress: f32, + /// The progress the animation has been started at + pub initial_progress: f32, + /// The type of effect for the animation + pub effect: AnimationEffect, +} + +/// The type of effect for the animation +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum AnimationEffect { + /// Transition is linear. + #[default] + Linear, + /// Transition is a cubic ease out. + EaseOut, + /// Transistion is instantaneous. + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +/// Direction of the animation +pub enum AnimationDirection { + #[default] + /// The animation goes forward + Forward, + /// The animation goes backward + Backward, +} + +impl HoverPressedAnimation { + /// Create a hover animation with the given transision effect + pub fn new(effect: AnimationEffect) -> Self { + Self { + effect, + ..Default::default() + } + } + + /// Check if the animation is running + pub fn is_running(&self) -> bool { + self.started_at.is_some() + } + + /// Reset the animation + pub fn reset(&mut self) { + self.direction = AnimationDirection::Forward; + self.started_at = None; + self.animation_progress = 0.0; + self.initial_progress = 0.0; + } + + /// Update the animation progress, if necessary, and returns the need to request a redraw. + pub fn on_redraw_request_update( + &mut self, + animation_duration_ms: u16, + now: std::time::Instant, + ) -> bool { + // Is the animation running ? + if let Some(started_at) = self.started_at { + if self.animation_progress >= 1.0 || animation_duration_ms == 0 { + self.animation_progress = 1.0; + } + + // Reset the animation once it has gone forward and now fully backward + if self.animation_progress == 0.0 + && self.direction == AnimationDirection::Backward + { + self.started_at = None; + } else { + // Evaluate new progress + match &mut self.effect { + AnimationEffect::Linear => { + let progress_since_start = + ((now - started_at).as_millis() as f64) + / (animation_duration_ms as f64); + match self.direction { + AnimationDirection::Forward => { + self.animation_progress = (self + .initial_progress + + progress_since_start as f32) + .clamp(0.0, 1.0); + } + AnimationDirection::Backward => { + self.animation_progress = (self + .initial_progress + - progress_since_start as f32) + .clamp(0.0, 1.0); + } + } + } + AnimationEffect::EaseOut => { + let progress_since_start = + ((now - started_at).as_millis() as f32) + / (animation_duration_ms as f32); + match self.direction { + AnimationDirection::Forward => { + self.animation_progress = (self + .initial_progress + + ease_out_cubic(progress_since_start)) + .clamp(0.0, 1.0); + } + AnimationDirection::Backward => { + self.animation_progress = (self + .initial_progress + - ease_out_cubic(progress_since_start)) + .clamp(0.0, 1.0); + } + } + } + AnimationEffect::None => {} + } + } + return true; + } + false + } + + /// Update the hovered state and return the need to request a redraw. + pub fn on_cursor_moved_update(&mut self, is_mouse_over: bool) -> bool { + if is_mouse_over { + // Is it already running ? + if self.started_at.is_some() { + // This is when the cursor re-enters the widget's area before the animation finishes + if self.direction == AnimationDirection::Backward { + // Change animation direction + self.direction = AnimationDirection::Forward; + // Start from where the animation was at + self.initial_progress = self.animation_progress; + self.started_at = Some(std::time::Instant::now()); + } + } else { + // Start the animation + self.direction = AnimationDirection::Forward; + self.started_at = Some(std::time::Instant::now()); + self.animation_progress = 0.0; + self.initial_progress = 0.0; + } + self.animation_progress != 1.0 + } else if self.started_at.is_some() { + // This is when the cursor leaves the widget's area + match self.direction { + AnimationDirection::Forward => { + // Change animation direction + self.direction = AnimationDirection::Backward; + // Start from where the animation was at + self.initial_progress = self.animation_progress; + self.started_at = Some(std::time::Instant::now()); + true + } + AnimationDirection::Backward => true, + } + } else { + false + } + } + + /// Start the animation when pressed. + pub fn on_press(&mut self) { + self.started_at = Some(std::time::Instant::now()); + self.direction = AnimationDirection::Forward; + self.animation_progress = 0.0; + self.initial_progress = 0.0; + } + + /// End the animation when released. + pub fn on_released(&mut self) { + self.started_at = Some(std::time::Instant::now()); + self.direction = AnimationDirection::Backward; + self.initial_progress = self.animation_progress; + } + + /// End the animation (go backgwards), skipping the forward phase. + pub fn on_activate(&mut self) { + self.started_at = Some(std::time::Instant::now()); + self.direction = AnimationDirection::Backward; + self.initial_progress = 1.0; + self.animation_progress = 1.0; + } +} + +/// Based on Robert Penner's infamous easing equations, MIT license. +fn ease_out_cubic(t: f32) -> f32 { + let p = t - 1f32; + p * p * p + 1f32 +} diff --git a/style/src/button.rs b/style/src/button.rs index a564a2b7ea..c11f51e382 100644 --- a/style/src/button.rs +++ b/style/src/button.rs @@ -40,7 +40,11 @@ pub trait StyleSheet { fn active(&self, style: &Self::Style) -> Appearance; /// Produces the hovered [`Appearance`] of a button. - fn hovered(&self, style: &Self::Style) -> Appearance { + fn hovered( + &self, + style: &Self::Style, + _hover_animation: &crate::animation::HoverPressedAnimation, + ) -> Appearance { let active = self.active(style); Appearance { @@ -50,7 +54,11 @@ pub trait StyleSheet { } /// Produces the pressed [`Appearance`] of a button. - fn pressed(&self, style: &Self::Style) -> Appearance { + fn pressed( + &self, + style: &Self::Style, + _pressed_animation: &crate::animation::HoverPressedAnimation, + ) -> Appearance { Appearance { shadow_offset: Vector::default(), ..self.active(style) diff --git a/style/src/lib.rs b/style/src/lib.rs index 286ff9db22..e18d40d4f7 100644 --- a/style/src/lib.rs +++ b/style/src/lib.rs @@ -20,6 +20,7 @@ #![allow(clippy::inherent_to_string, clippy::type_complexity)] pub use iced_core as core; +pub mod animation; pub mod application; pub mod button; pub mod checkbox; diff --git a/style/src/radio.rs b/style/src/radio.rs index 06c49029a6..f00c626b6b 100644 --- a/style/src/radio.rs +++ b/style/src/radio.rs @@ -1,6 +1,8 @@ //! Change the appearance of radio buttons. use iced_core::{Background, Color}; +use crate::animation; + /// The appearance of a radio button. #[derive(Debug, Clone, Copy)] pub struct Appearance { @@ -22,8 +24,18 @@ pub trait StyleSheet { type Style: Default; /// Produces the active [`Appearance`] of a radio button. - fn active(&self, style: &Self::Style, is_selected: bool) -> Appearance; + fn active( + &self, + style: &Self::Style, + is_selected: bool, + pressed_animation: &animation::HoverPressedAnimation, + ) -> Appearance; /// Produces the hovered [`Appearance`] of a radio button. - fn hovered(&self, style: &Self::Style, is_selected: bool) -> Appearance; + fn hovered( + &self, + style: &Self::Style, + is_selected: bool, + hover_animation: &animation::HoverPressedAnimation, + ) -> Appearance; } diff --git a/style/src/theme.rs b/style/src/theme.rs index d9893bcfa0..5f37650490 100644 --- a/style/src/theme.rs +++ b/style/src/theme.rs @@ -4,6 +4,7 @@ pub mod palette; use self::palette::Extended; pub use self::palette::Palette; +use crate::animation; use crate::application; use crate::button; use crate::checkbox; @@ -169,16 +170,20 @@ impl button::StyleSheet for Theme { } } - fn hovered(&self, style: &Self::Style) -> button::Appearance { + fn hovered( + &self, + style: &Self::Style, + hover_animation: &animation::HoverPressedAnimation, + ) -> button::Appearance { let palette = self.extended_palette(); if let Button::Custom(custom) = style { - return custom.hovered(self); + return custom.hovered(self, hover_animation); } let active = self.active(style); - let background = match style { + let mut background = match style { Button::Primary => Some(palette.primary.base.color), Button::Secondary => Some(palette.background.strong.color), Button::Positive => Some(palette.success.strong.color), @@ -186,19 +191,75 @@ impl button::StyleSheet for Theme { Button::Text | Button::Custom(_) => None, }; + // Mix the hovered and active styles backgrounds according to the animation state + if let (Some(active_background), Some(background_color)) = + (active.background, background.as_mut()) + { + match hover_animation.effect { + animation::AnimationEffect::Linear + | animation::AnimationEffect::EaseOut => { + match active_background { + Background::Color(active_background_color) => { + background_color.mix( + active_background_color, + 1.0 - hover_animation.animation_progress, + ); + } + } + } + animation::AnimationEffect::None => {} + } + } + button::Appearance { background: background.map(Background::from), ..active } } - fn pressed(&self, style: &Self::Style) -> button::Appearance { + fn pressed( + &self, + style: &Self::Style, + pressed_animation: &animation::HoverPressedAnimation, + ) -> button::Appearance { if let Button::Custom(custom) = style { - return custom.pressed(self); + return custom.pressed(self, pressed_animation); + } + let palette = self.extended_palette(); + + let active = self.active(style); + + let mut background = match style { + Button::Primary => Some(palette.primary.base.color), + Button::Secondary => Some(palette.background.strong.color), + Button::Positive => Some(palette.success.strong.color), + Button::Destructive => Some(palette.danger.strong.color), + Button::Text | Button::Custom(_) => None, + }; + + // Mix the hovered and active styles backgrounds according to the animation state + if let (Some(active_background), Some(background_color)) = + (active.background, background.as_mut()) + { + match pressed_animation.effect { + animation::AnimationEffect::Linear + | animation::AnimationEffect::EaseOut => { + match active_background { + Background::Color(active_background_color) => { + background_color.mix( + active_background_color, + pressed_animation.animation_progress, + ); + } + } + } + animation::AnimationEffect::None => {} + } } button::Appearance { shadow_offset: Vector::default(), + background: background.map(Background::from), ..self.active(style) } } @@ -585,20 +646,37 @@ impl radio::StyleSheet for Theme { &self, style: &Self::Style, is_selected: bool, + pressed_animation: &animation::HoverPressedAnimation, ) -> radio::Appearance { match style { Radio::Default => { let palette = self.extended_palette(); + let mut dot_color = palette.primary.strong.color; + + if is_selected { + match pressed_animation.effect { + animation::AnimationEffect::Linear + | animation::AnimationEffect::EaseOut => { + dot_color.mix( + Color::TRANSPARENT, + pressed_animation.animation_progress, + ); + } + animation::AnimationEffect::None => {} + } + } radio::Appearance { background: Color::TRANSPARENT.into(), - dot_color: palette.primary.strong.color, + dot_color, border_width: 1.0, border_color: palette.primary.strong.color, text_color: None, } } - Radio::Custom(custom) => custom.active(self, is_selected), + Radio::Custom(custom) => { + custom.active(self, is_selected, pressed_animation) + } } } @@ -606,19 +684,39 @@ impl radio::StyleSheet for Theme { &self, style: &Self::Style, is_selected: bool, + hover_animation: &animation::HoverPressedAnimation, ) -> radio::Appearance { match style { Radio::Default => { - let active = self.active(style, is_selected); + let active = self.active(style, is_selected, hover_animation); let palette = self.extended_palette(); + let mut background_color = palette.primary.weak.color; + + // Mix the hovered and active styles backgrounds according to the animation state + match hover_animation.effect { + animation::AnimationEffect::Linear + | animation::AnimationEffect::EaseOut => { + match active.background { + Background::Color(active_background_color) => { + background_color.mix( + active_background_color, + 1.0 - hover_animation.animation_progress, + ); + } + } + } + animation::AnimationEffect::None => {} + } radio::Appearance { dot_color: palette.primary.strong.color, - background: palette.primary.weak.color.into(), + background: background_color.into(), ..active } } - Radio::Custom(custom) => custom.hovered(self, is_selected), + Radio::Custom(custom) => { + custom.hovered(self, is_selected, hover_animation) + } } } } @@ -640,6 +738,7 @@ impl toggler::StyleSheet for Theme { &self, style: &Self::Style, is_active: bool, + pressed_animation: &crate::animation::HoverPressedAnimation, ) -> toggler::Appearance { match style { Toggler::Default => { @@ -647,9 +746,19 @@ impl toggler::StyleSheet for Theme { toggler::Appearance { background: if is_active { - palette.primary.strong.color + let mut color = palette.background.strong.color; + color.mix( + palette.primary.strong.color, + pressed_animation.animation_progress, + ); + color } else { - palette.background.strong.color + let mut color = palette.primary.strong.color; + color.mix( + palette.background.strong.color, + 1.0 - pressed_animation.animation_progress, + ); + color }, background_border: None, foreground: if is_active { @@ -660,7 +769,9 @@ impl toggler::StyleSheet for Theme { foreground_border: None, } } - Toggler::Custom(custom) => custom.active(self, is_active), + Toggler::Custom(custom) => { + custom.active(self, is_active, pressed_animation) + } } } @@ -668,6 +779,7 @@ impl toggler::StyleSheet for Theme { &self, style: &Self::Style, is_active: bool, + pressed_animation: &crate::animation::HoverPressedAnimation, ) -> toggler::Appearance { match style { Toggler::Default => { @@ -682,10 +794,12 @@ impl toggler::StyleSheet for Theme { } else { palette.background.weak.color }, - ..self.active(style, is_active) + ..self.active(style, is_active, pressed_animation) } } - Toggler::Custom(custom) => custom.hovered(self, is_active), + Toggler::Custom(custom) => { + custom.hovered(self, is_active, pressed_animation) + } } } } diff --git a/style/src/toggler.rs b/style/src/toggler.rs index abc73f2a25..8a34189d03 100644 --- a/style/src/toggler.rs +++ b/style/src/toggler.rs @@ -22,10 +22,20 @@ pub trait StyleSheet { /// Returns the active [`Appearance`] of the toggler for the provided [`Style`]. /// /// [`Style`]: Self::Style - fn active(&self, style: &Self::Style, is_active: bool) -> Appearance; + fn active( + &self, + style: &Self::Style, + is_active: bool, + _pressed_animation: &crate::animation::HoverPressedAnimation, + ) -> Appearance; /// Returns the hovered [`Appearance`] of the toggler for the provided [`Style`]. /// /// [`Style`]: Self::Style - fn hovered(&self, style: &Self::Style, is_active: bool) -> Appearance; + fn hovered( + &self, + style: &Self::Style, + is_active: bool, + _pressed_animation: &crate::animation::HoverPressedAnimation, + ) -> Appearance; } diff --git a/widget/src/button.rs b/widget/src/button.rs index 7eee69cbae..ca029bab8f 100644 --- a/widget/src/button.rs +++ b/widget/src/button.rs @@ -1,6 +1,7 @@ //! Allow your users to perform actions by pressing a button. //! //! A [`Button`] has some local [`State`]. + use crate::core::event::{self, Event}; use crate::core::layout; use crate::core::mouse; @@ -9,11 +10,13 @@ use crate::core::renderer; use crate::core::touch; use crate::core::widget::tree::{self, Tree}; use crate::core::widget::Operation; +use crate::core::window; use crate::core::{ Background, Clipboard, Color, Element, Layout, Length, Padding, Point, Rectangle, Shell, Vector, Widget, }; +use iced_style::animation::{AnimationEffect, HoverPressedAnimation}; pub use iced_style::button::{Appearance, StyleSheet}; /// A generic widget that produces a message when pressed. @@ -62,6 +65,9 @@ where height: Length, padding: Padding, style: ::Style, + animation_duration_ms: u16, + hover_animation_effect: AnimationEffect, + pressed_animation_effect: AnimationEffect, } impl<'a, Message, Renderer> Button<'a, Message, Renderer> @@ -78,6 +84,9 @@ where height: Length::Shrink, padding: Padding::new(5.0), style: ::Style::default(), + animation_duration_ms: 250, + hover_animation_effect: AnimationEffect::EaseOut, + pressed_animation_effect: AnimationEffect::EaseOut, } } @@ -115,6 +124,30 @@ where self.style = style; self } + + /// Sets the animation duration (in milliseconds) of the [`Button`]. + pub fn animation_duration(mut self, animation_duration_ms: u16) -> Self { + self.animation_duration_ms = animation_duration_ms; + self + } + + /// Sets the animation effect when hovering the [`Button`]. + pub fn hover_animation_effect( + mut self, + hover_animation_effect: AnimationEffect, + ) -> Self { + self.hover_animation_effect = hover_animation_effect; + self + } + + /// Sets the animation effect when pressing the [`Button`]. + pub fn pressed_animation_effect( + mut self, + pressed_animation_effect: AnimationEffect, + ) -> Self { + self.pressed_animation_effect = pressed_animation_effect; + self + } } impl<'a, Message, Renderer> Widget @@ -129,7 +162,10 @@ where } fn state(&self) -> tree::State { - tree::State::new(State::new()) + tree::State::new(State::new( + HoverPressedAnimation::new(self.hover_animation_effect), + HoverPressedAnimation::new(self.hover_animation_effect), + )) } fn children(&self) -> Vec { @@ -211,6 +247,7 @@ where shell, &self.on_press, || tree.state.downcast_mut::(), + self.animation_duration_ms, ) } @@ -288,15 +325,24 @@ where } /// The local state of a [`Button`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct State { is_pressed: bool, + hovered_animation: HoverPressedAnimation, + pressed_animation: HoverPressedAnimation, } impl State { /// Creates a new [`State`]. - pub fn new() -> State { - State::default() + pub fn new( + hovered_animation: HoverPressedAnimation, + pressed_animation: HoverPressedAnimation, + ) -> State { + State { + is_pressed: false, + hovered_animation, + pressed_animation, + } } } @@ -309,6 +355,7 @@ pub fn update<'a, Message: Clone>( shell: &mut Shell<'_, Message>, on_press: &Option, state: impl FnOnce() -> &'a mut State, + animation_duration_ms: u16, ) -> event::Status { match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) @@ -320,6 +367,8 @@ pub fn update<'a, Message: Clone>( let state = state(); state.is_pressed = true; + state.pressed_animation.on_press(); + shell.request_redraw(window::RedrawRequest::NextFrame); return event::Status::Captured; } @@ -332,6 +381,8 @@ pub fn update<'a, Message: Clone>( if state.is_pressed { state.is_pressed = false; + state.pressed_animation.on_released(); + shell.request_redraw(window::RedrawRequest::NextFrame); let bounds = layout.bounds(); @@ -348,6 +399,36 @@ pub fn update<'a, Message: Clone>( state.is_pressed = false; } + Event::Window(window::Event::RedrawRequested(now)) => { + let state = state(); + + if state.is_pressed || state.pressed_animation.is_running() { + if state + .pressed_animation + .on_redraw_request_update(animation_duration_ms, now) + { + shell.request_redraw(window::RedrawRequest::NextFrame); + } + } else if state + .hovered_animation + .on_redraw_request_update(animation_duration_ms, now) + { + shell.request_redraw(window::RedrawRequest::NextFrame); + } + } + Event::Mouse(mouse::Event::CursorMoved { position: _ }) => { + let state = state(); + let bounds = layout.bounds(); + let is_mouse_over = bounds.contains(cursor_position); + + if !state.is_pressed + && state + .hovered_animation + .on_cursor_moved_update(is_mouse_over) + { + shell.request_redraw(window::RedrawRequest::NextFrame); + } + } _ => {} } @@ -369,17 +450,16 @@ pub fn draw<'a, Renderer: crate::core::Renderer>( where Renderer::Theme: StyleSheet, { + let state = state(); let is_mouse_over = bounds.contains(cursor_position); let styling = if !is_enabled { style_sheet.disabled(style) - } else if is_mouse_over { - let state = state(); - - if state.is_pressed { - style_sheet.pressed(style) + } else if is_mouse_over || state.hovered_animation.is_running() { + if state.is_pressed || state.pressed_animation.is_running() { + style_sheet.pressed(style, &state.pressed_animation) } else { - style_sheet.hovered(style) + style_sheet.hovered(style, &state.hovered_animation) } } else { style_sheet.active(style) diff --git a/widget/src/radio.rs b/widget/src/radio.rs index 9dad1e22a2..95330427e5 100644 --- a/widget/src/radio.rs +++ b/widget/src/radio.rs @@ -6,13 +6,15 @@ use crate::core::mouse; use crate::core::renderer; use crate::core::text; use crate::core::touch; -use crate::core::widget::Tree; +use crate::core::widget::{self, Tree}; +use crate::core::window; use crate::core::{ Alignment, Clipboard, Color, Element, Layout, Length, Pixels, Point, Rectangle, Shell, Widget, }; use crate::{Row, Text}; +use iced_style::animation::{AnimationEffect, HoverPressedAnimation}; pub use iced_style::radio::{Appearance, StyleSheet}; /// A circular button representing a choice. @@ -85,6 +87,8 @@ where text_shaping: text::Shaping, font: Option, style: ::Style, + animation_duration_ms: u16, + hover_animation_effect: AnimationEffect, } impl Radio @@ -129,6 +133,8 @@ where text_shaping: text::Shaping::Basic, font: None, style: Default::default(), + animation_duration_ms: 250, + hover_animation_effect: AnimationEffect::EaseOut, } } @@ -201,6 +207,13 @@ where Length::Shrink } + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State::new( + HoverPressedAnimation::new(self.hover_animation_effect), + HoverPressedAnimation::new(self.hover_animation_effect), + )) + } + fn layout( &self, renderer: &Renderer, @@ -226,7 +239,7 @@ where fn on_event( &mut self, - _state: &mut Tree, + tree: &mut Tree, event: Event, layout: Layout<'_>, cursor_position: Point, @@ -238,11 +251,47 @@ where Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { if layout.bounds().contains(cursor_position) { + let state = tree.state.downcast_mut::(); + + state.hovered_animation.reset(); + state.pressed_animation.on_activate(); + shell.request_redraw(window::RedrawRequest::NextFrame); + shell.publish(self.on_click.clone()); return event::Status::Captured; } } + Event::Window(window::Event::RedrawRequested(now)) => { + let state = tree.state.downcast_mut::(); + + if self.is_selected && state.pressed_animation.is_running() { + if state.pressed_animation.on_redraw_request_update( + self.animation_duration_ms, + now, + ) { + shell.request_redraw(window::RedrawRequest::NextFrame); + } + } else if state + .hovered_animation + .on_redraw_request_update(self.animation_duration_ms, now) + { + shell.request_redraw(window::RedrawRequest::NextFrame); + } + } + Event::Mouse(mouse::Event::CursorMoved { position }) => { + let state = tree.state.downcast_mut::(); + let bounds = layout.bounds(); + let is_mouse_over = bounds.contains(position); + + if !self.is_selected + && state + .hovered_animation + .on_cursor_moved_update(is_mouse_over) + { + shell.request_redraw(window::RedrawRequest::NextFrame); + } + } _ => {} } @@ -266,7 +315,7 @@ where fn draw( &self, - _state: &Tree, + tree: &Tree, renderer: &mut Renderer, theme: &Renderer::Theme, style: &renderer::Style, @@ -274,16 +323,37 @@ where cursor_position: Point, _viewport: &Rectangle, ) { + let state = tree.state.downcast_ref::(); + let style_sheet: &dyn StyleSheet< + Style = ::Style, + > = theme; let bounds = layout.bounds(); let is_mouse_over = bounds.contains(cursor_position); let mut children = layout.children(); - let custom_style = if is_mouse_over { - theme.hovered(&self.style, self.is_selected) - } else { - theme.active(&self.style, self.is_selected) - }; + let custom_style = + if is_mouse_over || state.hovered_animation.is_running() { + if self.is_selected || state.pressed_animation.is_running() { + style_sheet.active( + &self.style, + self.is_selected, + &state.pressed_animation, + ) + } else { + style_sheet.hovered( + &self.style, + self.is_selected, + &state.hovered_animation, + ) + } + } else { + theme.active( + &self.style, + self.is_selected, + &state.pressed_animation, + ) + }; { let layout = children.next().unwrap(); @@ -342,6 +412,26 @@ where } } +/// The local state of a [`Radio`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct State { + hovered_animation: HoverPressedAnimation, + pressed_animation: HoverPressedAnimation, +} + +impl State { + /// Creates a new [`State`]. + pub fn new( + hovered_animation: HoverPressedAnimation, + pressed_animation: HoverPressedAnimation, + ) -> State { + State { + hovered_animation, + pressed_animation, + } + } +} + impl<'a, Message, Renderer> From> for Element<'a, Message, Renderer> where diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index bbc07dac07..65b16ec364 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -7,6 +7,7 @@ mod value; pub mod cursor; pub use cursor::Cursor; +use iced_style::animation::AnimationDirection; pub use value::Value; use editor::Editor; @@ -32,6 +33,8 @@ use crate::runtime::Command; pub use iced_style::text_input::{Appearance, StyleSheet}; +use self::cursor::CursorAnimation; + /// A field that can be filled with text. /// /// # Example @@ -74,6 +77,7 @@ where on_submit: Option, icon: Option>, style: ::Style, + cursor_animation_style: CursorAnimation, } impl<'a, Message, Renderer> TextInput<'a, Message, Renderer> @@ -103,6 +107,7 @@ where on_submit: None, icon: None, style: Default::default(), + cursor_animation_style: Default::default(), } } @@ -197,6 +202,15 @@ where self } + /// Sets the animation style of the [`Cursor`]. + pub fn set_cursor_animation_style( + mut self, + cursor_animation_style: CursorAnimation, + ) -> Self { + self.cursor_animation_style = cursor_animation_style; + self + } + /// Draws the [`TextInput`] with the given [`Renderer`], overriding its /// [`Value`] if provided. /// @@ -225,6 +239,7 @@ where self.is_secure, self.icon.as_ref(), &self.style, + self.cursor_animation_style, ) } } @@ -319,6 +334,7 @@ where self.on_paste.as_deref(), &self.on_submit, || tree.state.downcast_mut::(), + self.cursor_animation_style, ) } @@ -347,6 +363,7 @@ where self.is_secure, self.icon.as_ref(), &self.style, + self.cursor_animation_style, ) } @@ -541,6 +558,7 @@ pub fn update<'a, Message, Renderer>( on_paste: Option<&dyn Fn(String) -> Message>, on_submit: &Option, state: impl FnOnce() -> &'a mut State, + cursor_animation_style: CursorAnimation, ) -> event::Status where Message: Clone, @@ -699,6 +717,8 @@ where focus.updated_at = Instant::now(); + reset_cursor(cursor_animation_style, state); + return event::Status::Captured; } } @@ -712,6 +732,8 @@ where let modifiers = state.keyboard_modifiers; focus.updated_at = Instant::now(); + reset_cursor(cursor_animation_style, state); + match key_code { keyboard::KeyCode::Enter | keyboard::KeyCode::NumpadEnter => { @@ -921,13 +943,73 @@ where if let Some(focus) = &mut state.is_focused { focus.now = now; - let millis_until_redraw = CURSOR_BLINK_INTERVAL_MILLIS - - (now - focus.updated_at).as_millis() - % CURSOR_BLINK_INTERVAL_MILLIS; - - shell.request_redraw(window::RedrawRequest::At( - now + Duration::from_millis(millis_until_redraw as u64), - )); + match cursor_animation_style { + CursorAnimation::Blink => { + let millis_until_redraw = CURSOR_BLINK_INTERVAL_MILLIS + - (now - focus.updated_at).as_millis() + % CURSOR_BLINK_INTERVAL_MILLIS; + + shell.request_redraw(window::RedrawRequest::At( + now + Duration::from_millis( + millis_until_redraw as u64, + ), + )); + } + CursorAnimation::Fade => { + if let Some(last_direction_change) = + &mut state.cursor_animation_last_direction_change + { + match state.cursor_animation_direction { + AnimationDirection::Forward => { + let interval_ms = (now + - *last_direction_change) + .as_millis() + as f32; + let opacity_diff = interval_ms + / (CURSOR_FADE_INTERVAL_MILLIS as f32); + state.cursor_opacity = opacity_diff; + if state.cursor_opacity >= 1.0 { + state.cursor_animation_direction = + AnimationDirection::Backward; + state.cursor_opacity = 1.0; + *last_direction_change = now; + } + } + AnimationDirection::Backward => { + let interval_ms = (now + - *last_direction_change) + .as_millis() + as f32; + + // Make a pause at full opacity + if interval_ms + > CURSOR_FADE_INTERVAL_MILLIS_PAUSE + { + let opacity_diff = (interval_ms + - CURSOR_FADE_INTERVAL_MILLIS_PAUSE) + / (CURSOR_FADE_INTERVAL_MILLIS + as f32); + state.cursor_opacity = + 1.0 - opacity_diff; + } + if state.cursor_opacity <= 0.0 { + state.cursor_animation_direction = + AnimationDirection::Forward; + state.cursor_opacity = 0.0; + *last_direction_change = now; + } + } + } + } else { + state.cursor_animation_last_direction_change = + Some(now); + state.cursor_opacity = 1.0; + state.cursor_animation_direction = + AnimationDirection::Backward; + } + shell.request_redraw(window::RedrawRequest::NextFrame); + } + } } } _ => {} @@ -936,6 +1018,14 @@ where event::Status::Ignored } +fn reset_cursor(cursor_animation_style: CursorAnimation, state: &mut State) { + if let CursorAnimation::Fade = cursor_animation_style { + state.cursor_animation_direction = AnimationDirection::Backward; + state.cursor_opacity = 1.0; + state.cursor_animation_last_direction_change = None; + } +} + /// Draws the [`TextInput`] with the given [`Renderer`], overriding its /// [`Value`] if provided. /// @@ -955,6 +1045,7 @@ pub fn draw( is_secure: bool, icon: Option<&Icon>, style: &::Style, + cursor_animation_style: CursorAnimation, ) where Renderer: text::Renderer, Renderer::Theme: StyleSheet, @@ -1025,29 +1116,50 @@ pub fn draw( font, ); - let is_cursor_visible = ((focus.now - focus.updated_at) - .as_millis() - / CURSOR_BLINK_INTERVAL_MILLIS) - % 2 - == 0; - - let cursor = if is_cursor_visible { - Some(( - renderer::Quad { - bounds: Rectangle { - x: text_bounds.x + text_value_width, - y: text_bounds.y, - width: 1.0, - height: text_bounds.height, + let cursor = match cursor_animation_style { + CursorAnimation::Blink => { + // Check if cursor is visible + if ((focus.now - focus.updated_at).as_millis() + / CURSOR_BLINK_INTERVAL_MILLIS) + % 2 + == 0 + { + Some(( + renderer::Quad { + bounds: Rectangle { + x: text_bounds.x + text_value_width, + y: text_bounds.y, + width: 1.0, + height: text_bounds.height, + }, + border_radius: 0.0.into(), + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + theme.value_color(style), + )) + } else { + None + } + } + CursorAnimation::Fade => { + let mut color = theme.value_color(style); + color.a = state.cursor_opacity; + Some(( + renderer::Quad { + bounds: Rectangle { + x: text_bounds.x + text_value_width, + y: text_bounds.y, + width: 1.0, + height: text_bounds.height, + }, + border_radius: 0.0.into(), + border_width: 0.0, + border_color: Color::TRANSPARENT, }, - border_radius: 0.0.into(), - border_width: 0.0, - border_color: Color::TRANSPARENT, - }, - theme.value_color(style), - )) - } else { - None + color, + )) + } }; (cursor, offset) @@ -1177,6 +1289,9 @@ pub struct State { last_click: Option, cursor: Cursor, keyboard_modifiers: keyboard::Modifiers, + cursor_opacity: f32, + cursor_animation_direction: AnimationDirection, + cursor_animation_last_direction_change: Option, // TODO: Add stateful horizontal scrolling offset } @@ -1201,6 +1316,9 @@ impl State { last_click: None, cursor: Cursor::default(), keyboard_modifiers: keyboard::Modifiers::default(), + cursor_opacity: 1.0, + cursor_animation_direction: AnimationDirection::Backward, + cursor_animation_last_direction_change: None, } } @@ -1399,3 +1517,5 @@ where } const CURSOR_BLINK_INTERVAL_MILLIS: u128 = 500; +const CURSOR_FADE_INTERVAL_MILLIS: u128 = 600; +const CURSOR_FADE_INTERVAL_MILLIS_PAUSE: f32 = 400.0; diff --git a/widget/src/text_input/cursor.rs b/widget/src/text_input/cursor.rs index 9680dfd746..782f94bd8d 100644 --- a/widget/src/text_input/cursor.rs +++ b/widget/src/text_input/cursor.rs @@ -7,6 +7,16 @@ pub struct Cursor { state: State, } +/// The animation styles for the [`Cursor`]. +#[derive(Debug, Clone, Copy, Default)] +pub enum CursorAnimation { + /// Blink the cursor (visible then not visible in a loop) + Blink, + /// Fade from visible to invisible in a loop + #[default] + Fade, +} + /// The state of a [`Cursor`]. #[derive(Debug, Copy, Clone)] pub enum State { diff --git a/widget/src/toggler.rs b/widget/src/toggler.rs index b1ba65c95b..b463351608 100644 --- a/widget/src/toggler.rs +++ b/widget/src/toggler.rs @@ -5,13 +5,16 @@ use crate::core::layout; use crate::core::mouse; use crate::core::renderer; use crate::core::text; -use crate::core::widget::Tree; +use crate::core::widget::{self, Tree}; +use crate::core::window; use crate::core::{ Alignment, Clipboard, Element, Event, Layout, Length, Pixels, Point, Rectangle, Shell, Widget, }; use crate::{Row, Text}; +use crate::style::animation::AnimationEffect; +use crate::style::animation::HoverPressedAnimation; pub use crate::style::toggler::{Appearance, StyleSheet}; /// A toggler widget. @@ -48,6 +51,8 @@ where spacing: f32, font: Option, style: ::Style, + animation_duration: u16, + pressed_animation_effect: AnimationEffect, } impl<'a, Message, Renderer> Toggler<'a, Message, Renderer> @@ -87,6 +92,8 @@ where spacing: 0.0, font: None, style: Default::default(), + animation_duration: 250, + pressed_animation_effect: AnimationEffect::EaseOut, } } @@ -167,6 +174,19 @@ where Length::Shrink } + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + + fn state(&self) -> widget::tree::State { + let mut pressed_animation = + HoverPressedAnimation::new(self.pressed_animation_effect); + if self.is_toggled { + pressed_animation.animation_progress = 1.0; + } + widget::tree::State::new(State::new(pressed_animation)) + } + fn layout( &self, renderer: &Renderer, @@ -199,7 +219,7 @@ where fn on_event( &mut self, - _state: &mut Tree, + tree: &mut Tree, event: Event, layout: Layout<'_>, cursor_position: Point, @@ -212,6 +232,14 @@ where let mouse_over = layout.bounds().contains(cursor_position); if mouse_over { + let state = tree.state.downcast_mut::(); + + if !self.is_toggled { + state.pressed_animation.on_press(); + } else { + state.pressed_animation.on_released(); + } + shell.request_redraw(window::RedrawRequest::NextFrame); shell.publish((self.on_toggle)(!self.is_toggled)); event::Status::Captured @@ -219,6 +247,18 @@ where event::Status::Ignored } } + Event::Window(window::Event::RedrawRequested(now)) => { + let state = tree.state.downcast_mut::(); + + if state.pressed_animation.is_running() + && state + .pressed_animation + .on_redraw_request_update(self.animation_duration, now) + { + shell.request_redraw(window::RedrawRequest::NextFrame); + } + event::Status::Captured + } _ => event::Status::Ignored, } } @@ -240,7 +280,7 @@ where fn draw( &self, - _state: &Tree, + tree: &Tree, renderer: &mut Renderer, theme: &Renderer::Theme, style: &renderer::Style, @@ -248,6 +288,8 @@ where cursor_position: Point, _viewport: &Rectangle, ) { + let state = tree.state.downcast_ref::(); + /// Makes sure that the border radius of the toggler looks good at every size. const BORDER_RADIUS_RATIO: f32 = 32.0 / 13.0; @@ -281,9 +323,13 @@ where let is_mouse_over = bounds.contains(cursor_position); let style = if is_mouse_over { - theme.hovered(&self.style, self.is_toggled) + theme.hovered( + &self.style, + self.is_toggled, + &state.pressed_animation, + ) } else { - theme.active(&self.style, self.is_toggled) + theme.active(&self.style, self.is_toggled, &state.pressed_animation) }; let border_radius = bounds.height / BORDER_RADIUS_RATIO; @@ -310,11 +356,9 @@ where 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 + + (state.pressed_animation.animation_progress + * (bounds.width - bounds.height))), y: bounds.y + (2.0 * space), width: bounds.height - (4.0 * space), height: bounds.height - (4.0 * space), @@ -347,3 +391,16 @@ where Element::new(toggler) } } + +/// The local state of a [`Toggler`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct State { + pressed_animation: HoverPressedAnimation, +} + +impl State { + /// Creates a new [`State`]. + pub fn new(pressed_animation: HoverPressedAnimation) -> State { + State { pressed_animation } + } +}