1use std::cell::{Cell, RefCell};
2use std::rc::Rc;
3
4use web_time::Instant;
5
6use crate::nested_scroll::{NestedScrollConnection, NestedScrollSource};
7use crate::{ControlVisualSet, Dp, Signal, Vec2, request_frame, signal};
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum ScrollAxis {
12 Vertical,
13 Horizontal,
14 Both,
15}
16
17#[derive(Clone, Debug, PartialEq)]
21pub struct ScrollbarStyle {
22 pub thickness: Dp,
23 pub track_inset: Dp,
24 pub track_cross_inset: Dp,
28 pub min_thumb_length: Dp,
29 pub thumb_max_length_fraction: f32,
32 pub fixed_thumb_length: Option<Dp>,
33 pub radius: Option<Dp>,
34 pub track_visuals: ControlVisualSet,
35 pub thumb_visuals: ControlVisualSet,
36 pub fade_enabled: bool,
40 pub fade_delay_ms: u64,
42 pub fade_duration_ms: u64,
44}
45
46impl Default for ScrollbarStyle {
47 fn default() -> Self {
48 Self {
49 thickness: Dp(4.0),
50 track_inset: Dp(2.0),
51 track_cross_inset: Dp(0.0),
52 min_thumb_length: Dp(24.0),
53 thumb_max_length_fraction: 0.9,
54 fixed_thumb_length: None,
55 radius: None,
56 track_visuals: ControlVisualSet::default(),
57 thumb_visuals: ControlVisualSet::default(),
58 fade_enabled: true,
59 fade_delay_ms: 400,
60 fade_duration_ms: 250,
61 }
62 }
63}
64
65impl ScrollbarStyle {
66 pub fn with_thickness(mut self, thickness: Dp) -> Self {
67 self.thickness = thickness;
68 self
69 }
70
71 pub fn with_track_inset(mut self, track_inset: Dp) -> Self {
72 self.track_inset = track_inset;
73 self
74 }
75
76 pub fn with_track_cross_inset(mut self, track_cross_inset: Dp) -> Self {
77 self.track_cross_inset = track_cross_inset;
78 self
79 }
80
81 pub fn with_thumb_max_length_fraction(mut self, fraction: f32) -> Self {
82 self.thumb_max_length_fraction = fraction;
83 self
84 }
85
86 pub fn with_fade_enabled(mut self, fade_enabled: bool) -> Self {
87 self.fade_enabled = fade_enabled;
88 self
89 }
90
91 pub fn with_fade_delay_ms(mut self, fade_delay_ms: u64) -> Self {
92 self.fade_delay_ms = fade_delay_ms;
93 self
94 }
95
96 pub fn with_fade_duration_ms(mut self, fade_duration_ms: u64) -> Self {
97 self.fade_duration_ms = fade_duration_ms;
98 self
99 }
100
101 pub fn with_min_thumb_length(mut self, min_thumb_length: Dp) -> Self {
102 self.min_thumb_length = min_thumb_length;
103 self
104 }
105
106 pub fn with_fixed_thumb_length(mut self, length: Option<Dp>) -> Self {
107 self.fixed_thumb_length = length;
108 self
109 }
110
111 pub fn with_radius(mut self, radius: Option<Dp>) -> Self {
112 self.radius = radius;
113 self
114 }
115
116 pub fn with_track_visuals(mut self, visuals: ControlVisualSet) -> Self {
117 self.track_visuals = visuals;
118 self
119 }
120
121 pub fn with_thumb_visuals(mut self, visuals: ControlVisualSet) -> Self {
122 self.thumb_visuals = visuals;
123 self
124 }
125}
126
127#[derive(Clone, Default)]
129pub struct ScrollAxisBinding {
130 pub on_scroll: Option<Rc<dyn Fn(Vec2) -> Vec2>>,
131 pub set_viewport_main: Option<Rc<dyn Fn(f32)>>,
133 pub set_content_main: Option<Rc<dyn Fn(f32)>>,
135 pub get_offset_main: Option<Rc<dyn Fn() -> f32>>,
136 pub set_offset_main: Option<Rc<dyn Fn(f32)>>,
137 pub reversed: bool,
140 pub show_scrollbar: bool,
141 pub tick: Option<Rc<dyn Fn()>>,
142 pub set_nested_scroll_parent: Option<Rc<dyn Fn(NestedScrollConnection)>>,
143}
144
145impl std::fmt::Debug for ScrollAxisBinding {
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 f.debug_struct("ScrollAxisBinding")
148 .field("has_on_scroll", &self.on_scroll.is_some())
149 .field("set_viewport_main", &self.set_viewport_main.is_some())
150 .field("set_content_main", &self.set_content_main.is_some())
151 .field("get_offset_main", &self.get_offset_main.is_some())
152 .field("set_offset_main", &self.set_offset_main.is_some())
153 .field("show_scrollbar", &self.show_scrollbar)
154 .field("tick", &self.tick.is_some())
155 .field(
156 "set_nested_scroll_parent",
157 &self.set_nested_scroll_parent.is_some(),
158 )
159 .finish()
160 }
161}
162
163#[derive(Clone, Default)]
165pub struct ScrollBothBinding {
166 pub on_scroll: Option<Rc<dyn Fn(Vec2) -> Vec2>>,
167 pub set_viewport_width: Option<Rc<dyn Fn(f32)>>,
168 pub set_viewport_height: Option<Rc<dyn Fn(f32)>>,
169 pub set_content_width: Option<Rc<dyn Fn(f32)>>,
170 pub set_content_height: Option<Rc<dyn Fn(f32)>>,
171 pub get_offset_xy: Option<Rc<dyn Fn() -> (f32, f32)>>,
172 pub set_offset_xy: Option<Rc<dyn Fn(f32, f32)>>,
173 pub reversed_x: bool,
175 pub reversed_y: bool,
176 pub show_scrollbar: bool,
177 pub tick: Option<Rc<dyn Fn()>>,
178 pub set_nested_scroll_parent: Option<Rc<dyn Fn(NestedScrollConnection)>>,
179}
180
181impl std::fmt::Debug for ScrollBothBinding {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 f.debug_struct("ScrollBothBinding")
184 .field("has_on_scroll", &self.on_scroll.is_some())
185 .field("set_viewport_width", &self.set_viewport_width.is_some())
186 .field("set_viewport_height", &self.set_viewport_height.is_some())
187 .field("set_content_width", &self.set_content_width.is_some())
188 .field("set_content_height", &self.set_content_height.is_some())
189 .field("get_offset_xy", &self.get_offset_xy.is_some())
190 .field("set_offset_xy", &self.set_offset_xy.is_some())
191 .field("show_scrollbar", &self.show_scrollbar)
192 .field("tick", &self.tick.is_some())
193 .field(
194 "set_nested_scroll_parent",
195 &self.set_nested_scroll_parent.is_some(),
196 )
197 .finish()
198 }
199}
200
201#[derive(Clone, Debug)]
202pub enum ScrollBinding {
203 Vertical(ScrollAxisBinding),
204 Horizontal(ScrollAxisBinding),
205 Both(ScrollBothBinding),
206}
207
208impl ScrollBinding {
209 pub fn axis(&self) -> ScrollAxis {
210 match self {
211 ScrollBinding::Vertical(_) => ScrollAxis::Vertical,
212 ScrollBinding::Horizontal(_) => ScrollAxis::Horizontal,
213 ScrollBinding::Both(_) => ScrollAxis::Both,
214 }
215 }
216}
217
218const OVERSHOOT_DECAY_PER_60HZ: f32 = 0.78;
219
220pub const SCROLL_DECAY_PER_60HZ: f32 = 0.85;
222pub const SCROLL_STOP_VELOCITY: f32 = 15.0;
224pub const SCROLL_FLING_ACTIVATE_VELOCITY: f32 = 50.0;
226pub const SCROLL_OVERSROLL_BAND_MAX: f32 = 150.0;
228
229fn clamp_offset(off: f32, max: f32) -> f32 {
234 let max = if max.is_finite() { max.max(0.0) } else { 0.0 };
235 if off.is_finite() {
236 off.clamp(0.0, max)
237 } else {
238 0.0
239 }
240}
241
242pub fn rubber_band(amount: f32, max: f32) -> f32 {
245 let abs_val = amount.abs();
246 if abs_val <= 0.0 {
247 return 0.0;
248 }
249 (1.0 - 1.0 / (1.0 + abs_val / max)) * max * amount.signum()
250}
251
252pub fn os_scroll_axis(
255 off: &Signal<f32>,
256 os: &Signal<f32>,
257 overscroll_enabled: bool,
258 max_off: f32,
259 before: f32,
260 leftover: f32,
261 physics: &RefCell<ScrollPhysics>,
262) -> f32 {
263 if !overscroll_enabled || leftover.abs() < 0.001 {
264 return leftover;
265 }
266 let os_val = os.get();
267 if os_val.abs() > 0.5 && os_val.signum() * leftover < 0.0 {
269 let reduction = leftover.abs().min(os_val.abs());
270 os.set(os_val - os_val.signum() * reduction);
271 let remainder = leftover - leftover.signum() * reduction;
272 if remainder.abs() > 0.5 {
273 let new_off = clamp_offset(before + remainder, max_off);
274 off.set(new_off);
275 let consumed = new_off - before;
276 physics.borrow_mut().record_nested_input(consumed);
277 return remainder - consumed;
278 }
279 return 0.0;
280 }
281
282 let at_edge = (before <= 0.0 && leftover < 0.0) || (before >= max_off && leftover > 0.0);
284 if max_off > 5.0 && at_edge && leftover.abs() > 0.5 {
285 os.set(os_val + rubber_band(leftover, SCROLL_OVERSROLL_BAND_MAX));
286 physics.borrow_mut().record_nested_input(0.0);
287 return 0.0;
288 }
289 leftover
290}
291
292pub fn tick_os_axis(os: &Signal<f32>, enabled: bool, dt: f32) -> bool {
294 if !enabled {
295 return false;
296 }
297 let v = os.get();
298 if v.abs() > 0.5 {
299 let decayed = v * OVERSHOOT_DECAY_PER_60HZ.powf(dt * 60.0);
300 if decayed.abs() < 0.5 {
301 os.set(0.0);
302 } else {
303 os.set(decayed);
304 }
305 request_frame();
306 true
307 } else {
308 false
309 }
310}
311
312#[derive(Clone)]
315pub struct ScrollPhysics {
316 pub(crate) vel: f32,
317 last_t: Instant,
318 last_input_t: Instant,
319 pub(crate) animating: bool,
320 input_pending: bool,
322 last_dt: f32,
323 decay_per_60hz: f32,
324 stop_velocity: f32,
325 input_activate_velocity: f32,
326}
327
328impl Default for ScrollPhysics {
329 fn default() -> Self {
330 Self::new()
331 }
332}
333
334impl ScrollPhysics {
335 pub fn new() -> Self {
336 Self::with_tuning(
337 SCROLL_DECAY_PER_60HZ,
338 SCROLL_STOP_VELOCITY,
339 SCROLL_FLING_ACTIVATE_VELOCITY,
340 )
341 }
342
343 pub fn with_tuning(
344 decay_per_60hz: f32,
345 stop_velocity: f32,
346 input_activate_velocity: f32,
347 ) -> Self {
348 let now = Instant::now();
349 Self {
350 vel: 0.0,
351 last_t: now,
352 last_input_t: now,
353 animating: false,
354 input_pending: false,
355 last_dt: 1.0 / 60.0,
356 decay_per_60hz,
357 stop_velocity,
358 input_activate_velocity,
359 }
360 }
361
362 pub fn record_user_input(&mut self, consumed: f32) {
371 let (instant_vel, fresh) = self.sample(consumed);
372 if fresh || (self.animating && instant_vel * self.vel < 0.0) {
373 self.vel = instant_vel;
374 } else {
375 const SMOOTHING: f32 = 0.35;
376 self.vel = self.vel * (1.0 - SMOOTHING) + instant_vel * SMOOTHING;
377 }
378 self.finish_sample();
379 }
380
381 pub fn record_nested_input(&mut self, consumed: f32) {
385 let (instant_vel, _) = self.sample(consumed);
386 const SMOOTHING: f32 = 0.35;
387 self.vel = self.vel * (1.0 - SMOOTHING) + instant_vel * SMOOTHING;
388 self.finish_sample();
389 }
390
391 fn sample(&mut self, consumed: f32) -> (f32, bool) {
392 let now = Instant::now();
393 let raw_dt = (now - self.last_input_t).as_secs_f32();
394 let dt = raw_dt.clamp(1.0 / 240.0, 1.0 / 15.0);
395 self.last_dt = dt;
396 let fresh = raw_dt > 0.05;
398 self.last_input_t = now;
399 if raw_dt > 0.1 {
400 self.vel = 0.0;
401 }
402 (consumed / dt, fresh)
403 }
404
405 fn finish_sample(&mut self) {
406 const MAX_VEL: f32 = 8000.0;
407 self.vel = self.vel.clamp(-MAX_VEL, MAX_VEL);
408 self.animating = self.vel.abs() > self.input_activate_velocity;
409 self.input_pending = true;
410 }
411
412 fn dt(&mut self) -> f32 {
413 let now = Instant::now();
414 let dt = (now - self.last_t).as_secs_f32().min(0.1);
415 self.last_t = now;
416 dt
417 }
418
419 pub fn tick_integrate(&mut self, current: f32, min: f32, max: f32) -> Option<f32> {
420 let dt = self.dt();
425 if self.input_pending {
426 self.input_pending = false;
432 return None;
433 }
434 if !self.animating {
435 return None;
436 }
437 if dt <= 0.0 {
438 return None;
439 }
440 let vel0 = self.vel;
441 if vel0.abs() < self.stop_velocity {
442 self.vel = 0.0;
443 self.animating = false;
444 return None;
445 }
446 let new = {
447 let raw = current + vel0 * dt;
448 let raw = if raw.is_finite() { raw } else { current };
449 raw.clamp(min, max.max(min))
450 };
451 if (new - current).abs() < 0.01 && (current <= min || current >= max) {
452 self.vel = 0.0;
453 self.animating = false;
454 return None;
455 }
456 let speed = self.vel.abs();
457 let t = (speed / 4000.0).min(1.0);
458 let effective_decay = (self.decay_per_60hz + t * 0.10).min(0.999);
459 let decay = effective_decay.powf(dt * 60.0);
460 self.vel = vel0 * decay;
461 if self.vel.abs() < self.stop_velocity {
462 self.vel = 0.0;
463 self.animating = false;
464 return None;
465 }
466 Some(new)
467 }
468
469 pub fn is_animating(&self) -> bool {
470 self.animating
471 }
472}
473
474#[derive(Clone)]
476pub struct ScrollState {
477 scroll_offset: Signal<f32>,
478 viewport_height: Signal<f32>,
479 content_height: Signal<f32>,
480 physics: Rc<RefCell<ScrollPhysics>>,
481 overscroll: Signal<f32>,
482 overscroll_enabled: Cell<bool>,
483 pub(crate) parent_connection: Rc<RefCell<Option<NestedScrollConnection>>>,
484 show_scrollbar: Cell<bool>,
485 reversed: Rc<Cell<bool>>,
486 prev_tick: Cell<Instant>,
487}
488
489impl Default for ScrollState {
490 fn default() -> Self {
491 Self::new()
492 }
493}
494
495impl ScrollState {
496 pub fn new() -> Self {
497 Self {
498 scroll_offset: signal(0.0),
499 viewport_height: signal(0.0),
500 content_height: signal(0.0),
501 physics: Rc::new(RefCell::new(ScrollPhysics::new())),
502 overscroll: signal(0.0),
503 overscroll_enabled: Cell::new(true),
504 parent_connection: Rc::new(RefCell::new(None)),
505 show_scrollbar: Cell::new(true),
506 reversed: Rc::new(Cell::new(false)),
507 prev_tick: Cell::new(Instant::now()),
508 }
509 }
510
511 pub fn set_overscroll_enabled(&self, enabled: bool) {
512 self.overscroll_enabled.set(enabled);
513 }
514
515 pub fn overscroll_offset(&self) -> f32 {
516 self.overscroll.get()
517 }
518
519 pub fn set_nested_scroll_parent(&self, conn: NestedScrollConnection) {
520 *self.parent_connection.borrow_mut() = Some(conn);
521 }
522
523 pub fn connection(&self) -> NestedScrollConnection {
526 let this = Rc::new(self.clone());
527 let pc = Rc::clone(&this.parent_connection);
528 NestedScrollConnection::new().on_post_scroll(
529 move |_consumed: Vec2, available: Vec2, _source: NestedScrollSource| -> Vec2 {
530 if available.x.abs() < 0.001 && available.y.abs() < 0.001 {
531 return Vec2::ZERO;
532 }
533 let leftover_y = this.scroll_nested(available.y);
534 let after = run_post_scroll(
535 &pc,
536 Vec2 {
537 x: available.x,
538 y: leftover_y,
539 },
540 );
541 Vec2 {
542 x: available.x - after.x,
543 y: available.y - after.y,
544 }
545 },
546 )
547 }
548
549 pub fn set_show_scrollbar(&self, show: bool) {
550 self.show_scrollbar.set(show);
551 }
552
553 pub fn set_reversed(&self, reversed: bool) {
557 self.reversed.set(reversed);
558 }
559
560 pub fn set_viewport_height(&self, h: f32) {
561 let h = h.max(0.0);
562 if (self.viewport_height.get() - h).abs() > 0.5 {
563 self.viewport_height.set(h);
564 self.clamp_offset();
565 }
566 }
567
568 pub fn set_content_height(&self, h: f32) {
569 let h = h.max(0.0);
570 if (self.content_height.get() - h).abs() > 0.5 {
571 self.content_height.set(h);
572 self.clamp_offset();
573 }
574 }
575
576 pub fn set_overscroll(&self, val: f32) {
577 self.overscroll.set(val);
578 }
579
580 pub fn set_offset(&self, off: f32) {
581 let off = if off.is_finite() { off } else { 0.0 };
582 let vh = self.viewport_height.get();
583 let ch = self.content_height.get();
584 let max_off = (ch - vh).max(0.0);
585 self.scroll_offset.set(clamp_offset(off, max_off));
586 }
587
588 fn clamp_offset(&self) {
589 let vh = self.viewport_height.get();
590 let ch = self.content_height.get();
591 let max_off = (ch - vh).max(0.0);
592 self.scroll_offset.update(|o| {
593 if *o > max_off {
594 *o = max_off;
595 }
596 if *o < 0.0 {
597 *o = 0.0;
598 }
599 });
600 }
601
602 pub fn get(&self) -> f32 {
603 self.scroll_offset.get()
604 }
605
606 pub fn scroll_immediate(&self, dy: f32) -> f32 {
610 self.apply_delta(dy, true)
611 }
612
613 pub fn scroll_nested(&self, dy: f32) -> f32 {
617 self.apply_delta(dy, false)
618 }
619
620 fn apply_delta(&self, dy: f32, user: bool) -> f32 {
621 let dy = if dy.is_finite() { dy } else { 0.0 };
622 let before = self.scroll_offset.get();
623 let vh = self.viewport_height.get();
624 let ch = self.content_height.get();
625 let max_off = (ch - vh).max(0.0);
626
627 let new_off = clamp_offset(before + dy, max_off);
628 self.scroll_offset.set(new_off);
629 let consumed = new_off - before;
630 if user {
631 self.physics.borrow_mut().record_user_input(consumed);
632 } else {
633 self.physics.borrow_mut().record_nested_input(consumed);
634 }
635 dy - consumed
636 }
637
638 pub fn apply_overscroll(&self, leftover: f32) -> f32 {
642 let vh = self.viewport_height.get();
643 let ch = self.content_height.get();
644 os_scroll_axis(
645 &self.scroll_offset,
646 &self.overscroll,
647 self.overscroll_enabled.get(),
648 (ch - vh).max(0.0),
649 self.scroll_offset.get(),
650 leftover,
651 &self.physics,
652 )
653 }
654
655 pub fn tick(&self) -> bool {
656 let now = Instant::now();
657 let dt = (now - self.prev_tick.get()).as_secs_f32().min(0.1);
658 self.prev_tick.set(now);
659
660 if tick_os_axis(&self.overscroll, self.overscroll_enabled.get(), dt) {
661 return true;
662 }
663
664 let vh = self.viewport_height.get();
665 let ch = self.content_height.get();
666 let max_off = (ch - vh).max(0.0);
667
668 let mut p = self.physics.borrow_mut();
669 if let Some(new_off) = p.tick_integrate(self.scroll_offset.get(), 0.0, max_off) {
670 drop(p);
671 self.scroll_offset.set(new_off);
672 request_frame();
673 true
674 } else {
675 false
676 }
677 }
678
679 pub fn show_scrollbar(&self) -> bool {
680 self.show_scrollbar.get()
681 }
682
683 pub fn to_binding(&self) -> ScrollBinding {
685 let this = Rc::new(self.clone());
686 let pc = Rc::clone(&this.parent_connection);
687 let c_on_scroll = {
688 let state = Rc::clone(&pc);
689 let this = Rc::clone(&this);
690 Rc::new(move |d: Vec2| -> Vec2 {
691 let d = run_pre_scroll(&state, d);
692 let leftover_y = this.scroll_immediate(d.y);
695 let result = Vec2 {
696 x: d.x,
697 y: leftover_y,
698 };
699 let after_parent = run_post_scroll(&state, result);
700 let final_y = this.apply_overscroll(after_parent.y);
701 Vec2 {
702 x: after_parent.x,
703 y: final_y,
704 }
705 })
706 } as Rc<dyn Fn(Vec2) -> Vec2>;
707 let c_set_viewport = {
708 let this = Rc::clone(&this);
709 Rc::new(move |h: f32| this.set_viewport_height(h))
710 };
711 let c_set_content = {
712 let this = Rc::clone(&this);
713 Rc::new(move |h: f32| this.set_content_height(h))
714 };
715 let c_get = {
716 let this = Rc::clone(&this);
717 Rc::new(move || this.get() + this.overscroll_offset())
718 };
719 let c_set = {
720 let this = Rc::clone(&this);
721 Rc::new(move |off: f32| this.set_offset(off))
722 };
723 let c_tick = {
724 let this = Rc::clone(&this);
725 Rc::new(move || {
726 this.tick();
727 })
728 };
729 let c_set_nested = {
730 let state = Rc::clone(&pc);
731 Rc::new(move |conn| {
732 *state.borrow_mut() = Some(conn);
733 })
734 };
735 ScrollBinding::Vertical(ScrollAxisBinding {
736 on_scroll: Some(c_on_scroll),
737 set_viewport_main: Some(c_set_viewport),
738 set_content_main: Some(c_set_content),
739 get_offset_main: Some(c_get),
740 set_offset_main: Some(c_set),
741 reversed: this.reversed.get(),
742 show_scrollbar: self.show_scrollbar(),
743 tick: Some(c_tick),
744 set_nested_scroll_parent: Some(c_set_nested),
745 })
746 }
747}
748
749#[derive(Clone)]
751pub struct HorizontalScrollState {
752 scroll_offset: Signal<f32>,
753 viewport_width: Signal<f32>,
754 content_width: Signal<f32>,
755 physics: Rc<RefCell<ScrollPhysics>>,
756 overscroll: Signal<f32>,
757 overscroll_enabled: Cell<bool>,
758 pub(crate) parent_connection: Rc<RefCell<Option<NestedScrollConnection>>>,
759 show_scrollbar: Cell<bool>,
760 reversed: Rc<Cell<bool>>,
761 prev_tick: Cell<Instant>,
762}
763
764impl Default for HorizontalScrollState {
765 fn default() -> Self {
766 Self::new()
767 }
768}
769
770impl HorizontalScrollState {
771 pub fn new() -> Self {
772 Self {
773 scroll_offset: signal(0.0),
774 viewport_width: signal(0.0),
775 content_width: signal(0.0),
776 physics: Rc::new(RefCell::new(ScrollPhysics::new())),
777 overscroll: signal(0.0),
778 overscroll_enabled: Cell::new(true),
779 parent_connection: Rc::new(RefCell::new(None)),
780 show_scrollbar: Cell::new(true),
781 reversed: Rc::new(Cell::new(false)),
782 prev_tick: Cell::new(Instant::now()),
783 }
784 }
785
786 pub fn set_overscroll_enabled(&self, enabled: bool) {
787 self.overscroll_enabled.set(enabled);
788 }
789
790 pub fn overscroll_offset(&self) -> f32 {
791 self.overscroll.get()
792 }
793
794 pub fn set_nested_scroll_parent(&self, conn: NestedScrollConnection) {
795 *self.parent_connection.borrow_mut() = Some(conn);
796 }
797
798 pub fn connection(&self) -> NestedScrollConnection {
802 let this = Rc::new(self.clone());
803 let pc = Rc::clone(&this.parent_connection);
804 NestedScrollConnection::new().on_post_scroll(
805 move |_consumed: Vec2, available: Vec2, _source: NestedScrollSource| -> Vec2 {
806 if available.x.abs() < 0.001 && available.y.abs() < 0.001 {
807 return Vec2::ZERO;
808 }
809 let leftover_x = this.scroll_nested(available.x);
810 let after = run_post_scroll(
811 &pc,
812 Vec2 {
813 x: leftover_x,
814 y: available.y,
815 },
816 );
817 Vec2 {
818 x: available.x - after.x,
819 y: available.y - after.y,
820 }
821 },
822 )
823 }
824
825 pub fn set_show_scrollbar(&self, show: bool) {
826 self.show_scrollbar.set(show);
827 }
828
829 pub fn set_reversed(&self, reversed: bool) {
833 self.reversed.set(reversed);
834 }
835
836 pub fn set_viewport_width(&self, w: f32) {
837 let w = w.max(0.0);
838 if (self.viewport_width.get() - w).abs() > 0.5 {
839 self.viewport_width.set(w);
840 self.clamp();
841 }
842 }
843
844 pub fn set_content_width(&self, w: f32) {
845 let w = w.max(0.0);
846 if (self.content_width.get() - w).abs() > 0.5 {
847 self.content_width.set(w);
848 self.clamp();
849 }
850 }
851
852 pub fn set_overscroll(&self, val: f32) {
853 self.overscroll.set(val);
854 }
855
856 pub fn set_offset(&self, off: f32) {
857 let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
858 self.scroll_offset.set(clamp_offset(off, max_off));
859 }
860
861 fn clamp(&self) {
862 let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
863 self.scroll_offset.update(|o| {
864 *o = clamp_offset(*o, max_off);
865 });
866 }
867
868 pub fn get(&self) -> f32 {
869 self.scroll_offset.get()
870 }
871
872 pub fn scroll_immediate(&self, dx: f32) -> f32 {
875 self.apply_delta(dx, true)
876 }
877
878 pub fn scroll_nested(&self, dx: f32) -> f32 {
881 self.apply_delta(dx, false)
882 }
883
884 fn apply_delta(&self, dx: f32, user: bool) -> f32 {
885 let dx = if dx.is_finite() { dx } else { 0.0 };
886 let before = self.scroll_offset.get();
887 let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
888
889 let new_off = clamp_offset(before + dx, max_off);
890 self.scroll_offset.set(new_off);
891 let consumed = new_off - before;
892 if user {
893 self.physics.borrow_mut().record_user_input(consumed);
894 } else {
895 self.physics.borrow_mut().record_nested_input(consumed);
896 }
897 dx - consumed
898 }
899
900 pub fn apply_overscroll(&self, leftover: f32) -> f32 {
902 let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
903 os_scroll_axis(
904 &self.scroll_offset,
905 &self.overscroll,
906 self.overscroll_enabled.get(),
907 max_off,
908 self.scroll_offset.get(),
909 leftover,
910 &self.physics,
911 )
912 }
913
914 pub fn tick(&self) -> bool {
915 let now = Instant::now();
916 let dt = (now - self.prev_tick.get()).as_secs_f32().min(0.1);
917 self.prev_tick.set(now);
918
919 if tick_os_axis(&self.overscroll, self.overscroll_enabled.get(), dt) {
920 return true;
921 }
922
923 let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
924 let mut p = self.physics.borrow_mut();
925 if let Some(new_off) = p.tick_integrate(self.scroll_offset.get(), 0.0, max_off) {
926 drop(p);
927 self.scroll_offset.set(new_off);
928 request_frame();
929 true
930 } else {
931 false
932 }
933 }
934
935 pub fn show_scrollbar(&self) -> bool {
936 self.show_scrollbar.get()
937 }
938
939 pub fn to_binding(&self) -> ScrollBinding {
940 let this = Rc::new(self.clone());
941 let pc = Rc::clone(&this.parent_connection);
942 let c_on_scroll = {
943 let state = Rc::clone(&pc);
944 let this = Rc::clone(&this);
945 Rc::new(move |d: Vec2| -> Vec2 {
946 let d = run_pre_scroll(&state, d);
947 let leftover_x = this.scroll_immediate(d.x);
948 let result = Vec2 {
949 x: leftover_x,
950 y: d.y,
951 };
952 let after_parent = run_post_scroll(&state, result);
953 let final_x = this.apply_overscroll(after_parent.x);
954 Vec2 {
955 x: final_x,
956 y: after_parent.y,
957 }
958 })
959 } as Rc<dyn Fn(Vec2) -> Vec2>;
960 let c_set_vp = {
961 let this = Rc::clone(&this);
962 Rc::new(move |w: f32| this.set_viewport_width(w))
963 };
964 let c_set_ct = {
965 let this = Rc::clone(&this);
966 Rc::new(move |w: f32| this.set_content_width(w))
967 };
968 let c_get = {
969 let this = Rc::clone(&this);
970 Rc::new(move || this.get() + this.overscroll_offset())
971 };
972 let c_set = {
973 let this = Rc::clone(&this);
974 Rc::new(move |off: f32| this.set_offset(off))
975 };
976 let c_tick = {
977 let this = Rc::clone(&this);
978 Rc::new(move || {
979 this.tick();
980 })
981 };
982 let c_set_nested = {
983 let state = Rc::clone(&pc);
984 Rc::new(move |conn| {
985 *state.borrow_mut() = Some(conn);
986 })
987 };
988 ScrollBinding::Horizontal(ScrollAxisBinding {
989 on_scroll: Some(c_on_scroll),
990 set_viewport_main: Some(c_set_vp),
991 set_content_main: Some(c_set_ct),
992 get_offset_main: Some(c_get),
993 set_offset_main: Some(c_set),
994 reversed: this.reversed.get(),
995 show_scrollbar: this.show_scrollbar(),
996 tick: Some(c_tick),
997 set_nested_scroll_parent: Some(c_set_nested),
998 })
999 }
1000}
1001
1002#[derive(Clone)]
1004pub struct ScrollStateXY {
1005 off_x: Signal<f32>,
1006 off_y: Signal<f32>,
1007 vp_w: Signal<f32>,
1008 vp_h: Signal<f32>,
1009 c_w: Signal<f32>,
1010 c_h: Signal<f32>,
1011 physics_x: Rc<RefCell<ScrollPhysics>>,
1012 physics_y: Rc<RefCell<ScrollPhysics>>,
1013 os_x: Signal<f32>,
1014 os_y: Signal<f32>,
1015 overscroll_enabled: Cell<bool>,
1016 pub(crate) parent_connection: Rc<RefCell<Option<NestedScrollConnection>>>,
1017 show_scrollbar: Cell<bool>,
1018 reversed_x: Rc<Cell<bool>>,
1019 reversed_y: Rc<Cell<bool>>,
1020 prev_tick: Cell<Instant>,
1021}
1022
1023impl Default for ScrollStateXY {
1024 fn default() -> Self {
1025 Self::new()
1026 }
1027}
1028
1029impl ScrollStateXY {
1030 pub fn new() -> Self {
1031 Self {
1032 off_x: signal(0.0),
1033 off_y: signal(0.0),
1034 vp_w: signal(0.0),
1035 vp_h: signal(0.0),
1036 c_w: signal(0.0),
1037 c_h: signal(0.0),
1038 physics_x: Rc::new(RefCell::new(ScrollPhysics::new())),
1039 physics_y: Rc::new(RefCell::new(ScrollPhysics::new())),
1040 os_x: signal(0.0),
1041 os_y: signal(0.0),
1042 overscroll_enabled: Cell::new(true),
1043 parent_connection: Rc::new(RefCell::new(None)),
1044 show_scrollbar: Cell::new(true),
1045 reversed_x: Rc::new(Cell::new(false)),
1046 reversed_y: Rc::new(Cell::new(false)),
1047 prev_tick: Cell::new(Instant::now()),
1048 }
1049 }
1050
1051 pub fn set_overscroll_enabled(&self, enabled: bool) {
1052 self.overscroll_enabled.set(enabled);
1053 }
1054
1055 pub fn set_show_scrollbar(&self, show: bool) {
1056 self.show_scrollbar.set(show);
1057 }
1058
1059 pub fn set_reversed_x(&self, reversed: bool) {
1062 self.reversed_x.set(reversed);
1063 }
1064
1065 pub fn set_reversed_y(&self, reversed: bool) {
1068 self.reversed_y.set(reversed);
1069 }
1070
1071 pub fn overscroll_offset(&self) -> (f32, f32) {
1072 (self.os_x.get(), self.os_y.get())
1073 }
1074
1075 pub fn set_nested_scroll_parent(&self, conn: NestedScrollConnection) {
1076 *self.parent_connection.borrow_mut() = Some(conn);
1077 }
1078
1079 pub fn connection(&self) -> NestedScrollConnection {
1083 let this = Rc::new(self.clone());
1084 let pc = Rc::clone(&this.parent_connection);
1085 NestedScrollConnection::new().on_post_scroll(
1086 move |_consumed: Vec2, available: Vec2, _source: NestedScrollSource| -> Vec2 {
1087 if available.x.abs() < 0.001 && available.y.abs() < 0.001 {
1088 return Vec2::ZERO;
1089 }
1090 let after = this.scroll_nested(available);
1091 let after2 = run_post_scroll(&pc, after);
1092 Vec2 {
1093 x: available.x - after2.x,
1094 y: available.y - after2.y,
1095 }
1096 },
1097 )
1098 }
1099
1100 pub fn set_viewport(&self, w: f32, h: f32) {
1101 let w = w.max(0.0);
1102 let h = h.max(0.0);
1103 let changed = (self.vp_w.get() - w).abs() > 0.5 || (self.vp_h.get() - h).abs() > 0.5;
1104 if changed {
1105 self.vp_w.set(w);
1106 self.vp_h.set(h);
1107 self.clamp();
1108 }
1109 }
1110 pub fn set_content(&self, w: f32, h: f32) {
1111 let w = w.max(0.0);
1112 let h = h.max(0.0);
1113 let changed = (self.c_w.get() - w).abs() > 0.5 || (self.c_h.get() - h).abs() > 0.5;
1114 if changed {
1115 self.c_w.set(w);
1116 self.c_h.set(h);
1117 self.clamp();
1118 }
1119 }
1120 pub fn set_offset_xy(&self, x: f32, y: f32) {
1121 let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
1122 let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
1123 self.off_x.set(clamp_offset(x, max_x));
1124 self.off_y.set(clamp_offset(y, max_y));
1125 }
1126 fn clamp(&self) {
1127 let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
1128 let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
1129 self.off_x.update(|x| *x = clamp_offset(*x, max_x));
1130 self.off_y.update(|y| *y = clamp_offset(*y, max_y));
1131 }
1132 pub fn get(&self) -> (f32, f32) {
1133 (self.off_x.get(), self.off_y.get())
1134 }
1135
1136 fn os_scroll_axis(
1137 off: &Signal<f32>,
1138 os: &Signal<f32>,
1139 overscroll_enabled: bool,
1140 max_off: f32,
1141 before: f32,
1142 leftover: f32,
1143 physics: &RefCell<ScrollPhysics>,
1144 ) -> f32 {
1145 if !overscroll_enabled || leftover.abs() < 0.001 {
1146 return leftover;
1147 }
1148 let os_val = os.get();
1149 if os_val.abs() > 0.5 && os_val.signum() * leftover < 0.0 {
1151 let reduction = leftover.abs().min(os_val.abs());
1152 os.set(os_val - os_val.signum() * reduction);
1153 let remainder = leftover - leftover.signum() * reduction;
1154 if remainder.abs() > 0.5 {
1155 let new_off = clamp_offset(before + remainder, max_off);
1156 off.set(new_off);
1157 let consumed = new_off - before;
1158 physics.borrow_mut().record_nested_input(consumed);
1159 return remainder - consumed;
1160 }
1161 return 0.0;
1162 }
1163
1164 let at_edge = (before <= 0.0 && leftover < 0.0) || (before >= max_off && leftover > 0.0);
1166 if max_off > 5.0 && at_edge && leftover.abs() > 0.5 {
1167 os.set(os_val + rubber_band(leftover, SCROLL_OVERSROLL_BAND_MAX));
1168 physics.borrow_mut().record_nested_input(0.0);
1169 return 0.0;
1170 }
1171 leftover
1172 }
1173 pub fn scroll_immediate(&self, d: Vec2) -> Vec2 {
1176 self.apply_delta(d, true)
1177 }
1178
1179 pub fn scroll_nested(&self, d: Vec2) -> Vec2 {
1182 self.apply_delta(d, false)
1183 }
1184
1185 fn apply_delta(&self, d: Vec2, user: bool) -> Vec2 {
1186 let d = Vec2 {
1187 x: if d.x.is_finite() { d.x } else { 0.0 },
1188 y: if d.y.is_finite() { d.y } else { 0.0 },
1189 };
1190 let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
1191 let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
1192 let (bx, by) = (self.off_x.get(), self.off_y.get());
1193
1194 let nx = clamp_offset(bx + d.x, max_x);
1195 let ny = clamp_offset(by + d.y, max_y);
1196 self.off_x.set(nx);
1197 self.off_y.set(ny);
1198 let (cx, cy) = (nx - bx, ny - by);
1199
1200 let mut px = self.physics_x.borrow_mut();
1201 let mut py = self.physics_y.borrow_mut();
1202 if user {
1203 px.record_user_input(cx);
1204 py.record_user_input(cy);
1205 } else {
1206 px.record_nested_input(cx);
1207 py.record_nested_input(cy);
1208 }
1209 drop((px, py));
1210
1211 Vec2 {
1212 x: d.x - cx,
1213 y: d.y - cy,
1214 }
1215 }
1216
1217 pub fn apply_overscroll(&self, d: Vec2) -> Vec2 {
1219 let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
1220 let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
1221 let (bx, by) = (self.off_x.get(), self.off_y.get());
1222
1223 let lx = Self::os_scroll_axis(
1224 &self.off_x,
1225 &self.os_x,
1226 self.overscroll_enabled.get(),
1227 max_x,
1228 bx,
1229 d.x,
1230 &self.physics_x,
1231 );
1232 let ly = Self::os_scroll_axis(
1233 &self.off_y,
1234 &self.os_y,
1235 self.overscroll_enabled.get(),
1236 max_y,
1237 by,
1238 d.y,
1239 &self.physics_y,
1240 );
1241 Vec2 { x: lx, y: ly }
1242 }
1243 pub fn tick(&self) -> bool {
1244 let now = Instant::now();
1245 let dt = (now - self.prev_tick.get()).as_secs_f32().min(0.1);
1246 self.prev_tick.set(now);
1247
1248 if self.overscroll_enabled.get()
1249 && (tick_os_axis(&self.os_x, true, dt) || tick_os_axis(&self.os_y, true, dt))
1250 {
1251 return true;
1252 }
1253
1254 let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
1255 let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
1256 let (bx, by) = (self.off_x.get(), self.off_y.get());
1257
1258 let mut px = self.physics_x.borrow_mut();
1259 let mut py = self.physics_y.borrow_mut();
1260 if !px.animating && !py.animating {
1261 return false;
1262 }
1263 if px.animating {
1264 if px.vel.abs() < px.stop_velocity {
1265 px.vel = 0.0;
1266 px.animating = false;
1267 } else {
1268 let nx = clamp_offset(bx + px.vel * dt, max_x);
1269 if (nx - bx).abs() < 0.01 && (bx <= 0.0 || bx >= max_x) {
1270 px.vel = 0.0;
1271 px.animating = false;
1272 } else {
1273 let speed = px.vel.abs();
1274 let t = (speed / 4000.0).min(1.0);
1275 let effective_decay = 0.85 + t * 0.10;
1276 px.vel *= effective_decay.powf(dt * 60.0);
1277 if px.vel.abs() < px.stop_velocity {
1278 px.vel = 0.0;
1279 px.animating = false;
1280 } else {
1281 self.off_x.set(nx);
1282 }
1283 }
1284 }
1285 }
1286 if py.animating {
1287 if py.vel.abs() < py.stop_velocity {
1288 py.vel = 0.0;
1289 py.animating = false;
1290 } else {
1291 let ny = clamp_offset(by + py.vel * dt, max_y);
1292 if (ny - by).abs() < 0.01 && (by <= 0.0 || by >= max_y) {
1293 py.vel = 0.0;
1294 py.animating = false;
1295 } else {
1296 let speed = py.vel.abs();
1297 let t = (speed / 4000.0).min(1.0);
1298 let effective_decay = 0.85 + t * 0.10;
1299 py.vel *= effective_decay.powf(dt * 60.0);
1300 if py.vel.abs() < py.stop_velocity {
1301 py.vel = 0.0;
1302 py.animating = false;
1303 } else {
1304 self.off_y.set(ny);
1305 }
1306 }
1307 }
1308 }
1309 let running = px.animating || py.animating;
1310 if running {
1311 request_frame();
1312 }
1313 running
1314 }
1315
1316 pub fn show_scrollbar(&self) -> bool {
1317 self.show_scrollbar.get()
1318 }
1319
1320 pub fn to_binding(&self) -> ScrollBinding {
1321 let this = Rc::new(self.clone());
1322 let pc = Rc::clone(&this.parent_connection);
1323 let c_on_scroll = {
1324 let state = Rc::clone(&pc);
1325 let this = Rc::clone(&this);
1326 Rc::new(move |d: Vec2| -> Vec2 {
1327 let d = run_pre_scroll(&state, d);
1328 let result = this.scroll_immediate(d);
1329 let after_parent = run_post_scroll(&state, result);
1330 this.apply_overscroll(after_parent)
1331 })
1332 } as Rc<dyn Fn(Vec2) -> Vec2>;
1333 let c_set_vw = {
1334 let this = Rc::clone(&this);
1335 Rc::new(move |w: f32| this.set_viewport(w, this.vp_h.get()))
1336 };
1337 let c_set_vh = {
1338 let this = Rc::clone(&this);
1339 Rc::new(move |h: f32| this.set_viewport(this.vp_w.get(), h))
1340 };
1341 let c_set_cw = {
1342 let this = Rc::clone(&this);
1343 Rc::new(move |w: f32| this.set_content(w, this.c_h.get()))
1344 };
1345 let c_set_ch = {
1346 let this = Rc::clone(&this);
1347 Rc::new(move |h: f32| this.set_content(this.c_w.get(), h))
1348 };
1349 let c_get_xy = {
1350 let this = Rc::clone(&this);
1351 Rc::new(move || {
1352 let (ox, oy) = this.get();
1353 let (osx, osy) = this.overscroll_offset();
1354 (ox + osx, oy + osy)
1355 })
1356 };
1357 let c_set_xy = {
1358 let this = Rc::clone(&this);
1359 Rc::new(move |x: f32, y: f32| this.set_offset_xy(x, y))
1360 };
1361 let c_tick = {
1362 let this = Rc::clone(&this);
1363 Rc::new(move || {
1364 this.tick();
1365 })
1366 };
1367 let c_set_nested = {
1368 let state = Rc::clone(&pc);
1369 Rc::new(move |conn| {
1370 *state.borrow_mut() = Some(conn);
1371 })
1372 };
1373 ScrollBinding::Both(ScrollBothBinding {
1374 on_scroll: Some(c_on_scroll),
1375 set_viewport_width: Some(c_set_vw),
1376 set_viewport_height: Some(c_set_vh),
1377 set_content_width: Some(c_set_cw),
1378 set_content_height: Some(c_set_ch),
1379 get_offset_xy: Some(c_get_xy),
1380 set_offset_xy: Some(c_set_xy),
1381 reversed_x: this.reversed_x.get(),
1382 reversed_y: this.reversed_y.get(),
1383 show_scrollbar: this.show_scrollbar(),
1384 tick: Some(c_tick),
1385 set_nested_scroll_parent: Some(c_set_nested),
1386 })
1387 }
1388}
1389
1390pub fn run_pre_scroll(conn: &RefCell<Option<NestedScrollConnection>>, d: Vec2) -> Vec2 {
1391 if let Some(ref parent) = *conn.borrow() {
1392 let consumed = parent.dispatch_pre_scroll(d, NestedScrollSource::UserInput);
1393 d - consumed
1394 } else {
1395 d
1396 }
1397}
1398
1399pub fn run_post_scroll(conn: &RefCell<Option<NestedScrollConnection>>, leftover: Vec2) -> Vec2 {
1400 if let Some(ref parent) = *conn.borrow() {
1401 let added =
1402 parent.dispatch_post_scroll(Vec2::ZERO, leftover, NestedScrollSource::UserInput);
1403 leftover - added
1404 } else {
1405 leftover
1406 }
1407}
1408
1409#[cfg(test)]
1410mod fling_tests {
1411 use super::*;
1412
1413 const FRAME: web_time::Duration = web_time::Duration::from_millis(16);
1414
1415 fn seeded() -> ScrollState {
1416 let st = ScrollState::new();
1417 st.set_viewport_height(100.0);
1418 st.set_content_height(4000.0);
1419 st
1420 }
1421
1422 fn frame() {
1426 std::thread::sleep(FRAME);
1427 }
1428
1429 #[test]
1433 fn drag_does_not_double_count() {
1434 let st = seeded();
1435 let (frames, step) = (10, 30.0);
1436 for _ in 0..frames {
1437 st.scroll_immediate(step);
1438 frame();
1439 st.tick();
1440 }
1441 let expected = step * frames as f32;
1442 assert!(
1443 (st.get() - expected).abs() < 1.0,
1444 "drag of {expected} over {frames} frames moved {}",
1445 st.get()
1446 );
1447 }
1448
1449 #[test]
1452 fn fling_continues_after_input_stops() {
1453 let st = seeded();
1454 st.scroll_immediate(200.0);
1455 frame();
1456 st.tick();
1457 let at_release = st.get();
1458 let mut coasted = 0.0;
1459 for _ in 0..10 {
1460 frame();
1461 if st.tick() {
1462 coasted += 1.0;
1463 }
1464 }
1465 assert!(coasted > 0.0, "fling stopped immediately after release");
1466 assert!(st.get() > at_release, "fling must travel further");
1467 }
1468
1469 #[test]
1474 fn release_is_continuous_with_the_drag() {
1475 for step in [8.0f32, 30.0, 70.0] {
1476 let st = seeded();
1477 for _ in 0..10 {
1478 st.scroll_immediate(step);
1479 frame();
1480 st.tick();
1481 }
1482 let released = st.get();
1483 frame();
1484 st.tick();
1485 let first = st.get() - released;
1486 assert!(
1487 (first - step).abs() < step * 0.25,
1488 "drag at {step}px/frame released into a {first}px step"
1489 );
1490 }
1491 }
1492
1493 #[test]
1496 fn drag_cancels_stale_fling_direction() {
1497 let st = seeded();
1498 for _ in 0..6 {
1499 st.scroll_immediate(200.0);
1500 frame();
1501 st.tick();
1502 }
1503 let before = st.get();
1504 for _ in 0..6 {
1505 st.scroll_immediate(-20.0);
1506 frame();
1507 st.tick();
1508 }
1509 assert!(
1510 st.get() < before,
1511 "reverse drag must win over the old fling ({} -> {})",
1512 before,
1513 st.get()
1514 );
1515 }
1516
1517 #[test]
1519 fn parent_flings_after_taking_child_leftover() {
1520 let st = seeded();
1521 let conn = st.connection();
1522 let consumed = conn.dispatch_post_scroll(
1523 Vec2::ZERO,
1524 Vec2 { x: 0.0, y: 200.0 },
1525 NestedScrollSource::UserInput,
1526 );
1527 assert!(
1529 (consumed.y - 200.0).abs() < 0.001,
1530 "consumed {}",
1531 consumed.y
1532 );
1533 assert!(st.get() > 0.0, "parent scrolled");
1534 assert!(st.physics.borrow().is_animating(), "parent has velocity");
1535 frame();
1536 st.tick();
1537 let before_coast = st.get();
1538 frame();
1539 st.tick();
1540 assert!(
1541 st.get() > before_coast,
1542 "parent must coast: nested velocity reached the driven physics"
1543 );
1544 }
1545}