Skip to content

Commit 4e69ed5

Browse files
committed
feat: smooth scrolling
1 parent cbab267 commit 4e69ed5

File tree

5 files changed

+133
-49
lines changed

5 files changed

+133
-49
lines changed

core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ web-time.workspace = true
3232

3333
dark-light.workspace = true
3434
dark-light.optional = true
35-
lilt = "0.5.0"
35+
lilt = "0.6.0"
3636

3737
[dev-dependencies]
3838
approx = "0.5"

widget/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,4 @@ ouroboros.optional = true
4141

4242
qrcode.workspace = true
4343
qrcode.optional = true
44-
lilt = "0.5.0"
44+
lilt = "0.6.0"

widget/src/button.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,7 +611,7 @@ pub enum Status {
611611

612612
/// The [`AnimationTarget`] represents, through its ['FloatRepresentable`]
613613
/// implementation the ratio of color mixing between the base and hover colors.
614-
#[derive(Debug, Clone)]
614+
#[derive(Debug, Clone, Copy, PartialEq)]
615615
enum AnimationTarget {
616616
Active,
617617
Hovered,

widget/src/scrollable.rs

Lines changed: 129 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,19 @@ use crate::core::touch;
1111
use crate::core::widget;
1212
use crate::core::widget::operation::{self, Operation};
1313
use crate::core::widget::tree::{self, Tree};
14+
use crate::core::window;
1415
use crate::core::{
1516
self, Background, Clipboard, Color, Element, Layout, Length, Padding,
1617
Pixels, Point, Rectangle, Shell, Size, Theme, Vector, Widget,
1718
};
18-
use crate::runtime::task::{self, Task};
19-
use crate::runtime::Action;
20-
19+
use crate::runtime::{
20+
task::{self, Task},
21+
Action,
22+
};
23+
use lilt::Animated;
24+
use lilt::Easing::EaseOut;
2125
pub use operation::scrollable::{AbsoluteOffset, RelativeOffset};
26+
use std::time::Instant;
2227

2328
/// A widget that can vertically display an infinite amount of content with a
2429
/// scrollbar.
@@ -39,6 +44,7 @@ pub struct Scrollable<
3944
content: Element<'a, Message, Theme, Renderer>,
4045
on_scroll: Option<Box<dyn Fn(Viewport) -> Message + 'a>>,
4146
class: Theme::Class<'a>,
47+
animation_duration_ms: f32,
4248
}
4349

4450
impl<'a, Message, Theme, Renderer> Scrollable<'a, Message, Theme, Renderer>
@@ -58,6 +64,7 @@ where
5864
content: content.into(),
5965
on_scroll: None,
6066
class: Theme::default(),
67+
animation_duration_ms: 200.,
6168
}
6269
.validate()
6370
}
@@ -338,7 +345,7 @@ where
338345
}
339346

340347
fn state(&self) -> tree::State {
341-
tree::State::new(State::new())
348+
tree::State::new(State::new(self.animation_duration_ms))
342349
}
343350

344351
fn children(&self) -> Vec<Tree> {
@@ -479,10 +486,14 @@ where
479486
return event::Status::Ignored;
480487
};
481488

482-
state.scroll_y_to(scrollbar.scroll_percentage_y(
483-
scroller_grabbed_at,
484-
cursor_position,
485-
));
489+
state.scroll_y_to(
490+
scrollbar.scroll_percentage_y(
491+
scroller_grabbed_at,
492+
cursor_position,
493+
),
494+
true,
495+
);
496+
shell.request_redraw(window::RedrawRequest::NextFrame);
486497

487498
let _ = notify_on_scroll(
488499
state,
@@ -511,10 +522,14 @@ where
511522
scrollbars.grab_y_scroller(cursor_position),
512523
scrollbars.y,
513524
) {
514-
state.scroll_y_to(scrollbar.scroll_percentage_y(
515-
scroller_grabbed_at,
516-
cursor_position,
517-
));
525+
state.scroll_y_to(
526+
scrollbar.scroll_percentage_y(
527+
scroller_grabbed_at,
528+
cursor_position,
529+
),
530+
false,
531+
);
532+
shell.request_redraw(window::RedrawRequest::NextFrame);
518533

519534
state.y_scroller_grabbed_at = Some(scroller_grabbed_at);
520535

@@ -542,10 +557,14 @@ where
542557
};
543558

544559
if let Some(scrollbar) = scrollbars.x {
545-
state.scroll_x_to(scrollbar.scroll_percentage_x(
546-
scroller_grabbed_at,
547-
cursor_position,
548-
));
560+
state.scroll_x_to(
561+
scrollbar.scroll_percentage_x(
562+
scroller_grabbed_at,
563+
cursor_position,
564+
),
565+
true,
566+
);
567+
shell.request_redraw(window::RedrawRequest::NextFrame);
549568

550569
let _ = notify_on_scroll(
551570
state,
@@ -574,10 +593,14 @@ where
574593
scrollbars.grab_x_scroller(cursor_position),
575594
scrollbars.x,
576595
) {
577-
state.scroll_x_to(scrollbar.scroll_percentage_x(
578-
scroller_grabbed_at,
579-
cursor_position,
580-
));
596+
state.scroll_x_to(
597+
scrollbar.scroll_percentage_x(
598+
scroller_grabbed_at,
599+
cursor_position,
600+
),
601+
false,
602+
);
603+
shell.request_redraw(window::RedrawRequest::NextFrame);
581604

582605
state.x_scroller_grabbed_at = Some(scroller_grabbed_at);
583606

@@ -644,6 +667,19 @@ where
644667
state.x_scroller_grabbed_at = None;
645668
state.y_scroller_grabbed_at = None;
646669

670+
// Reset animations durations from instantaneous to default.
671+
// This is necessary because we change the animation duration when
672+
// grabbing the scrollbars, and are unable to access the animation
673+
// duration in all methods, such as `scroll_to` and `snap_to`.
674+
state.y_animation = state
675+
.y_animation
676+
.clone()
677+
.duration(self.animation_duration_ms);
678+
state.x_animation = state
679+
.x_animation
680+
.clone()
681+
.duration(self.animation_duration_ms);
682+
647683
return event_status;
648684
}
649685

@@ -682,6 +718,7 @@ where
682718
};
683719

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

686723
event_status = if notify_on_scroll(
687724
state,
@@ -727,6 +764,9 @@ where
727764
bounds,
728765
content_bounds,
729766
);
767+
shell.request_redraw(
768+
window::RedrawRequest::NextFrame,
769+
);
730770

731771
state.scroll_area_touched_at =
732772
Some(cursor_position);
@@ -746,6 +786,13 @@ where
746786

747787
event_status = event::Status::Captured;
748788
}
789+
Event::Window(window::Event::RedrawRequested(now)) => {
790+
if state.x_animation.in_progress(now)
791+
|| state.y_animation.in_progress(now)
792+
{
793+
shell.request_redraw(window::RedrawRequest::NextFrame);
794+
}
795+
}
749796
_ => {}
750797
}
751798

@@ -1122,7 +1169,7 @@ fn notify_on_scroll<Message>(
11221169
true
11231170
}
11241171

1125-
#[derive(Debug, Clone, Copy)]
1172+
#[derive(Debug, Clone)]
11261173
struct State {
11271174
scroll_area_touched_at: Option<Point>,
11281175
offset_y_relative: f32,
@@ -1131,20 +1178,8 @@ struct State {
11311178
x_scroller_grabbed_at: Option<f32>,
11321179
keyboard_modifiers: keyboard::Modifiers,
11331180
last_notified: Option<Viewport>,
1134-
}
1135-
1136-
impl Default for State {
1137-
fn default() -> Self {
1138-
Self {
1139-
scroll_area_touched_at: None,
1140-
offset_y_relative: 0.0,
1141-
y_scroller_grabbed_at: None,
1142-
offset_x_relative: 0.0,
1143-
x_scroller_grabbed_at: None,
1144-
keyboard_modifiers: keyboard::Modifiers::default(),
1145-
last_notified: None,
1146-
}
1147-
}
1181+
y_animation: Animated<f32, Instant>,
1182+
x_animation: Animated<f32, Instant>,
11481183
}
11491184

11501185
impl operation::Scrollable for State {
@@ -1261,8 +1296,22 @@ impl Viewport {
12611296

12621297
impl State {
12631298
/// Creates a new [`State`] with the scrollbar(s) at the beginning.
1264-
pub fn new() -> Self {
1265-
State::default()
1299+
pub fn new(animation_duration_ms: f32) -> Self {
1300+
Self {
1301+
scroll_area_touched_at: None,
1302+
offset_y_relative: 0.0,
1303+
y_scroller_grabbed_at: None,
1304+
offset_x_relative: 0.0,
1305+
x_scroller_grabbed_at: None,
1306+
keyboard_modifiers: keyboard::Modifiers::default(),
1307+
last_notified: None,
1308+
y_animation: Animated::new(0.0)
1309+
.easing(EaseOut)
1310+
.duration(animation_duration_ms),
1311+
x_animation: Animated::new(0.0)
1312+
.easing(EaseOut)
1313+
.duration(animation_duration_ms),
1314+
}
12661315
}
12671316

12681317
/// Apply a scrolling offset to the current [`State`], given the bounds of
@@ -1294,13 +1343,15 @@ impl State {
12941343
align(vertical_alignment, delta.y),
12951344
);
12961345

1346+
let now = Instant::now();
12971347
if bounds.height < content_bounds.height {
12981348
self.offset_y_relative =
12991349
((Offset::Relative(self.offset_y_relative)
13001350
.absolute(bounds.height, content_bounds.height)
13011351
- delta.y)
13021352
.clamp(0.0, content_bounds.height - bounds.height))
13031353
/ (content_bounds.height - bounds.height);
1354+
self.y_animation.transition(self.offset_y_relative, now);
13041355
}
13051356

13061357
if bounds.width < content_bounds.width {
@@ -1310,29 +1361,51 @@ impl State {
13101361
- delta.x)
13111362
.clamp(0.0, content_bounds.width - bounds.width))
13121363
/ (content_bounds.width - bounds.width);
1364+
self.x_animation.transition(self.offset_x_relative, now);
13131365
}
13141366
}
13151367

13161368
/// Scrolls the [`Scrollable`] to a relative amount along the y axis.
13171369
///
13181370
/// `0` represents scrollbar at the beginning, while `1` represents scrollbar at
13191371
/// the end.
1320-
pub fn scroll_y_to(&mut self, percentage: f32) {
1321-
self.offset_y_relative = percentage.clamp(0.0, 1.0);
1372+
///
1373+
/// When `instantaneous` is set to `true`, the transition uses no animation.
1374+
pub fn scroll_y_to(&mut self, percentage: f32, instantaneous: bool) {
1375+
let percentage = percentage.clamp(0.0, 1.0);
1376+
self.offset_y_relative = percentage;
1377+
if instantaneous {
1378+
self.y_animation
1379+
.transition_instantaneous(percentage, Instant::now());
1380+
} else {
1381+
self.y_animation.transition(percentage, Instant::now());
1382+
}
13221383
}
13231384

13241385
/// Scrolls the [`Scrollable`] to a relative amount along the x axis.
13251386
///
13261387
/// `0` represents scrollbar at the beginning, while `1` represents scrollbar at
13271388
/// the end.
1328-
pub fn scroll_x_to(&mut self, percentage: f32) {
1329-
self.offset_x_relative = percentage.clamp(0.0, 1.0);
1389+
///
1390+
/// When `instantaneous` is set to `true`, the transition uses no animation.
1391+
pub fn scroll_x_to(&mut self, percentage: f32, instantaneous: bool) {
1392+
let percentage = percentage.clamp(0.0, 1.0);
1393+
self.offset_x_relative = percentage;
1394+
if instantaneous {
1395+
self.x_animation
1396+
.transition_instantaneous(percentage, Instant::now());
1397+
} else {
1398+
self.x_animation.transition(percentage, Instant::now());
1399+
}
13301400
}
13311401

13321402
/// Snaps the scroll position to a [`RelativeOffset`].
13331403
pub fn snap_to(&mut self, offset: RelativeOffset) {
1404+
let now = Instant::now();
13341405
self.offset_x_relative = offset.x.clamp(0.0, 1.0);
13351406
self.offset_y_relative = offset.y.clamp(0.0, 1.0);
1407+
self.x_animation.transition(self.offset_x_relative, now);
1408+
self.y_animation.transition(self.offset_y_relative, now);
13361409
}
13371410

13381411
/// Scroll to the provided [`AbsoluteOffset`].
@@ -1342,10 +1415,15 @@ impl State {
13421415
bounds: Rectangle,
13431416
content_bounds: Rectangle,
13441417
) {
1418+
let now = Instant::now();
13451419
self.offset_x_relative = Offset::Absolute(offset.x.max(0.0))
1346-
.relative(bounds.width, content_bounds.width);
1420+
.relative(bounds.width, content_bounds.width)
1421+
.clamp(0.0, 1.0);
13471422
self.offset_y_relative = Offset::Absolute(offset.y.max(0.0))
1348-
.relative(bounds.height, content_bounds.height);
1423+
.relative(bounds.height, content_bounds.height)
1424+
.clamp(0.0, 1.0);
1425+
self.x_animation.transition(self.offset_x_relative, now);
1426+
self.y_animation.transition(self.offset_y_relative, now);
13491427
}
13501428

13511429
/// Returns the scrolling translation of the [`State`], given a [`Direction`],
@@ -1358,7 +1436,10 @@ impl State {
13581436
) -> Vector {
13591437
Vector::new(
13601438
if let Some(horizontal) = direction.horizontal() {
1361-
Offset::Relative(self.offset_x_relative).translation(
1439+
Offset::Relative(
1440+
self.x_animation.animate(|target| target, Instant::now()),
1441+
)
1442+
.translation(
13621443
bounds.width,
13631444
content_bounds.width,
13641445
horizontal.alignment,
@@ -1367,7 +1448,10 @@ impl State {
13671448
0.0
13681449
},
13691450
if let Some(vertical) = direction.vertical() {
1370-
Offset::Relative(self.offset_y_relative).translation(
1451+
Offset::Relative(
1452+
self.y_animation.animate(|target| target, Instant::now()),
1453+
)
1454+
.translation(
13711455
bounds.height,
13721456
content_bounds.height,
13731457
vertical.alignment,

widget/src/text_input.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ pub enum CursorAnimationType {
9898

9999
/// The [`AnimationTarget`] represents, through its ['FloatRepresentable`]
100100
/// implementation the ratio of opacity of the cursor during it's blink effect.
101-
#[derive(Debug, Clone)]
101+
#[derive(Debug, Clone, Copy, PartialEq)]
102102
enum AnimationTarget {
103103
Shown,
104104
Hidden,

0 commit comments

Comments
 (0)