diff --git a/core/Cargo.toml b/core/Cargo.toml index 6bb646c6cc..eb150fa4f0 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -32,7 +32,7 @@ web-time.workspace = true dark-light.workspace = true dark-light.optional = true -lilt = "0.5.0" +lilt = "0.6.0" [dev-dependencies] approx = "0.5" diff --git a/widget/Cargo.toml b/widget/Cargo.toml index 15f4d2f6a9..013c9d14b9 100644 --- a/widget/Cargo.toml +++ b/widget/Cargo.toml @@ -41,4 +41,4 @@ ouroboros.optional = true qrcode.workspace = true qrcode.optional = true -lilt = "0.5.0" +lilt = "0.6.0" diff --git a/widget/src/button.rs b/widget/src/button.rs index bed3ae5d95..932703df16 100644 --- a/widget/src/button.rs +++ b/widget/src/button.rs @@ -611,7 +611,7 @@ pub enum Status { /// The [`AnimationTarget`] represents, through its ['FloatRepresentable`] /// implementation the ratio of color mixing between the base and hover colors. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy, PartialEq)] enum AnimationTarget { Active, Hovered, diff --git a/widget/src/scrollable.rs b/widget/src/scrollable.rs index cadba3ce2d..2ec7dba320 100644 --- a/widget/src/scrollable.rs +++ b/widget/src/scrollable.rs @@ -11,14 +11,19 @@ use crate::core::touch; use crate::core::widget; use crate::core::widget::operation::{self, Operation}; use crate::core::widget::tree::{self, Tree}; +use crate::core::window; use crate::core::{ self, Background, Clipboard, Color, Element, Layout, Length, Padding, Pixels, Point, Rectangle, Shell, Size, Theme, Vector, Widget, }; -use crate::runtime::task::{self, Task}; -use crate::runtime::Action; - +use crate::runtime::{ + task::{self, Task}, + Action, +}; +use lilt::Animated; +use lilt::Easing::EaseOut; pub use operation::scrollable::{AbsoluteOffset, RelativeOffset}; +use std::time::Instant; /// A widget that can vertically display an infinite amount of content with a /// scrollbar. @@ -39,6 +44,7 @@ pub struct Scrollable< content: Element<'a, Message, Theme, Renderer>, on_scroll: Option Message + 'a>>, class: Theme::Class<'a>, + animation_duration_ms: f32, } impl<'a, Message, Theme, Renderer> Scrollable<'a, Message, Theme, Renderer> @@ -58,6 +64,7 @@ where content: content.into(), on_scroll: None, class: Theme::default(), + animation_duration_ms: 200., } .validate() } @@ -338,7 +345,7 @@ where } fn state(&self) -> tree::State { - tree::State::new(State::new()) + tree::State::new(State::new(self.animation_duration_ms)) } fn children(&self) -> Vec { @@ -479,10 +486,14 @@ where return event::Status::Ignored; }; - state.scroll_y_to(scrollbar.scroll_percentage_y( - scroller_grabbed_at, - cursor_position, - )); + state.scroll_y_to( + scrollbar.scroll_percentage_y( + scroller_grabbed_at, + cursor_position, + ), + true, + ); + shell.request_redraw(window::RedrawRequest::NextFrame); let _ = notify_on_scroll( state, @@ -511,10 +522,14 @@ where scrollbars.grab_y_scroller(cursor_position), scrollbars.y, ) { - state.scroll_y_to(scrollbar.scroll_percentage_y( - scroller_grabbed_at, - cursor_position, - )); + state.scroll_y_to( + scrollbar.scroll_percentage_y( + scroller_grabbed_at, + cursor_position, + ), + false, + ); + shell.request_redraw(window::RedrawRequest::NextFrame); state.y_scroller_grabbed_at = Some(scroller_grabbed_at); @@ -542,10 +557,14 @@ where }; if let Some(scrollbar) = scrollbars.x { - state.scroll_x_to(scrollbar.scroll_percentage_x( - scroller_grabbed_at, - cursor_position, - )); + state.scroll_x_to( + scrollbar.scroll_percentage_x( + scroller_grabbed_at, + cursor_position, + ), + true, + ); + shell.request_redraw(window::RedrawRequest::NextFrame); let _ = notify_on_scroll( state, @@ -574,10 +593,14 @@ where scrollbars.grab_x_scroller(cursor_position), scrollbars.x, ) { - state.scroll_x_to(scrollbar.scroll_percentage_x( - scroller_grabbed_at, - cursor_position, - )); + state.scroll_x_to( + scrollbar.scroll_percentage_x( + scroller_grabbed_at, + cursor_position, + ), + false, + ); + shell.request_redraw(window::RedrawRequest::NextFrame); state.x_scroller_grabbed_at = Some(scroller_grabbed_at); @@ -644,6 +667,19 @@ where state.x_scroller_grabbed_at = None; state.y_scroller_grabbed_at = None; + // Reset animations durations from instantaneous to default. + // This is necessary because we change the animation duration when + // grabbing the scrollbars, and are unable to access the animation + // duration in all methods, such as `scroll_to` and `snap_to`. + state.y_animation = state + .y_animation + .clone() + .duration(self.animation_duration_ms); + state.x_animation = state + .x_animation + .clone() + .duration(self.animation_duration_ms); + return event_status; } @@ -682,6 +718,7 @@ where }; state.scroll(delta, self.direction, bounds, content_bounds); + shell.request_redraw(window::RedrawRequest::NextFrame); event_status = if notify_on_scroll( state, @@ -727,6 +764,9 @@ where bounds, content_bounds, ); + shell.request_redraw( + window::RedrawRequest::NextFrame, + ); state.scroll_area_touched_at = Some(cursor_position); @@ -746,6 +786,13 @@ where event_status = event::Status::Captured; } + Event::Window(window::Event::RedrawRequested(now)) => { + if state.x_animation.in_progress(now) + || state.y_animation.in_progress(now) + { + shell.request_redraw(window::RedrawRequest::NextFrame); + } + } _ => {} } @@ -1122,7 +1169,7 @@ fn notify_on_scroll( true } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] struct State { scroll_area_touched_at: Option, offset_y_relative: f32, @@ -1131,20 +1178,8 @@ struct State { x_scroller_grabbed_at: Option, keyboard_modifiers: keyboard::Modifiers, last_notified: Option, -} - -impl Default for State { - fn default() -> Self { - Self { - scroll_area_touched_at: None, - offset_y_relative: 0.0, - y_scroller_grabbed_at: None, - offset_x_relative: 0.0, - x_scroller_grabbed_at: None, - keyboard_modifiers: keyboard::Modifiers::default(), - last_notified: None, - } - } + y_animation: Animated, + x_animation: Animated, } impl operation::Scrollable for State { @@ -1261,8 +1296,22 @@ impl Viewport { impl State { /// Creates a new [`State`] with the scrollbar(s) at the beginning. - pub fn new() -> Self { - State::default() + pub fn new(animation_duration_ms: f32) -> Self { + Self { + scroll_area_touched_at: None, + offset_y_relative: 0.0, + y_scroller_grabbed_at: None, + offset_x_relative: 0.0, + x_scroller_grabbed_at: None, + keyboard_modifiers: keyboard::Modifiers::default(), + last_notified: None, + y_animation: Animated::new(0.0) + .easing(EaseOut) + .duration(animation_duration_ms), + x_animation: Animated::new(0.0) + .easing(EaseOut) + .duration(animation_duration_ms), + } } /// Apply a scrolling offset to the current [`State`], given the bounds of @@ -1294,6 +1343,7 @@ impl State { align(vertical_alignment, delta.y), ); + let now = Instant::now(); if bounds.height < content_bounds.height { self.offset_y_relative = ((Offset::Relative(self.offset_y_relative) @@ -1301,6 +1351,7 @@ impl State { - delta.y) .clamp(0.0, content_bounds.height - bounds.height)) / (content_bounds.height - bounds.height); + self.y_animation.transition(self.offset_y_relative, now); } if bounds.width < content_bounds.width { @@ -1310,6 +1361,7 @@ impl State { - delta.x) .clamp(0.0, content_bounds.width - bounds.width)) / (content_bounds.width - bounds.width); + self.x_animation.transition(self.offset_x_relative, now); } } @@ -1317,22 +1369,43 @@ impl State { /// /// `0` represents scrollbar at the beginning, while `1` represents scrollbar at /// the end. - pub fn scroll_y_to(&mut self, percentage: f32) { - self.offset_y_relative = percentage.clamp(0.0, 1.0); + /// + /// When `instantaneous` is set to `true`, the transition uses no animation. + pub fn scroll_y_to(&mut self, percentage: f32, instantaneous: bool) { + let percentage = percentage.clamp(0.0, 1.0); + self.offset_y_relative = percentage; + if instantaneous { + self.y_animation + .transition_instantaneous(percentage, Instant::now()); + } else { + self.y_animation.transition(percentage, Instant::now()); + } } /// Scrolls the [`Scrollable`] to a relative amount along the x axis. /// /// `0` represents scrollbar at the beginning, while `1` represents scrollbar at /// the end. - pub fn scroll_x_to(&mut self, percentage: f32) { - self.offset_x_relative = percentage.clamp(0.0, 1.0); + /// + /// When `instantaneous` is set to `true`, the transition uses no animation. + pub fn scroll_x_to(&mut self, percentage: f32, instantaneous: bool) { + let percentage = percentage.clamp(0.0, 1.0); + self.offset_x_relative = percentage; + if instantaneous { + self.x_animation + .transition_instantaneous(percentage, Instant::now()); + } else { + self.x_animation.transition(percentage, Instant::now()); + } } /// Snaps the scroll position to a [`RelativeOffset`]. pub fn snap_to(&mut self, offset: RelativeOffset) { + let now = Instant::now(); self.offset_x_relative = offset.x.clamp(0.0, 1.0); self.offset_y_relative = offset.y.clamp(0.0, 1.0); + self.x_animation.transition(self.offset_x_relative, now); + self.y_animation.transition(self.offset_y_relative, now); } /// Scroll to the provided [`AbsoluteOffset`]. @@ -1342,10 +1415,15 @@ impl State { bounds: Rectangle, content_bounds: Rectangle, ) { + let now = Instant::now(); self.offset_x_relative = Offset::Absolute(offset.x.max(0.0)) - .relative(bounds.width, content_bounds.width); + .relative(bounds.width, content_bounds.width) + .clamp(0.0, 1.0); self.offset_y_relative = Offset::Absolute(offset.y.max(0.0)) - .relative(bounds.height, content_bounds.height); + .relative(bounds.height, content_bounds.height) + .clamp(0.0, 1.0); + self.x_animation.transition(self.offset_x_relative, now); + self.y_animation.transition(self.offset_y_relative, now); } /// Returns the scrolling translation of the [`State`], given a [`Direction`], @@ -1358,7 +1436,10 @@ impl State { ) -> Vector { Vector::new( if let Some(horizontal) = direction.horizontal() { - Offset::Relative(self.offset_x_relative).translation( + Offset::Relative( + self.x_animation.animate(|target| target, Instant::now()), + ) + .translation( bounds.width, content_bounds.width, horizontal.alignment, @@ -1367,7 +1448,10 @@ impl State { 0.0 }, if let Some(vertical) = direction.vertical() { - Offset::Relative(self.offset_y_relative).translation( + Offset::Relative( + self.y_animation.animate(|target| target, Instant::now()), + ) + .translation( bounds.height, content_bounds.height, vertical.alignment, diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index fa4bdb84c6..7eae057889 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -98,7 +98,7 @@ pub enum CursorAnimationType { /// The [`AnimationTarget`] represents, through its ['FloatRepresentable`] /// implementation the ratio of opacity of the cursor during it's blink effect. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy, PartialEq)] enum AnimationTarget { Shown, Hidden,