Skip to content

Commit

Permalink
feat: smooth scrolling
Browse files Browse the repository at this point in the history
  • Loading branch information
lazytanuki committed Jul 12, 2024
1 parent 94ecfea commit 180d37e
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 49 deletions.
2 changes: 1 addition & 1 deletion core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion widget/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,4 @@ ouroboros.optional = true

qrcode.workspace = true
qrcode.optional = true
lilt = "0.5.0"
lilt = "0.6.0"
2 changes: 1 addition & 1 deletion widget/src/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,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,
Expand Down
174 changes: 129 additions & 45 deletions widget/src/scrollable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,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, Border, 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.
Expand All @@ -38,6 +43,7 @@ pub struct Scrollable<
content: Element<'a, Message, Theme, Renderer>,
on_scroll: Option<Box<dyn Fn(Viewport) -> Message + 'a>>,
class: Theme::Class<'a>,
animation_duration_ms: f32,
}

impl<'a, Message, Theme, Renderer> Scrollable<'a, Message, Theme, Renderer>
Expand All @@ -57,6 +63,7 @@ where
content: content.into(),
on_scroll: None,
class: Theme::default(),
animation_duration_ms: 200.,
}
.validate()
}
Expand Down Expand Up @@ -305,7 +312,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<Tree> {
Expand Down Expand Up @@ -444,10 +451,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,
Expand Down Expand Up @@ -476,10 +487,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);

Expand Down Expand Up @@ -507,10 +522,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,
Expand Down Expand Up @@ -539,10 +558,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);

Expand Down Expand Up @@ -609,6 +632,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;
}

Expand Down Expand Up @@ -647,6 +683,7 @@ where
};

state.scroll(delta, self.direction, bounds, content_bounds);
shell.request_redraw(window::RedrawRequest::NextFrame);

event_status = if notify_on_scroll(
state,
Expand Down Expand Up @@ -692,6 +729,9 @@ where
bounds,
content_bounds,
);
shell.request_redraw(
window::RedrawRequest::NextFrame,
);

state.scroll_area_touched_at =
Some(cursor_position);
Expand All @@ -711,6 +751,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);
}
}
_ => {}
}

Expand Down Expand Up @@ -1087,7 +1134,7 @@ fn notify_on_scroll<Message>(
true
}

#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone)]
struct State {
scroll_area_touched_at: Option<Point>,
offset_y_relative: f32,
Expand All @@ -1096,20 +1143,8 @@ struct State {
x_scroller_grabbed_at: Option<f32>,
keyboard_modifiers: keyboard::Modifiers,
last_notified: Option<Viewport>,
}

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<f32, Instant>,
x_animation: Animated<f32, Instant>,
}

impl operation::Scrollable for State {
Expand Down Expand Up @@ -1226,8 +1261,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
Expand Down Expand Up @@ -1259,13 +1308,15 @@ 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)
.absolute(bounds.height, content_bounds.height)
- 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 {
Expand All @@ -1275,29 +1326,51 @@ 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);
}
}

/// Scrolls the [`Scrollable`] to a relative amount along the y axis.
///
/// `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`].
Expand All @@ -1307,10 +1380,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`],
Expand All @@ -1323,7 +1401,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,
Expand All @@ -1332,7 +1413,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,
Expand Down
2 changes: 1 addition & 1 deletion widget/src/text_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 180d37e

Please sign in to comment.