1pub mod scrollbar;
9use std::time::Duration;
10
11use tessera_foundation::{
12 gesture::{ScrollRecognizer, TapRecognizer},
13 scroll_physics::{ExponentialInertia, ScrollVelocityTracker as FoundationVelocityTracker},
14};
15use tessera_ui::{
16 AxisConstraint, BuildModifierNode, CallbackWith, Color, ComputedData, Constraint, Dp,
17 LayoutCoordinates, LayoutModifierChild, LayoutModifierInput, LayoutModifierNode, LayoutResult,
18 MeasurementError, Modifier, ModifierBuildContext, PointerInput, PointerInputModifierNode, Px,
19 PxPosition, RenderSlot, ScrollEventSource, State, current_frame_nanos, current_frame_time,
20 focus::FocusRevealRequest,
21 layout::{MeasureScope, PlacementResult, PlacementScope, layout},
22 modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
23 receive_frame_nanos, remember, tessera,
24 time::Instant,
25};
26
27use crate::{
28 alignment::Alignment,
29 boxed::boxed,
30 modifier::ModifierExt,
31 nested_scroll::{NestedScrollConnection, ScrollDelta, ScrollVelocity},
32 pos_misc::is_position_inside_bounds,
33};
34
35pub use crate::scrollable::scrollbar::{
36 ScrollBarState, ScrollbarDefaults, scrollbar_h, scrollbar_v,
37};
38
39const SCROLL_INERTIA_DECAY_CONSTANT: f32 = 5.0;
40const SCROLL_INERTIA_MIN_VELOCITY_DP: f32 = 10.0;
41const SCROLL_INERTIA_START_THRESHOLD_DP: f32 = 50.0;
42const SCROLL_INERTIA_MAX_VELOCITY_DP: f32 = 6000.0;
43const SCROLL_VELOCITY_SAMPLE_WINDOW: Duration = Duration::from_millis(90);
44const SCROLL_VELOCITY_IDLE_CUTOFF: Duration = Duration::from_millis(65);
45
46pub(crate) const DEFAULT_SCROLL_SMOOTHING: f32 = 0.75;
54
55fn clamp_scroll_coordinate(value: f32, boundary: f32, enabled: bool) -> f32 {
56 if !enabled {
57 0.0
58 } else if value < boundary {
59 boundary
60 } else if value > 0.0 {
61 0.0
62 } else {
63 value
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Default)]
69pub enum ScrollBarBehavior {
70 #[default]
72 AlwaysVisible,
73 AutoHide,
75 Hidden,
77}
78
79#[derive(Debug, Clone, PartialEq, Default)]
81pub enum ScrollBarLayout {
82 #[default]
85 Alongside,
86 Overlay,
88}
89
90#[derive(Clone, PartialEq)]
99pub struct ScrollableController {
100 density: f64,
101 viewport_scale: f32,
102 viewport_axes: Option<(bool, bool)>,
103 child_position: PxPosition,
105 target_position: PxPosition,
107 target_position_f32: (f32, f32),
109 child_position_f32: (f32, f32),
112 animation_position: (f64, f64),
115 child_size: ComputedData,
117 visible_size: ComputedData,
119 override_child_size: Option<ComputedData>,
121 last_frame_nanos: Option<u64>,
123 scrollbar_state_v: scrollbar::ScrollBarStateInner,
125 scrollbar_state_h: scrollbar::ScrollBarStateInner,
127 velocity_tracker: Option<FoundationVelocityTracker>,
129 active_inertia: Option<ExponentialInertia>,
131 inertia_last_tick: Option<Instant>,
132 positioned: bool,
139}
140
141impl Default for ScrollableController {
142 fn default() -> Self {
143 Self::new()
144 }
145}
146
147impl ScrollableController {
148 pub fn new() -> Self {
150 Self {
151 density: tessera_ui::dp::scale_factor(),
152 viewport_scale: 1.0,
153 viewport_axes: None,
154 child_position: PxPosition::ZERO,
155 target_position: PxPosition::ZERO,
156 target_position_f32: (0.0, 0.0),
157 child_position_f32: (0.0, 0.0),
158 animation_position: (0.0, 0.0),
159 child_size: ComputedData::ZERO,
160 visible_size: ComputedData::ZERO,
161 override_child_size: None,
162 last_frame_nanos: None,
163 scrollbar_state_v: scrollbar::ScrollBarStateInner::default(),
164 scrollbar_state_h: scrollbar::ScrollBarStateInner::default(),
165 velocity_tracker: None,
166 active_inertia: None,
167 inertia_last_tick: None,
168 positioned: false,
169 }
170 }
171
172 pub fn child_position(&self) -> PxPosition {
179 self.child_position
180 }
181
182 pub fn is_positioned(&self) -> bool {
189 self.positioned
190 }
191
192 pub fn visible_size(&self) -> ComputedData {
194 self.visible_size
195 }
196
197 #[cfg(test)]
198 pub(crate) fn set_visible_size_for_test(&mut self, size: ComputedData) {
199 self.visible_size = size;
200 }
201
202 pub(crate) fn child_size(&self) -> ComputedData {
203 self.child_size
204 }
205
206 pub fn override_child_size(&mut self, size: ComputedData) {
210 self.update_density(tessera_ui::dp::scale_factor());
211 self.override_child_size = Some(size);
212 self.child_size = size;
213 }
214
215 pub(crate) fn target_position(&self) -> PxPosition {
216 self.target_position
217 }
218
219 pub(crate) fn set_target_position(&mut self, target: PxPosition) {
220 self.update_density(tessera_ui::dp::scale_factor());
221 self.cancel_inertia();
222 self.velocity_tracker = None;
223 self.positioned = true;
224 self.target_position = target;
225 self.target_position_f32 = (target.x, target.y);
226 }
227
228 pub fn set_scroll_position(&mut self, position: PxPosition) {
233 self.update_density(tessera_ui::dp::scale_factor());
234 self.cancel_inertia();
235 self.velocity_tracker = None;
236 self.last_frame_nanos = None;
237 self.positioned = true;
238 self.child_position = position;
239 self.target_position = position;
240 self.child_position_f32 = (position.x, position.y);
241 self.animation_position = (position.x as f64, position.y as f64);
242 self.target_position_f32 = (position.x, position.y);
243 }
244
245 pub(crate) fn translate_scroll_position(&mut self, delta: PxPosition) {
246 self.child_position = self.child_position + delta;
249 self.target_position = self.target_position + delta;
250 self.child_position_f32.0 += delta.x;
251 self.child_position_f32.1 += delta.y;
252 self.target_position_f32.0 += delta.x;
253 self.target_position_f32.1 += delta.y;
254 self.animation_position.0 += f64::from(delta.x);
255 self.animation_position.1 += f64::from(delta.y);
256 }
257
258 pub(crate) fn update_density(&mut self, density: f64) -> f64 {
259 let ratio = density / self.density;
260 if density == self.density {
261 return 1.0;
262 }
263 self.density = density;
264 self.animation_position.0 *= ratio;
265 self.animation_position.1 *= ratio;
266 self.child_position_f32 = (
267 self.animation_position.0 as f32,
268 self.animation_position.1 as f32,
269 );
270 self.target_position_f32.0 = (f64::from(self.target_position_f32.0) * ratio) as f32;
271 self.target_position_f32.1 = (f64::from(self.target_position_f32.1) * ratio) as f32;
272 self.child_position =
273 PxPosition::from_f32_xy(self.child_position_f32.0, self.child_position_f32.1);
274 self.target_position =
275 PxPosition::from_f32_xy(self.target_position_f32.0, self.target_position_f32.1);
276 let scale_size = |size: ComputedData| ComputedData {
277 width: Px((f64::from(size.width.0) * ratio) as i32),
278 height: Px((f64::from(size.height.0) * ratio) as i32),
279 };
280 self.child_size = scale_size(self.child_size);
281 self.visible_size = scale_size(self.visible_size);
282 self.override_child_size = self.override_child_size.map(scale_size);
283 if let Some(inertia) = &mut self.active_inertia {
284 inertia.position_x *= ratio as f32;
285 inertia.position_y *= ratio as f32;
286 inertia.velocity_x *= ratio as f32;
287 inertia.velocity_y *= ratio as f32;
288 }
289 self.velocity_tracker = None;
292 ratio
293 }
294
295 fn needs_layout_reconciliation(
296 &self,
297 child_size: ComputedData,
298 visible_size: ComputedData,
299 vertical: bool,
300 horizontal: bool,
301 ) -> bool {
302 self.child_size != child_size
303 || self.visible_size != visible_size
304 || constrain_position(
305 self.child_position,
306 &child_size,
307 &visible_size,
308 vertical,
309 horizontal,
310 ) != self.child_position
311 || constrain_position(
312 self.target_position,
313 &child_size,
314 &visible_size,
315 vertical,
316 horizontal,
317 ) != self.target_position
318 }
319
320 fn reconcile_layout_extents(
321 &mut self,
322 child_size: ComputedData,
323 visible_size: ComputedData,
324 vertical: bool,
325 horizontal: bool,
326 ) {
327 self.child_size = child_size;
328 self.visible_size = visible_size;
329 let minimum = constrain_position(
330 PxPosition::new(Px(i32::MIN), Px(i32::MIN)),
331 &child_size,
332 &visible_size,
333 vertical,
334 horizontal,
335 );
336 self.animation_position.0 = self.animation_position.0.clamp(f64::from(minimum.x), 0.0);
337 self.animation_position.1 = self.animation_position.1.clamp(f64::from(minimum.y), 0.0);
338 self.child_position_f32 = (
339 self.animation_position.0 as f32,
340 self.animation_position.1 as f32,
341 );
342 self.child_position =
343 PxPosition::from_f32_xy(self.child_position_f32.0, self.child_position_f32.1);
344 self.target_position_f32.0 = self.target_position_f32.0.clamp(minimum.x, 0.0);
345 self.target_position_f32.1 = self.target_position_f32.1.clamp(minimum.y, 0.0);
346 self.target_position =
347 PxPosition::from_f32_xy(self.target_position_f32.0, self.target_position_f32.1);
348 if let Some(inertia) = &mut self.active_inertia {
349 inertia.position_x = inertia.position_x.clamp(minimum.x, 0.0);
350 inertia.position_y = inertia.position_y.clamp(minimum.y, 0.0);
351 }
352 if !self.has_pending_animation_frame() {
353 self.last_frame_nanos = None;
354 }
355 }
356
357 pub(crate) fn update_scroll_position(&mut self, frame_nanos: u64, smoothing: f32) -> bool {
360 let delta_time = self
361 .last_frame_nanos
362 .map(|last| frame_nanos.saturating_sub(last) as f32 / 1_000_000_000.0)
363 .unwrap_or(1.0 / 60.0)
364 .clamp(0.0, 0.25);
365 self.last_frame_nanos = Some(frame_nanos);
366 let old = self.child_position;
367 let (target_x, target_y) = self.target_position_f32;
368 let (current_x, current_y) = self.animation_position;
369 let smoothing = smoothing.clamp(0.0, 0.9999);
370 let response = if smoothing == 0.0 {
371 f32::INFINITY
372 } else {
373 -(smoothing).ln() * 60.0
374 };
375 let factor = if response.is_infinite() {
376 1.0
377 } else {
378 1.0 - (-response * delta_time).exp()
379 };
380 self.animation_position = (
381 current_x + (target_x as f64 - current_x) * factor as f64,
382 current_y + (target_y as f64 - current_y) * factor as f64,
383 );
384 if (target_x as f64 - self.animation_position.0).abs() < 0.01 {
385 self.animation_position.0 = target_x as f64;
386 }
387 if (target_y as f64 - self.animation_position.1).abs() < 0.01 {
388 self.animation_position.1 = target_y as f64;
389 }
390 self.child_position_f32 = (
391 self.animation_position.0 as f32,
392 self.animation_position.1 as f32,
393 );
394 self.child_position =
395 PxPosition::from_f32_xy(self.child_position_f32.0, self.child_position_f32.1);
396 if !self.has_pending_animation_frame() {
397 self.last_frame_nanos = None;
398 self.animation_position = (target_x as f64, target_y as f64);
399 }
400 if old != self.child_position {
401 self.positioned = true;
402 }
403 old != self.child_position
404 }
405
406 fn cancel_inertia(&mut self) {
407 self.active_inertia = None;
408 self.inertia_last_tick = None;
409 }
410
411 fn apply_input_delta(
415 &mut self,
416 delta: ScrollDelta,
417 source: ScrollEventSource,
418 discrete: bool,
419 container_size: &ComputedData,
420 vertical: bool,
421 horizontal: bool,
422 ) -> ScrollDelta {
423 self.cancel_inertia();
424 if source != ScrollEventSource::Touch {
425 self.velocity_tracker = None;
426 }
427 let immediate = !discrete;
428 if immediate {
429 self.target_position_f32 = self.child_position_f32;
430 self.target_position = self.child_position;
431 }
432 let consumed = self.apply_scroll_delta(delta, container_size, vertical, horizontal);
433 if immediate {
434 self.child_position_f32 = self.target_position_f32;
435 self.child_position = self.target_position;
436 self.animation_position = (
437 self.child_position_f32.0 as f64,
438 self.child_position_f32.1 as f64,
439 );
440 }
441 consumed
442 }
443 fn apply_scroll_delta(
444 &mut self,
445 delta: ScrollDelta,
446 container_size: &ComputedData,
447 vertical_scrollable: bool,
448 horizontal_scrollable: bool,
449 ) -> ScrollDelta {
450 self.positioned = true;
451 let current_target = self.target_position_f32;
452 let proposed = (current_target.0 + delta.x, current_target.1 + delta.y);
453 let extent_size = self.override_child_size.unwrap_or(self.child_size);
454 let constrained_target = constrain_position(
455 PxPosition::new(Px::new(i32::MIN), Px::new(i32::MIN)),
456 &extent_size,
457 container_size,
458 vertical_scrollable,
459 horizontal_scrollable,
460 );
461 let constrained = (constrained_target.x, constrained_target.y);
462 let next = (
463 clamp_scroll_coordinate(proposed.0, constrained.0, horizontal_scrollable),
464 clamp_scroll_coordinate(proposed.1, constrained.1, vertical_scrollable),
465 );
466 self.target_position_f32 = next;
467 self.target_position = PxPosition::from_f32_xy(next.0, next.1);
468 ScrollDelta::new(next.0 - current_target.0, next.1 - current_target.1)
469 }
470
471 fn push_touch_delta(&mut self, now: Instant, dx: f32, dy: f32) {
472 let tracker = self.velocity_tracker.get_or_insert_with(|| {
473 FoundationVelocityTracker::new(
474 now,
475 SCROLL_VELOCITY_SAMPLE_WINDOW,
476 SCROLL_VELOCITY_IDLE_CUTOFF,
477 )
478 });
479 tracker.push_delta(now, dx, dy);
480 }
481
482 fn resolve_touch_velocity(&mut self, now: Instant) -> ScrollVelocity {
483 let Some(mut tracker) = self.velocity_tracker.take() else {
484 return ScrollVelocity::ZERO;
485 };
486 if let Some(velocity) = tracker.resolve(now) {
487 let density = self.density as f32;
488 let speed = velocity.x.hypot(velocity.y);
489 if speed.is_finite() && speed > SCROLL_INERTIA_START_THRESHOLD_DP * density {
490 let scale = (SCROLL_INERTIA_MAX_VELOCITY_DP * density / speed).min(1.0);
491 return ScrollVelocity::new(velocity.x * scale, velocity.y * scale);
492 }
493 }
494 ScrollVelocity::ZERO
495 }
496
497 fn start_inertia(&mut self, now: Instant, velocity: ScrollVelocity) {
498 if velocity.is_zero() {
499 return;
500 }
501 self.inertia_last_tick = Some(now);
502 self.active_inertia = Some(ExponentialInertia::new(
503 self.target_position_f32.0,
504 self.target_position_f32.1,
505 tessera_foundation::scroll_physics::ScrollVelocity::new(velocity.x, velocity.y),
506 SCROLL_INERTIA_DECAY_CONSTANT,
507 ));
508 }
509
510 fn has_pending_animation_frame(&self) -> bool {
511 self.child_position_f32 != self.target_position_f32 || self.active_inertia.is_some()
512 }
513}
514
515pub(crate) fn synchronize_scroll_density(controller: State<ScrollableController>) {
516 let density = tessera_ui::dp::scale_factor();
517 if controller.with(|controller| controller.density != density) {
518 controller.with_mut(|controller| {
519 controller.update_density(density);
520 });
521 }
522}
523
524#[derive(Clone, PartialEq)]
525struct ScrollableAlongsideLayout {
526 vertical: bool,
527 horizontal: bool,
528}
529
530impl tessera_ui::LayoutModifierNode for ScrollableAlongsideLayout {
531 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
532 let mut result = LayoutResult::default();
533 let children = input.children();
534 let mut final_size = ComputedData::ZERO;
535 let child_constraint = input.parent_constraint().without_min();
536 let mut content_constraint = Constraint::new(
537 input.parent_constraint().width(),
538 input.parent_constraint().height(),
539 );
540
541 if self.vertical {
542 let scrollbar = children[1];
543 let size = scrollbar.measure(input, &child_constraint)?;
544 content_constraint.width -= size.width;
545 final_size.width += size.width;
546 }
547
548 if self.horizontal {
549 let scrollbar = if self.vertical {
550 children[2]
551 } else {
552 children[1]
553 };
554 let size = scrollbar.measure(input, &child_constraint)?;
555 content_constraint.height -= size.height;
556 final_size.height += size.height;
557 }
558
559 let content = children[0];
560 let content_measurement = content.measure(input, &content_constraint)?;
561 final_size.width += content_measurement.width;
562 final_size.height += content_measurement.height;
563
564 result.place_child(content, PxPosition::ZERO);
565 if self.vertical {
566 result.place_child(
567 children[1],
568 PxPosition::new(content_measurement.width, Px::ZERO),
569 );
570 }
571 if self.horizontal {
572 let scrollbar = if self.vertical {
573 children[2]
574 } else {
575 children[1]
576 };
577 result.place_child(
578 scrollbar,
579 PxPosition::new(Px::ZERO, content_measurement.height),
580 );
581 }
582
583 Ok(result.with_size(final_size))
584 }
585}
586
587struct ScrollLayoutModifierNode {
588 controller: State<ScrollableController>,
589 vertical: bool,
590 horizontal: bool,
591 apply_child_offset: bool,
592 has_override: bool,
593}
594
595impl PartialEq for ScrollLayoutModifierNode {
596 fn eq(&self, other: &Self) -> bool {
597 self.vertical == other.vertical
598 && self.horizontal == other.horizontal
599 && self.apply_child_offset == other.apply_child_offset
600 && self.has_override == other.has_override
601 }
602}
603
604impl LayoutModifierNode for ScrollLayoutModifierNode {
605 fn measure_content(
606 &self,
607 input: &LayoutModifierInput<'_>,
608 child: &mut dyn LayoutModifierChild,
609 ) -> Result<LayoutResult, MeasurementError> {
610 let parent = input.layout_input.parent_constraint();
611 let mut child_constraint = *parent.as_ref();
612 if self.vertical {
613 child_constraint.height = AxisConstraint::NONE;
614 }
615 if self.horizontal {
616 child_constraint.width = AxisConstraint::NONE;
617 }
618
619 let child_measurement = child.measure(&child_constraint)?;
620 let width = parent.width().clamp(child_measurement.width);
621 let height = parent.height().clamp(child_measurement.height);
622 let computed_data = ComputedData { width, height };
623 let current_child_position = input.placement(|| {
624 let next_child_size = self
625 .controller
626 .with(|c| c.override_child_size.unwrap_or(child_measurement));
627 if self.controller.with(|c| {
628 c.needs_layout_reconciliation(
629 next_child_size,
630 computed_data,
631 self.vertical,
632 self.horizontal,
633 )
634 }) {
635 self.controller.with_mut(|c| {
636 c.reconcile_layout_extents(
637 next_child_size,
638 computed_data,
639 self.vertical,
640 self.horizontal,
641 )
642 });
643 }
644 if self.apply_child_offset {
645 self.controller.with(|c| c.child_position())
646 } else {
647 PxPosition::ZERO
648 }
649 });
650 child.place(current_child_position);
651
652 Ok(LayoutResult::new(computed_data))
653 }
654
655 fn place_children(
656 &self,
657 _scope: &PlacementScope<'_>,
658 inner: &PlacementResult,
659 ) -> Option<PlacementResult> {
660 let (child_size, visible_size) = self.controller.with(|c| (c.child_size, c.visible_size));
664 if self.controller.with(|c| {
665 c.needs_layout_reconciliation(child_size, visible_size, self.vertical, self.horizontal)
666 }) {
667 self.controller.with_mut(|c| {
668 c.reconcile_layout_extents(child_size, visible_size, self.vertical, self.horizontal)
669 });
670 }
671 let offset = if self.apply_child_offset {
672 self.controller.with(|c| c.child_position())
673 } else {
674 PxPosition::ZERO
675 };
676 let mut result = PlacementResult::new();
677 for (instance_key, position) in inner.iter() {
678 result.place(*instance_key, offset + *position);
679 }
680 Some(result)
681 }
682}
683
684pub(crate) fn apply_scroll_modifier(
690 base: Modifier,
691 controller: State<ScrollableController>,
692 vertical: bool,
693 horizontal: bool,
694) -> Modifier {
695 if !(vertical || horizontal) {
696 return base;
697 }
698
699 base.push_build(ScrollBuild {
700 controller,
701 vertical,
702 horizontal,
703 })
704}
705
706struct ScrollBuild {
707 controller: State<ScrollableController>,
708 vertical: bool,
709 horizontal: bool,
710}
711
712impl BuildModifierNode for ScrollBuild {
713 fn materialize(&self, context: &mut ModifierBuildContext) -> Option<Modifier> {
714 let Self {
715 controller,
716 vertical,
717 horizontal,
718 } = *self;
719 synchronize_scroll_density(controller);
720
721 let scrollbar_state_v = ScrollBarState::viewport(controller, true);
722 let scrollbar_state_h = ScrollBarState::viewport(controller, false);
723 let tap_recognizer = context.state(TapRecognizer::default);
724 let scroll_recognizer = context.state(ScrollRecognizer::default);
725 if controller.with(|c| c.has_pending_animation_frame()) {
726 context.receive_frame_nanos(move |frame_nanos| {
727 let pending = controller.with_mut(|c| {
728 c.update_scroll_position(frame_nanos, DEFAULT_SCROLL_SMOOTHING);
729 c.has_pending_animation_frame()
730 });
731 if pending {
732 tessera_ui::FrameNanosControl::Continue
733 } else {
734 tessera_ui::FrameNanosControl::Stop
735 }
736 });
737 }
738 let has_override = controller.with(|c| c.override_child_size.is_some());
739 let nested_scroll_connection = context.nested_scroll_parent();
740 let modifier = apply_scrollable_viewport_input_modifier(ScrollableViewportInputArgs {
741 base: Modifier::new(),
742 controller,
743 vertical,
744 horizontal,
745 scrollbar_behavior: ScrollBarBehavior::Hidden,
746 scrollbar_state_v,
747 scrollbar_state_h,
748 tap_recognizer,
749 scroll_recognizer,
750 nested_scroll_connection,
751 });
752 let modifier = modifier.on_layout_coordinates(move |coordinates: LayoutCoordinates| {
753 let scale = coordinates.local_to_window.scale();
754 if controller.with(|controller| (controller.viewport_scale, controller.viewport_axes))
755 != (scale, Some((vertical, horizontal)))
756 {
757 controller.with_mut(|controller| {
758 controller.viewport_scale = scale;
759 controller.viewport_axes = Some((vertical, horizontal));
760 });
761 }
762 });
763 let modifier =
764 apply_scrollable_focus_reveal_modifier(modifier, controller, vertical, horizontal);
765 Some(
766 modifier
767 .clip_to_bounds()
768 .push_layout(ScrollLayoutModifierNode {
769 controller,
770 vertical,
771 horizontal,
772 apply_child_offset: true,
773 has_override,
774 }),
775 )
776 }
777}
778
779#[tessera]
786pub(crate) fn scrollable(
787 modifier: Option<Modifier>,
788 vertical: Option<bool>,
789 horizontal: Option<bool>,
790 scroll_smoothing: Option<f32>,
791 apply_child_offset: Option<bool>,
792 scrollbar_behavior: Option<ScrollBarBehavior>,
793 scrollbar_track_color: Option<Color>,
794 scrollbar_thumb_color: Option<Color>,
795 scrollbar_thumb_hover_color: Option<Color>,
796 scrollbar_layout: Option<ScrollBarLayout>,
797 controller: Option<State<ScrollableController>>,
798 child: Option<RenderSlot>,
799) {
800 let vertical = vertical.unwrap_or(false);
801 let horizontal = horizontal.unwrap_or(false);
802 let scroll_smoothing = scroll_smoothing.unwrap_or(DEFAULT_SCROLL_SMOOTHING);
803 let apply_child_offset = apply_child_offset.unwrap_or(true);
804 let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
805 let scrollbar_layout = scrollbar_layout.unwrap_or_default();
806 let controller = match controller {
807 Some(value) => value,
808 None => remember(ScrollableController::new),
809 };
810 let child = child.unwrap_or_else(RenderSlot::empty);
811 let modifier = modifier.unwrap_or_else(|| Modifier::new().fill_max_size());
812
813 match scrollbar_layout {
814 ScrollBarLayout::Alongside => {
815 layout().modifier(modifier).child(move || {
816 scrollable_with_alongside_scrollbar()
817 .controller(controller)
818 .vertical(vertical)
819 .horizontal(horizontal)
820 .scroll_smoothing(scroll_smoothing)
821 .apply_child_offset(apply_child_offset)
822 .scrollbar_behavior(scrollbar_behavior.clone())
823 .scrollbar_track_color_optional(scrollbar_track_color)
824 .scrollbar_thumb_color_optional(scrollbar_thumb_color)
825 .scrollbar_thumb_hover_color_optional(scrollbar_thumb_hover_color)
826 .child_shared(child);
827 });
828 }
829 ScrollBarLayout::Overlay => {
830 layout().modifier(modifier).child(move || {
831 scrollable_with_overlay_scrollbar()
832 .controller(controller)
833 .vertical(vertical)
834 .horizontal(horizontal)
835 .scroll_smoothing(scroll_smoothing)
836 .apply_child_offset(apply_child_offset)
837 .scrollbar_behavior(scrollbar_behavior.clone())
838 .scrollbar_track_color_optional(scrollbar_track_color)
839 .scrollbar_thumb_color_optional(scrollbar_thumb_color)
840 .scrollbar_thumb_hover_color_optional(scrollbar_thumb_hover_color)
841 .child_shared(child);
842 });
843 }
844 }
845}
846
847#[tessera]
848fn scrollbar_v_bound(
849 controller: Option<State<ScrollableController>>,
850 thickness: Option<Dp>,
851 scrollbar_behavior: Option<ScrollBarBehavior>,
852 track_color: Option<Color>,
853 thumb_color: Option<Color>,
854 thumb_hover_color: Option<Color>,
855 scrollbar_state: Option<ScrollBarState>,
856) {
857 let controller = controller.expect("scrollbar_v_bound requires controller");
858 let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
859 scrollbar_v()
860 .total(controller.with(|c| c.child_size().height))
861 .visible(controller.with(|c| c.visible_size().height))
862 .offset(controller.with(|c| c.child_position().y_px()))
863 .thickness_optional(thickness)
864 .state(controller)
865 .scrollbar_behavior(scrollbar_behavior)
866 .track_color_optional(track_color)
867 .thumb_color_optional(thumb_color)
868 .thumb_hover_color_optional(thumb_hover_color)
869 .scrollbar_state(
870 scrollbar_state.unwrap_or_else(|| ScrollBarState::viewport(controller, true)),
871 );
872}
873
874#[tessera]
875fn scrollbar_h_bound(
876 controller: Option<State<ScrollableController>>,
877 thickness: Option<Dp>,
878 scrollbar_behavior: Option<ScrollBarBehavior>,
879 track_color: Option<Color>,
880 thumb_color: Option<Color>,
881 thumb_hover_color: Option<Color>,
882 scrollbar_state: Option<ScrollBarState>,
883) {
884 let controller = controller.expect("scrollbar_h_bound requires controller");
885 let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
886 scrollbar_h()
887 .total(controller.with(|c| c.child_size().width))
888 .visible(controller.with(|c| c.visible_size().width))
889 .offset(controller.with(|c| c.child_position().x_px()))
890 .thickness_optional(thickness)
891 .state(controller)
892 .scrollbar_behavior(scrollbar_behavior)
893 .track_color_optional(track_color)
894 .thumb_color_optional(thumb_color)
895 .thumb_hover_color_optional(thumb_hover_color)
896 .scrollbar_state(
897 scrollbar_state.unwrap_or_else(|| ScrollBarState::viewport(controller, false)),
898 );
899}
900
901#[tessera]
902fn scrollable_with_alongside_scrollbar(
903 controller: Option<State<ScrollableController>>,
904 vertical: Option<bool>,
905 horizontal: Option<bool>,
906 scroll_smoothing: Option<f32>,
907 apply_child_offset: Option<bool>,
908 scrollbar_behavior: Option<ScrollBarBehavior>,
909 scrollbar_track_color: Option<Color>,
910 scrollbar_thumb_color: Option<Color>,
911 scrollbar_thumb_hover_color: Option<Color>,
912 child: Option<RenderSlot>,
913) {
914 let vertical = vertical.unwrap_or(false);
915 let horizontal = horizontal.unwrap_or(false);
916 let scroll_smoothing = scroll_smoothing.unwrap_or(DEFAULT_SCROLL_SMOOTHING);
917 let apply_child_offset = apply_child_offset.unwrap_or(true);
918 let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
919 let controller = controller.expect("scrollable_with_alongside_scrollbar requires controller");
920 let child = child.unwrap_or_else(RenderSlot::empty);
921 let scrollbar_v_state = ScrollBarState::viewport(controller, true);
922 let scrollbar_h_state = ScrollBarState::viewport(controller, false);
923
924 layout()
925 .policy(ScrollableAlongsideLayout {
926 vertical,
927 horizontal,
928 })
929 .child(move || {
930 scrollable_viewport()
931 .vertical(vertical)
932 .horizontal(horizontal)
933 .scroll_smoothing(scroll_smoothing)
934 .apply_child_offset(apply_child_offset)
935 .scrollbar_behavior(scrollbar_behavior.clone())
936 .controller(controller)
937 .scrollbar_state_v(scrollbar_v_state)
938 .scrollbar_state_h(scrollbar_h_state)
939 .child_shared(child);
940
941 if vertical {
942 scrollbar_v_bound()
943 .controller(controller)
944 .scrollbar_behavior(scrollbar_behavior.clone())
945 .thickness(ScrollbarDefaults::THICKNESS)
946 .track_color_optional(scrollbar_track_color)
947 .thumb_color_optional(scrollbar_thumb_color)
948 .thumb_hover_color_optional(scrollbar_thumb_hover_color)
949 .scrollbar_state(scrollbar_v_state);
950 }
951
952 if horizontal {
953 scrollbar_h_bound()
954 .controller(controller)
955 .scrollbar_behavior(scrollbar_behavior.clone())
956 .thickness(ScrollbarDefaults::THICKNESS)
957 .track_color_optional(scrollbar_track_color)
958 .thumb_color_optional(scrollbar_thumb_color)
959 .thumb_hover_color_optional(scrollbar_thumb_hover_color)
960 .scrollbar_state(scrollbar_h_state);
961 }
962 });
963}
964
965#[tessera]
966fn scrollable_with_overlay_scrollbar(
967 controller: Option<State<ScrollableController>>,
968 vertical: Option<bool>,
969 horizontal: Option<bool>,
970 scroll_smoothing: Option<f32>,
971 apply_child_offset: Option<bool>,
972 scrollbar_behavior: Option<ScrollBarBehavior>,
973 scrollbar_track_color: Option<Color>,
974 scrollbar_thumb_color: Option<Color>,
975 scrollbar_thumb_hover_color: Option<Color>,
976 child: Option<RenderSlot>,
977) {
978 let vertical = vertical.unwrap_or(false);
979 let horizontal = horizontal.unwrap_or(false);
980 let scroll_smoothing = scroll_smoothing.unwrap_or(DEFAULT_SCROLL_SMOOTHING);
981 let apply_child_offset = apply_child_offset.unwrap_or(true);
982 let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
983 let controller = controller.expect("scrollable_with_overlay_scrollbar requires controller");
984 let child = child.unwrap_or_else(RenderSlot::empty);
985
986 boxed()
987 .modifier(Modifier::new().fill_max_size())
988 .alignment(Alignment::BottomEnd)
989 .children(move || {
990 {
991 let child = child;
992 let scrollbar_v_state = ScrollBarState::viewport(controller, true);
993 let scrollbar_h_state = ScrollBarState::viewport(controller, false);
994 let scrollbar_behavior = scrollbar_behavior.clone();
995 scrollable_viewport()
996 .vertical(vertical)
997 .horizontal(horizontal)
998 .scroll_smoothing(scroll_smoothing)
999 .apply_child_offset(apply_child_offset)
1000 .scrollbar_behavior(scrollbar_behavior.clone())
1001 .controller(controller)
1002 .scrollbar_state_v(scrollbar_v_state)
1003 .scrollbar_state_h(scrollbar_h_state)
1004 .child_shared(child);
1005 };
1006 {
1007 let scrollbar_v_state = ScrollBarState::viewport(controller, true);
1008 let scrollbar_behavior = scrollbar_behavior.clone();
1009 if vertical {
1010 scrollbar_v_bound()
1011 .controller(controller)
1012 .scrollbar_behavior(scrollbar_behavior.clone())
1013 .thickness(ScrollbarDefaults::THICKNESS)
1014 .track_color_optional(scrollbar_track_color)
1015 .thumb_color_optional(scrollbar_thumb_color)
1016 .thumb_hover_color_optional(scrollbar_thumb_hover_color)
1017 .scrollbar_state(scrollbar_v_state);
1018 }
1019 };
1020 {
1021 let scrollbar_h_state = ScrollBarState::viewport(controller, false);
1022 let scrollbar_behavior = scrollbar_behavior.clone();
1023 if horizontal {
1024 scrollbar_h_bound()
1025 .controller(controller)
1026 .scrollbar_behavior(scrollbar_behavior.clone())
1027 .thickness(ScrollbarDefaults::THICKNESS)
1028 .track_color_optional(scrollbar_track_color)
1029 .thumb_color_optional(scrollbar_thumb_color)
1030 .thumb_hover_color_optional(scrollbar_thumb_hover_color)
1031 .scrollbar_state(scrollbar_h_state);
1032 }
1033 };
1034 });
1035}
1036
1037struct ScrollSampleConsumption {
1038 child: ScrollDelta,
1039 gesture: ScrollDelta,
1040}
1041
1042struct ScrollInputTarget<'a> {
1043 controller: State<ScrollableController>,
1044 vertical: bool,
1045 horizontal: bool,
1046 viewport: ComputedData,
1047 connection: Option<&'a NestedScrollConnection>,
1048 transform: tessera_ui::LocalToWindow,
1049}
1050
1051impl ScrollInputTarget<'_> {
1052 fn apply(
1053 &self,
1054 event: &mut tessera_ui::ScrollEventContent,
1055 discrete: bool,
1056 ) -> ScrollSampleConsumption {
1057 let connection = self
1058 .connection
1059 .map(|connection| connection.in_space(self.transform));
1060 let available = ScrollDelta::new(event.delta_x, event.delta_y);
1061 let pre = connection
1062 .as_ref()
1063 .map(|connection| connection.pre_scroll(available, event.source))
1064 .unwrap_or(ScrollDelta::ZERO);
1065 let child = self.controller.with_mut(|controller| {
1066 controller.apply_input_delta(
1067 available - pre,
1068 event.source,
1069 discrete,
1070 &self.viewport,
1071 self.vertical,
1072 self.horizontal,
1073 )
1074 });
1075 let available_after_child = available - pre - child;
1076 let post = connection
1077 .as_ref()
1078 .map(|connection| connection.post_scroll(child, available_after_child, event.source))
1079 .unwrap_or(ScrollDelta::ZERO);
1080 let remaining = available_after_child - post;
1081 event.delta_x = remaining.x;
1082 event.delta_y = remaining.y;
1083 ScrollSampleConsumption {
1084 child,
1085 gesture: pre + child + post,
1086 }
1087 }
1088}
1089
1090struct ScrollbarWheelModifierNode {
1091 controller: State<ScrollableController>,
1092 recognizer: State<ScrollRecognizer>,
1093 connection: Option<NestedScrollConnection>,
1094 scrollbar_state: ScrollBarState,
1095 behavior: ScrollBarBehavior,
1096}
1097
1098impl PointerInputModifierNode for ScrollbarWheelModifierNode {
1099 fn on_pointer_input(&self, input: PointerInput<'_>) {
1100 if input.pass != tessera_ui::PointerEventPass::Main
1101 || !input
1102 .cursor_position_rel
1103 .is_some_and(|position| is_position_inside_bounds(input.computed_data, position))
1104 {
1105 return;
1106 }
1107 let (viewport_scale, axes) = self
1108 .controller
1109 .with(|controller| (controller.viewport_scale, controller.viewport_axes));
1110 let Some(axes) = axes else {
1111 return;
1112 };
1113 let target = ScrollInputTarget {
1114 controller: self.controller,
1115 vertical: axes.0,
1116 horizontal: axes.1,
1117 viewport: self.controller.with(|controller| controller.visible_size()),
1118 connection: self.connection.as_ref(),
1119 transform: tessera_ui::LocalToWindow::new(viewport_scale, PxPosition::ZERO)
1120 .expect("Viewport scale is finite and positive"),
1121 };
1122 for change in input.pointer_changes.iter_mut() {
1123 if !matches!(&change.content, tessera_ui::CursorEventContent::Scroll(event)
1124 if event.source == ScrollEventSource::Wheel)
1125 {
1126 continue;
1127 }
1128 let discrete = change.is_discrete_scroll();
1129 self.recognizer.with_mut(|recognizer| {
1130 recognizer.for_each(input.pass, std::slice::from_mut(change), |_, event| {
1131 event.normalize_to_pixels();
1132 let to_viewport = input.local_to_window.scale() / viewport_scale;
1133 event.delta_x *= to_viewport;
1134 event.delta_y *= to_viewport;
1135 let consumption = target.apply(event, discrete);
1136 event.delta_x /= to_viewport;
1137 event.delta_y /= to_viewport;
1138 if !consumption.child.is_zero() && self.behavior == ScrollBarBehavior::AutoHide
1139 {
1140 self.scrollbar_state.with_mut(|state| {
1141 state.last_scroll_activity_frame_nanos = Some(current_frame_nanos());
1142 state.should_be_visible = true;
1143 });
1144 }
1145 });
1146 });
1147 }
1148 }
1149}
1150
1151struct ScrollableViewportPointerModifierNode {
1152 controller: State<ScrollableController>,
1153 vertical: bool,
1154 horizontal: bool,
1155 scrollbar_behavior: ScrollBarBehavior,
1156 scrollbar_state_v: ScrollBarState,
1157 scrollbar_state_h: ScrollBarState,
1158 tap_recognizer: State<TapRecognizer>,
1159 scroll_recognizer: State<ScrollRecognizer>,
1160 nested_scroll_connection: Option<NestedScrollConnection>,
1161}
1162
1163struct ScrollableViewportInputArgs {
1164 base: Modifier,
1165 controller: State<ScrollableController>,
1166 vertical: bool,
1167 horizontal: bool,
1168 scrollbar_behavior: ScrollBarBehavior,
1169 scrollbar_state_v: ScrollBarState,
1170 scrollbar_state_h: ScrollBarState,
1171 tap_recognizer: State<TapRecognizer>,
1172 scroll_recognizer: State<ScrollRecognizer>,
1173 nested_scroll_connection: Option<NestedScrollConnection>,
1174}
1175
1176fn apply_scrollable_viewport_input_modifier(args: ScrollableViewportInputArgs) -> Modifier {
1177 let ScrollableViewportInputArgs {
1178 base,
1179 controller,
1180 vertical,
1181 horizontal,
1182 scrollbar_behavior,
1183 scrollbar_state_v,
1184 scrollbar_state_h,
1185 tap_recognizer,
1186 scroll_recognizer,
1187 nested_scroll_connection,
1188 } = args;
1189 base.push_pointer_input(ScrollableViewportPointerModifierNode {
1190 controller,
1191 vertical,
1192 horizontal,
1193 scrollbar_behavior,
1194 scrollbar_state_v,
1195 scrollbar_state_h,
1196 tap_recognizer,
1197 scroll_recognizer,
1198 nested_scroll_connection,
1199 })
1200}
1201
1202impl PointerInputModifierNode for ScrollableViewportPointerModifierNode {
1203 fn on_pointer_input(&self, input: PointerInput<'_>) {
1204 self.handle_changes(
1205 input.pass,
1206 input.pointer_changes.as_mut_slice(),
1207 input.cursor_position_rel,
1208 input.computed_data,
1209 input.local_to_window,
1210 current_frame_time().unwrap_or_else(Instant::now),
1211 current_frame_nanos(),
1212 );
1213 }
1214}
1215
1216impl ScrollableViewportPointerModifierNode {
1217 fn advance_inertia(
1218 &self,
1219 now: Instant,
1220 viewport: &ComputedData,
1221 transform: tessera_ui::LocalToWindow,
1222 ) {
1223 let connection = self
1224 .nested_scroll_connection
1225 .as_ref()
1226 .map(|connection| connection.in_space(transform));
1227 let step = self.controller.with_mut(|controller| {
1228 let mut inertia = controller.active_inertia.take()?;
1229 let last = controller.inertia_last_tick.replace(now).unwrap_or(now);
1230 let displacement = inertia.advance(now.duration_since(last));
1231 Some((inertia, ScrollDelta::new(displacement.x, displacement.y)))
1232 });
1233 let Some((mut inertia, displacement)) = step else {
1234 return;
1235 };
1236 let pre = connection
1237 .as_ref()
1238 .map(|connection| connection.pre_scroll(displacement, ScrollEventSource::Touch))
1239 .unwrap_or(ScrollDelta::ZERO);
1240 let child = self.controller.with_mut(|controller| {
1241 controller.apply_input_delta(
1242 displacement - pre,
1243 ScrollEventSource::Touch,
1244 false,
1245 viewport,
1246 self.vertical,
1247 self.horizontal,
1248 )
1249 });
1250 let available = displacement - pre - child;
1251 let post = connection
1252 .as_ref()
1253 .map(|connection| connection.post_scroll(child, available, ScrollEventSource::Touch))
1254 .unwrap_or(ScrollDelta::ZERO);
1255 let remaining = available - post;
1256 let target = self
1259 .controller
1260 .with(|controller| controller.target_position_f32);
1261 let tolerance_x = (target.0.abs() * f32::EPSILON * 2.0).max(0.001);
1262 let tolerance_y = (target.1.abs() * f32::EPSILON * 2.0).max(0.001);
1263 let mut velocity = ScrollVelocity::ZERO;
1264 if remaining.x.abs() > tolerance_x {
1265 velocity.x = inertia.velocity_x;
1266 inertia.velocity_x = 0.0;
1267 }
1268 if remaining.y.abs() > tolerance_y {
1269 velocity.y = inertia.velocity_y;
1270 inertia.velocity_y = 0.0;
1271 }
1272 let stop_speed = self.controller.with(|c| c.density as f32) / transform.scale()
1273 * SCROLL_INERTIA_MIN_VELOCITY_DP;
1274 let active =
1275 inertia.velocity_x.abs() >= stop_speed || inertia.velocity_y.abs() >= stop_speed;
1276 self.controller.with_mut(|controller| {
1277 if active {
1278 controller.active_inertia = Some(inertia);
1279 controller.inertia_last_tick = Some(now);
1280 }
1281 });
1282 if !active && let Some(connection) = &connection {
1283 connection.post_fling(velocity);
1284 }
1285 }
1286
1287 fn handle_changes(
1288 &self,
1289 pass: tessera_ui::PointerEventPass,
1290 changes: &mut [tessera_ui::PointerChange],
1291 cursor_position_rel: Option<PxPosition>,
1292 computed_data: ComputedData,
1293 transform: tessera_ui::LocalToWindow,
1294 now: Instant,
1295 frame_nanos: u64,
1296 ) {
1297 if pass != tessera_ui::PointerEventPass::Main {
1298 return;
1299 }
1300 let connection = self
1301 .nested_scroll_connection
1302 .as_ref()
1303 .map(|connection| connection.in_space(transform));
1304 let is_cursor_in_component = cursor_position_rel
1305 .map(|pos| is_position_inside_bounds(computed_data, pos))
1306 .unwrap_or(false);
1307 for change in changes {
1308 let changes = std::slice::from_mut(change);
1309 let should_handle_scroll = is_cursor_in_component
1310 || matches!(
1311 changes[0].content,
1312 tessera_ui::CursorEventContent::Scroll(ref scroll)
1313 if scroll.source == ScrollEventSource::Touch
1314 );
1315 let tap_result = self
1316 .tap_recognizer
1317 .with_mut(|recognizer| recognizer.update(pass, changes, is_cursor_in_component));
1318 if tap_result.pressed {
1319 self.controller.with_mut(|c| {
1320 c.cancel_inertia();
1321 c.target_position_f32 = c.child_position_f32;
1322 c.target_position = c.child_position;
1323 c.velocity_tracker = Some(FoundationVelocityTracker::new(
1324 tap_result.press_timestamp.unwrap_or(now),
1325 SCROLL_VELOCITY_SAMPLE_WINDOW,
1326 SCROLL_VELOCITY_IDLE_CUTOFF,
1327 ));
1328 });
1329 }
1330
1331 if should_handle_scroll {
1334 let discrete = changes[0].is_discrete_scroll();
1335 self.scroll_recognizer.with_mut(|recognizer| {
1336 recognizer.for_each(pass, changes, |context, scroll_event| {
1337 scroll_event.normalize_to_pixels();
1338 let consumption = ScrollInputTarget {
1339 controller: self.controller,
1340 vertical: self.vertical,
1341 horizontal: self.horizontal,
1342 viewport: computed_data,
1343 connection: self.nested_scroll_connection.as_ref(),
1344 transform,
1345 }
1346 .apply(scroll_event, discrete);
1347 let child_consumed = consumption.child;
1348 let gesture_consumed = consumption.gesture;
1349 if scroll_event.source == ScrollEventSource::Touch
1350 && !gesture_consumed.is_zero()
1351 {
1352 self.controller.with_mut(|c| {
1353 let window = transform.map_vector(PxPosition::from_f32_xy(
1354 gesture_consumed.x,
1355 gesture_consumed.y,
1356 ));
1357 c.push_touch_delta(context.timestamp, window.x, window.y);
1358 });
1359 }
1360
1361 if matches!(self.scrollbar_behavior, ScrollBarBehavior::AutoHide)
1362 && !child_consumed.is_zero()
1363 {
1364 if self.vertical {
1365 self.scrollbar_state_v.with_mut(|scrollbar_state| {
1366 scrollbar_state.last_scroll_activity_frame_nanos =
1367 Some(frame_nanos);
1368 scrollbar_state.should_be_visible = true;
1369 });
1370 }
1371 if self.horizontal {
1372 self.scrollbar_state_h.with_mut(|scrollbar_state| {
1373 scrollbar_state.last_scroll_activity_frame_nanos =
1374 Some(frame_nanos);
1375 scrollbar_state.should_be_visible = true;
1376 });
1377 }
1378 }
1379 });
1380 });
1381
1382 let target = self.controller.with(|c| c.target_position());
1383 let child_size = self.controller.with(|c| c.child_size());
1384 let constrained_position = constrain_position(
1385 target,
1386 &child_size,
1387 &computed_data,
1388 self.vertical,
1389 self.horizontal,
1390 );
1391 if target != constrained_position {
1392 self.controller
1393 .with_mut(|c| c.set_target_position(constrained_position));
1394 }
1395 }
1396 let cancelled = changes.iter().any(|change| {
1400 matches!(change.content, tessera_ui::CursorEventContent::Cancelled(_))
1401 });
1402 if cancelled {
1403 self.controller.with_mut(|c| {
1404 c.velocity_tracker = None;
1405 c.cancel_inertia();
1406 });
1407 if let Some(connection) = &connection {
1408 connection.post_fling(ScrollVelocity::ZERO);
1409 }
1410 } else if let Some(release_timestamp) = tap_result.release_timestamp {
1411 let available_velocity = self
1412 .controller
1413 .with_mut(|c| c.resolve_touch_velocity(release_timestamp));
1414 let local = transform.inverse_vector(PxPosition::from_f32_xy(
1415 available_velocity.x,
1416 available_velocity.y,
1417 ));
1418 let available_velocity = ScrollVelocity::new(local.x, local.y);
1419 let consumed_velocity = connection
1420 .as_ref()
1421 .map(|connection| connection.pre_fling(available_velocity))
1422 .unwrap_or(ScrollVelocity::ZERO);
1423 self.controller.with_mut(|c| {
1424 c.start_inertia(release_timestamp, available_velocity - consumed_velocity)
1425 });
1426 if self.controller.with(|c| c.active_inertia.is_none()) {
1427 if let Some(connection) = &connection {
1428 connection.post_fling(ScrollVelocity::ZERO);
1429 }
1430 }
1431 }
1432 }
1433 if self.controller.with(|c| c.active_inertia.is_some()) {
1434 self.advance_inertia(now, &computed_data, transform);
1435 }
1436 }
1437}
1438
1439#[tessera]
1440fn scrollable_viewport(
1441 vertical: Option<bool>,
1442 horizontal: Option<bool>,
1443 scroll_smoothing: Option<f32>,
1444 apply_child_offset: Option<bool>,
1445 scrollbar_behavior: Option<ScrollBarBehavior>,
1446 controller: Option<State<ScrollableController>>,
1447 scrollbar_state_v: Option<ScrollBarState>,
1448 scrollbar_state_h: Option<ScrollBarState>,
1449 child: Option<RenderSlot>,
1450) {
1451 let vertical = vertical.unwrap_or(false);
1452 let horizontal = horizontal.unwrap_or(false);
1453 let scroll_smoothing = scroll_smoothing.unwrap_or(DEFAULT_SCROLL_SMOOTHING);
1454 let apply_child_offset = apply_child_offset.unwrap_or(true);
1455 let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
1456 let controller = controller.expect("scrollable_viewport requires controller");
1457 synchronize_scroll_density(controller);
1458 let scrollbar_state_v =
1459 scrollbar_state_v.unwrap_or_else(|| ScrollBarState::viewport(controller, true));
1460 let scrollbar_state_h =
1461 scrollbar_state_h.unwrap_or_else(|| ScrollBarState::viewport(controller, false));
1462 let child = child.unwrap_or_else(RenderSlot::empty);
1463 let tap_recognizer = remember(TapRecognizer::default);
1464 let scroll_recognizer = remember(ScrollRecognizer::default);
1465 if controller.with(|c| c.has_pending_animation_frame()) {
1466 let smoothing = scroll_smoothing;
1467 receive_frame_nanos(move |frame_nanos| {
1468 let has_pending_animation_frame = controller.with_mut(|c| {
1469 c.update_scroll_position(frame_nanos, smoothing);
1470 c.has_pending_animation_frame()
1471 });
1472 if has_pending_animation_frame {
1473 tessera_ui::FrameNanosControl::Continue
1474 } else {
1475 tessera_ui::FrameNanosControl::Stop
1476 }
1477 });
1478 }
1479 let has_override = controller.with(|c| c.override_child_size.is_some());
1480 let nested_scroll_connection = NestedScrollConnection::nearest();
1481 let modifier = apply_scrollable_viewport_input_modifier(ScrollableViewportInputArgs {
1482 base: Modifier::new(),
1483 controller,
1484 vertical,
1485 horizontal,
1486 scrollbar_behavior: scrollbar_behavior.clone(),
1487 scrollbar_state_v,
1488 scrollbar_state_h,
1489 tap_recognizer,
1490 scroll_recognizer,
1491 nested_scroll_connection,
1492 });
1493 let modifier = modifier.on_layout_coordinates(move |coordinates: LayoutCoordinates| {
1494 let scale = coordinates.local_to_window.scale();
1495 if controller.with(|controller| (controller.viewport_scale, controller.viewport_axes))
1496 != (scale, Some((vertical, horizontal)))
1497 {
1498 controller.with_mut(|controller| {
1499 controller.viewport_scale = scale;
1500 controller.viewport_axes = Some((vertical, horizontal));
1501 });
1502 }
1503 });
1504 let modifier = if vertical || horizontal {
1505 apply_scrollable_focus_reveal_modifier(modifier, controller, vertical, horizontal)
1506 } else {
1507 modifier
1508 };
1509 let modifier = modifier
1510 .clip_to_bounds()
1511 .push_layout(ScrollLayoutModifierNode {
1512 controller,
1513 vertical,
1514 horizontal,
1515 apply_child_offset,
1516 has_override,
1517 });
1518 layout().modifier(modifier).child(move || child.render());
1519}
1520
1521fn apply_scrollable_focus_reveal_modifier(
1522 base: Modifier,
1523 controller: State<ScrollableController>,
1524 vertical: bool,
1525 horizontal: bool,
1526) -> Modifier {
1527 base.focus_reveal_handler(CallbackWith::new(move |request: FocusRevealRequest| {
1528 let target = request.local_target_bounds();
1529 let viewport = request.local_viewport_bounds();
1530 let (current_position, child_size, visible_size) =
1531 controller.with(|c| (c.child_position(), c.child_size(), c.visible_size()));
1532 let mut desired_position = current_position;
1533
1534 if horizontal {
1535 desired_position.x = reveal_axis_position(
1536 current_position.x,
1537 target.x,
1538 target.x + target.width,
1539 viewport.x,
1540 viewport.x + viewport.width,
1541 );
1542 }
1543
1544 if vertical {
1545 desired_position.y = reveal_axis_position(
1546 current_position.y,
1547 target.y,
1548 target.y + target.height,
1549 viewport.y,
1550 viewport.y + viewport.height,
1551 );
1552 }
1553
1554 let constrained_position = constrain_position(
1555 desired_position,
1556 &child_size,
1557 &visible_size,
1558 vertical,
1559 horizontal,
1560 );
1561 if constrained_position == current_position {
1562 return false;
1563 }
1564
1565 controller.with_mut(|c| c.set_scroll_position(constrained_position));
1566 true
1567 }))
1568}
1569
1570fn reveal_axis_position(
1571 current: f32,
1572 target_start: f32,
1573 target_end: f32,
1574 viewport_start: f32,
1575 viewport_end: f32,
1576) -> f32 {
1577 if target_start < viewport_start {
1578 current + (viewport_start - target_start)
1579 } else if target_end > viewport_end {
1580 current - (target_end - viewport_end)
1581 } else {
1582 current
1583 }
1584}
1585
1586fn constrain_axis(pos: f32, child_len: Px, container_len: Px) -> f32 {
1587 let child_len = child_len.0 as f32;
1588 let container_len = container_len.0 as f32;
1589 if child_len <= container_len {
1590 return 0.0;
1591 }
1592
1593 if pos > 0.0 {
1594 0.0
1595 } else if pos + child_len < container_len {
1596 container_len - child_len
1597 } else {
1598 pos
1599 }
1600}
1601
1602fn constrain_position(
1603 position: PxPosition,
1604 child_size: &ComputedData,
1605 container_size: &ComputedData,
1606 vertical_scrollable: bool,
1607 horizontal_scrollable: bool,
1608) -> PxPosition {
1609 let x = if horizontal_scrollable {
1610 constrain_axis(position.x, child_size.width, container_size.width)
1611 } else {
1612 0.0
1613 };
1614
1615 let y = if vertical_scrollable {
1616 constrain_axis(position.y, child_size.height, container_size.height)
1617 } else {
1618 0.0
1619 };
1620
1621 PxPosition::from_f32_xy(x, y)
1622}
1623
1624#[cfg(test)]
1625mod scroll_tests {
1626 use super::*;
1627 fn controller() -> ScrollableController {
1628 let mut controller = ScrollableController::new();
1629 controller.child_size = ComputedData {
1630 width: Px(100),
1631 height: Px(2000),
1632 };
1633 controller.visible_size = ComputedData {
1634 width: Px(100),
1635 height: Px(100),
1636 };
1637 controller
1638 }
1639 #[test]
1640 fn extent_reconciliation_preserves_valid_interpolation_and_inertia() {
1641 let mut scroll = controller();
1642 scroll.set_scroll_position(PxPosition::from_f32_xy(0.0, -200.5));
1643 scroll.animation_position.1 = -200.500_000_001;
1644 scroll.set_target_position(PxPosition::from_f32_xy(0.0, -300.75));
1645 let now = Instant::now();
1646 scroll.start_inertia(now, ScrollVelocity::new(0.0, -120.5));
1647 scroll.last_frame_nanos = Some(16_000_000);
1648 let animation = scroll.animation_position;
1649 let inertia = scroll.active_inertia;
1650 let viewport = scroll.visible_size;
1651 scroll.reconcile_layout_extents(
1652 ComputedData {
1653 width: Px(100),
1654 height: Px(1800),
1655 },
1656 viewport,
1657 true,
1658 false,
1659 );
1660 assert_eq!(scroll.animation_position, animation);
1661 assert_eq!(scroll.child_position().y, -200.5);
1662 assert_eq!(scroll.target_position().y, -300.75);
1663 assert_eq!(scroll.active_inertia, inertia);
1664 assert_eq!(scroll.inertia_last_tick, Some(now));
1665 assert_eq!(scroll.last_frame_nanos, Some(16_000_000));
1666 assert!(scroll.has_pending_animation_frame());
1667 }
1668
1669 #[test]
1670 fn override_extent_reconciliation_clamps_disabled_axis_and_preserves_boundary_velocity() {
1671 let mut scroll = controller();
1672 scroll.set_scroll_position(PxPosition::from_f32_xy(-12.25, -1500.5));
1673 scroll.set_target_position(PxPosition::from_f32_xy(-30.75, -1900.25));
1674 let now = Instant::now();
1675 scroll.start_inertia(now, ScrollVelocity::new(-40.0, -120.5));
1676 scroll.override_child_size(ComputedData {
1677 width: Px(100),
1678 height: Px(800),
1679 });
1680 let (child, viewport) = (scroll.child_size, scroll.visible_size);
1681 assert!(
1682 scroll.needs_layout_reconciliation(child, viewport, true, false),
1683 "An already-published virtual extent must still invalidate out-of-range offsets"
1684 );
1685 scroll.reconcile_layout_extents(child, viewport, true, false);
1686 assert_eq!(
1687 scroll.child_position(),
1688 PxPosition::from_f32_xy(0.0, -700.0)
1689 );
1690 assert_eq!(
1691 scroll.target_position(),
1692 PxPosition::from_f32_xy(0.0, -700.0)
1693 );
1694 assert_eq!(scroll.animation_position, (0.0, -700.0));
1695 let inertia = scroll
1696 .active_inertia
1697 .expect("Boundary velocity remains available for nested dispatch");
1698 assert_eq!((inertia.position_x, inertia.position_y), (0.0, -700.0));
1699 assert_eq!((inertia.velocity_x, inertia.velocity_y), (-40.0, -120.5));
1700 assert_eq!(scroll.inertia_last_tick, Some(now));
1701 assert!(!scroll.needs_layout_reconciliation(child, viewport, true, false));
1702 }
1703
1704 #[test]
1705 fn pixel_input_accumulates_subpixels_without_smoothing() {
1706 let mut controller = controller();
1707 let viewport = controller.visible_size;
1708 for _ in 0..20 {
1709 controller.apply_input_delta(
1710 ScrollDelta::new(0.0, -0.1),
1711 ScrollEventSource::Wheel,
1712 false,
1713 &viewport,
1714 true,
1715 false,
1716 );
1717 }
1718 assert!((controller.child_position_f32.1 + 2.0).abs() < 0.0001);
1719 assert!(!controller.has_pending_animation_frame());
1720 }
1721 #[test]
1722 fn smoothing_does_not_snap_when_one_frame_rounds_to_same_pixel() {
1723 let mut controller = controller();
1724 controller.set_target_position(PxPosition::new(Px(0), Px(-100)));
1725 controller.last_frame_nanos = Some(0);
1726 controller.update_scroll_position(1_000_000, 0.99);
1727 assert!(controller.child_position_f32.1 > -1.0);
1728 assert!(controller.has_pending_animation_frame());
1729 for frame in 1..2000 {
1730 controller.update_scroll_position(frame * 16_666_667, 0.99);
1731 }
1732 assert!(!controller.has_pending_animation_frame());
1733 assert_eq!(controller.child_position.y, -100.0);
1734 }
1735
1736 #[test]
1737 fn translating_layout_origin_preserves_smoothing_and_idle_state() {
1738 let mut original = controller();
1739 original.set_scroll_position(PxPosition::from_f32_xy(-100.25, -200.5));
1740 original.set_target_position(PxPosition::from_f32_xy(-300.25, -500.5));
1741 original.update_scroll_position(16_666_667, 0.75);
1742 let mut translated = original.clone();
1743 let delta = PxPosition::new(Px(-80), Px(-120));
1744 translated.translate_scroll_position(delta);
1745 for frame in 2..100 {
1746 original.update_scroll_position(frame * 16_666_667, 0.75);
1747 translated.update_scroll_position(frame * 16_666_667, 0.75);
1748 let expected = original.child_position() + delta;
1749 let actual = translated.child_position();
1750 assert!((actual.x - expected.x).abs() < 0.0001);
1751 assert!((actual.y - expected.y).abs() < 0.0001);
1752 assert_eq!(
1753 translated.has_pending_animation_frame(),
1754 original.has_pending_animation_frame()
1755 );
1756 }
1757 assert_eq!(
1758 translated.child_position(),
1759 PxPosition::from_f32_xy(-380.25, -620.5)
1760 );
1761 translated.translate_scroll_position(PxPosition::new(Px(80), Px(120)));
1762 assert_eq!(translated.child_position(), original.child_position());
1763 assert!(!translated.has_pending_animation_frame());
1764 }
1765
1766 #[test]
1767 fn density_changes_scale_animation_distance_and_inertial_motion() {
1768 let mut original = controller();
1769 original.set_scroll_position(PxPosition::from_f32_xy(-100.25, -200.5));
1770 original.set_target_position(PxPosition::from_f32_xy(-300.25, -500.5));
1771 original.update_scroll_position(16_666_667, 0.75);
1772 let now = Instant::now();
1773 original.start_inertia(now, ScrollVelocity::new(120.0, -240.0));
1774 let mut scaled = original.clone();
1775 scaled.update_density(original.density * 1.5);
1776 assert_eq!(scaled.last_frame_nanos, original.last_frame_nanos);
1777 assert_eq!(scaled.inertia_last_tick, Some(now));
1778 assert_eq!(
1779 scaled.target_position(),
1780 PxPosition::from_f32_xy(-450.375, -750.75)
1781 );
1782 let remaining = original.target_position().y - original.child_position().y;
1783 let scaled_remaining = scaled.target_position().y - scaled.child_position().y;
1784 assert!((scaled_remaining - remaining * 1.5).abs() < 0.0001);
1785 let mut original_motion = original.active_inertia.expect("active inertia");
1786 let motion = scaled.active_inertia.as_mut().expect("scaled inertia");
1787 assert_eq!(motion.velocity_x, 180.0);
1788 assert_eq!(motion.velocity_y, -360.0);
1789 let before = original_motion.advance(Duration::from_millis(32));
1790 let after = motion.advance(Duration::from_millis(32));
1791 assert!((after.x - before.x * 1.5).abs() < 0.0001);
1792 assert!((after.y - before.y * 1.5).abs() < 0.0001);
1793 scaled.update_density(original.density);
1794 assert_eq!(scaled.child_position(), original.child_position());
1795 assert_eq!(scaled.target_position(), original.target_position());
1796 assert!(scaled.has_pending_animation_frame());
1797 }
1798
1799 #[test]
1800 fn large_virtual_offsets_reach_the_target_and_stop_the_animation_clock() {
1801 for smoothing in [0.75, 0.99] {
1802 let mut controller = controller();
1803 controller.set_scroll_position(PxPosition::from_f32_xy(-765_072.0, -1_250_500.0));
1804 let target = PxPosition::from_f32_xy(-765_576.0, -1_251_004.0);
1805 controller.set_target_position(target);
1806 for frame in 1..2000 {
1807 controller.update_scroll_position(frame * 16_666_667, smoothing);
1808 }
1809 assert_eq!(controller.child_position(), target);
1810 assert!(!controller.has_pending_animation_frame());
1811 }
1812 }
1813
1814 #[test]
1815 fn overridden_child_extent_allows_scrolling_past_measured_child() {
1816 let mut controller = controller();
1817 controller.child_size.height = controller.visible_size.height;
1818 controller.override_child_size(ComputedData {
1819 width: controller.child_size.width,
1820 height: Px(2000),
1821 });
1822 let viewport = controller.visible_size;
1823 controller.apply_input_delta(
1824 ScrollDelta::new(0.0, -300.0),
1825 ScrollEventSource::Wheel,
1826 true,
1827 &viewport,
1828 true,
1829 false,
1830 );
1831 assert!(controller.target_position.y < 0.0);
1832 }
1833 #[test]
1834 fn touch_and_programmatic_position_interrupt_inertia() {
1835 let mut controller = controller();
1836 let viewport = controller.visible_size;
1837 let now = Instant::now();
1838 controller.start_inertia(now, ScrollVelocity::new(0.0, -1000.0));
1839 controller.apply_input_delta(
1840 ScrollDelta::new(0.0, -2.0),
1841 ScrollEventSource::Touch,
1842 false,
1843 &viewport,
1844 true,
1845 false,
1846 );
1847 assert!(controller.active_inertia.is_none());
1848 controller.start_inertia(now, ScrollVelocity::new(0.0, -1000.0));
1849 controller.set_scroll_position(PxPosition::ZERO);
1850 assert!(!controller.has_pending_animation_frame());
1851 }
1852 #[test]
1853 fn holding_still_does_not_start_inertia_and_release_has_no_velocity() {
1854 let mut controller = controller();
1855 let now = Instant::now();
1856 controller.push_touch_delta(now, 0.0, -10.0);
1857 controller.push_touch_delta(now + Duration::from_millis(10), 0.0, -10.0);
1858 assert!(controller.active_inertia.is_none());
1859 assert!(
1860 controller
1861 .resolve_touch_velocity(now + Duration::from_millis(100))
1862 .is_zero()
1863 );
1864 }
1865
1866 #[test]
1867 fn fling_launch_threshold_and_cap_use_the_same_logical_speed_at_each_density() {
1868 let start = Instant::now();
1869 let sample = start + Duration::from_millis(10);
1870 for density in [1.0, 3.0] {
1871 let mut controller = controller();
1872 controller.update_density(density);
1873 controller.push_touch_delta(start, 0.0, 0.0);
1874 controller.push_touch_delta(sample, 0.0, -0.4 * density as f32);
1875 assert!(controller.resolve_touch_velocity(sample).is_zero());
1876
1877 controller.push_touch_delta(start, 0.0, 0.0);
1878 controller.push_touch_delta(sample, 0.0, -100.0 * density as f32);
1879 let logical = controller.resolve_touch_velocity(sample).y / density as f32;
1880 assert!(
1881 (logical + 6000.0).abs() < 0.01,
1882 "Logical fling speed: {logical}"
1883 );
1884 }
1885 }
1886
1887 #[test]
1888 fn line_wheel_steps_reach_the_top_from_the_bottom() {
1889 let mut controller = controller();
1890 let viewport = controller.visible_size;
1891 controller.set_scroll_position(PxPosition::new(Px(0), Px(-1900)));
1892 let mut frame = 0u64;
1893 let mut steps = 0;
1894 while controller.child_position.y != 0.0 && steps < 200 {
1895 controller.apply_input_delta(
1896 ScrollDelta::new(0.0, 40.0),
1897 ScrollEventSource::Wheel,
1898 true,
1899 &viewport,
1900 true,
1901 false,
1902 );
1903 steps += 1;
1904 for _ in 0..30 {
1905 frame += 1;
1906 controller.update_scroll_position(frame * 16_666_667, DEFAULT_SCROLL_SMOOTHING);
1907 }
1908 }
1909 assert_eq!(
1910 controller.child_position.y,
1911 0.0,
1912 "steps={steps} current={:?} target={:?} target_f32={:?} pending={}",
1913 controller.child_position,
1914 controller.target_position,
1915 controller.target_position_f32,
1916 controller.has_pending_animation_frame()
1917 );
1918 }
1919
1920 #[test]
1921 fn pixel_overshoot_reaches_the_top_from_the_bottom() {
1922 let mut controller = controller();
1923 let viewport = controller.visible_size;
1924 controller.set_scroll_position(PxPosition::new(Px(0), Px(-1900)));
1925 controller.apply_input_delta(
1926 ScrollDelta::new(0.0, 1900.5),
1927 ScrollEventSource::Wheel,
1928 true,
1929 &viewport,
1930 true,
1931 false,
1932 );
1933 for frame in 1..=60u64 {
1934 controller.update_scroll_position(frame * 16_666_667, DEFAULT_SCROLL_SMOOTHING);
1935 }
1936 assert_eq!(
1937 controller.child_position.y, 0.0,
1938 "current={:?}",
1939 controller.child_position
1940 );
1941 assert!(!controller.has_pending_animation_frame());
1942 }
1943
1944 #[test]
1945 fn discrete_wheel_notch_eases_towards_its_target() {
1946 let mut controller = controller();
1947 let viewport = controller.visible_size;
1948 controller.apply_input_delta(
1949 ScrollDelta::new(0.0, -120.0),
1950 ScrollEventSource::Wheel,
1951 true,
1952 &viewport,
1953 true,
1954 false,
1955 );
1956 assert_eq!(controller.child_position.y, 0.0);
1958 assert!(controller.has_pending_animation_frame());
1959
1960 let mut travelled = Vec::new();
1961 for frame in 1..=4u64 {
1962 controller.update_scroll_position(frame * 16_666_667, DEFAULT_SCROLL_SMOOTHING);
1963 travelled.push(controller.child_position.y);
1964 }
1965 assert!(travelled[0] < 0.0 && travelled[0] > -120.0, "{travelled:?}");
1967 assert!(
1968 travelled.windows(2).all(|pair| pair[1] <= pair[0]),
1969 "scroll must stay monotonic: {travelled:?}"
1970 );
1971 assert!(
1972 travelled[3] < travelled[0],
1973 "later frames must keep easing: {travelled:?}"
1974 );
1975
1976 for frame in 5..=60u64 {
1977 controller.update_scroll_position(frame * 16_666_667, DEFAULT_SCROLL_SMOOTHING);
1978 }
1979 assert_eq!(controller.child_position.y, -120.0);
1980 assert!(!controller.has_pending_animation_frame());
1981 }
1982
1983 #[test]
1984 fn continuous_trackpad_delta_keeps_tracking_directly() {
1985 let mut controller = controller();
1986 let viewport = controller.visible_size;
1987 controller.apply_input_delta(
1988 ScrollDelta::new(0.0, -80.0),
1989 ScrollEventSource::Wheel,
1990 false,
1991 &viewport,
1992 true,
1993 false,
1994 );
1995 assert_eq!(controller.child_position.y, -80.0);
1996 assert!(!controller.has_pending_animation_frame());
1997 }
1998
1999 #[test]
2000 fn fresh_viewport_is_not_positioned_until_it_scrolls() {
2001 let controller = controller();
2002 assert!(!controller.is_positioned());
2003 }
2004
2005 #[test]
2006 fn viewport_stays_positioned_after_scrolling_back_to_the_top() {
2007 let mut controller = controller();
2008 let viewport = controller.visible_size;
2009 controller.apply_input_delta(
2010 ScrollDelta::new(0.0, -500.0),
2011 ScrollEventSource::Wheel,
2012 true,
2013 &viewport,
2014 true,
2015 false,
2016 );
2017 let mut frame = 0u64;
2018 for _ in 0..40 {
2019 frame += 1;
2020 controller.update_scroll_position(frame * 16_666_667, DEFAULT_SCROLL_SMOOTHING);
2021 }
2022 assert!(controller.is_positioned());
2023 assert_eq!(controller.child_position.y, -500.0);
2024 controller.apply_input_delta(
2025 ScrollDelta::new(0.0, 500.0),
2026 ScrollEventSource::Wheel,
2027 true,
2028 &viewport,
2029 true,
2030 false,
2031 );
2032 for _ in 0..40 {
2033 frame += 1;
2034 controller.update_scroll_position(frame * 16_666_667, DEFAULT_SCROLL_SMOOTHING);
2035 }
2036 assert_eq!(controller.child_position(), PxPosition::ZERO);
2037 assert!(
2038 controller.is_positioned(),
2039 "a viewport at the top must not look like a fresh one"
2040 );
2041 }
2042
2043 mod scroll_modifier_api {
2044
2045 use tessera_ui::{
2046 Dp, Modifier, Px, PxPosition, State,
2047 layout::layout,
2048 remember, tessera,
2049 testing::{layout_test, pump_layout_test_session, start_layout_test_session},
2050 };
2051
2052 use crate::{
2053 column::column,
2054 modifier::{ModifierExt as _, SemanticsArgs},
2055 spacer::spacer,
2056 };
2057
2058 use super::ScrollableController;
2059
2060 #[tessera]
2061 fn scroll_modifier_case(
2062 output: tessera_ui::testing::TestProbe<Option<State<ScrollableController>>>,
2063 ) {
2064 let controller = remember(ScrollableController::new);
2065 output.set(Some(controller));
2066 layout()
2067 .modifier(Modifier::new().vertical_scroll(controller))
2068 .child(move || {
2069 column().children(move || {
2070 spacer().modifier(Modifier::new().height(Dp(400.0)).semantics(
2071 SemanticsArgs {
2072 test_tag: Some("inner".to_string()),
2073 ..Default::default()
2074 },
2075 ));
2076 });
2077 });
2078 }
2079
2080 #[test]
2081 fn cached_viewport_publishes_updated_scroll_extent() {
2082 let output = tessera_ui::testing::TestProbe::default();
2083 let probe = output.clone();
2084 let mut session = start_layout_test_session(
2085 layout_test(move || {
2086 scroll_modifier_case(probe.clone());
2087 })
2088 .viewport_px(100, 100),
2089 );
2090 let _ = pump_layout_test_session(&mut session, 0);
2091 let controller = output.get().expect("scroll controller");
2092 controller.with_mut(|c| {
2093 c.override_child_size(tessera_ui::ComputedData {
2094 width: Px(100),
2095 height: Px(400),
2096 });
2097 });
2098 let _ = pump_layout_test_session(&mut session, 16_666_667);
2099 controller.with_mut(|c| {
2100 c.override_child_size(tessera_ui::ComputedData {
2101 width: Px(100),
2102 height: Px(800),
2103 });
2104 });
2105 let _ = pump_layout_test_session(&mut session, 33_333_334);
2106 assert_eq!(controller.with(|c| c.child_size().height), Px(800));
2107 controller.with_mut(|c| c.set_scroll_position(PxPosition::new(Px(0), Px(-650))));
2108 let snapshot = pump_layout_test_session(&mut session, 50_000_001);
2109 snapshot.node("inner").y(-650);
2110 }
2111
2112 #[test]
2113 fn vertical_scroll_modifier_reapplies_offset() {
2114 let output = tessera_ui::testing::TestProbe::default();
2115 let probe = output.clone();
2116 let mut session = start_layout_test_session(
2117 layout_test(move || {
2118 scroll_modifier_case(probe.clone());
2119 })
2120 .viewport_px(100, 100),
2121 );
2122 let snapshot = pump_layout_test_session(&mut session, 0);
2123 snapshot.node("inner").y(0);
2124
2125 let controller = output.get().expect("scroll controller");
2126 controller.with_mut(|c| {
2127 c.set_scroll_position(PxPosition::new(Px(0), Px(-40)));
2128 });
2129
2130 let snapshot = pump_layout_test_session(&mut session, 16_666_667);
2131 snapshot.node("inner").y(-40);
2132 }
2133 }
2134}
2135
2136#[cfg(all(test, not(target_family = "wasm")))]
2137#[path = "scrollable_focus_tests.rs"]
2138mod focus_tests;
2139
2140#[cfg(all(test, not(target_family = "wasm")))]
2141#[path = "scrollable_extent_tests.rs"]
2142mod extent_tests;