use crate::prelude::*; use std::time::Instant; pub struct ScrollArea { inner: StrongWidget, ctl: ScrollController, container_len: f32, content_len: Option, } impl Scrollable for ScrollArea { fn controller(&self) -> &ScrollController { &self.ctl } fn controller_mut(&mut self) -> &mut ScrollController { &mut self.ctl } } impl Widget for ScrollArea { fn tick(&mut self, now: Instant) -> bool { self.tick_fling(now) } fn draw(&mut self, painter: &mut Painter) { let axis = self.ctl.axis(); let container_len = painter.px_size().axis(axis); self.container_len = container_len; self.ctl.set_density(painter.density()); let delta = self.ctl.take_delta(); let travelled = self.ctl.amt() - delta; self.ctl.set_amt(travelled); let hint = self.content_len.unwrap_or(container_len); let used = painter .widget_within(&self.inner, self.child_region(hint)) .size(); let measured = used .axis(axis) .apply_rest(painter.density()) .to_abs(container_len); self.content_len = Some(measured); let range = (measured - container_len).max(0.0); let amt = if self.ctl.pinned_to_end() && delta == 0.0 { range } else { travelled.clamp(0.0, range) }; self.ctl.set_amt(amt); self.ctl.set_pinned_to_end(amt >= range); self.ctl.set_travel(Travel { back: amt, fwd: range - amt, }); let size = painter .place(&self.inner, self.child_region(measured)) .size(); painter.set_size(size); } } impl ScrollArea { pub fn new(inner: StrongWidget, axis: Axis, pin: Pin) -> Self { Self { inner, ctl: ScrollController::new(Dir::new(axis, Sign::Pos), pin), container_len: 0.0, content_len: None, } } fn child_region(&self, content_len: f32) -> UiRegion { let axis = self.ctl.axis(); let mut region = UiRegion::FULL; region.axis_mut(axis).end = region.axis(axis).start.offset(content_len); region.offset(Vec2::from_axis(axis, -self.ctl.amt(), 0.0)) } } #[cfg(test)] mod tests { use super::*; use crate::layout_tests::TestRsc; use crate::sense::{CursorButton, DRAG_SLOP, PointerRequests}; use iris_core::UiData; use std::time::Duration; fn area() -> (Fixture, WidgetId) { area_on(Axis::Y) } fn area_on(axis: Axis) -> (Fixture, WidgetId) { let mut rsc = TestRsc { ui: UiData::default(), }; let fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)).any(); let id = fill.id(); let long = Some(Len::abs(1000.0)); let tall = rsc.ui.widgets.add_strong(Sized { inner: fill, x: (axis == Axis::X).then_some(long).flatten(), y: (axis == Axis::Y).then_some(long).flatten(), }); let area = rsc .ui .widgets .add_strong(ScrollArea::new(tall.any(), axis, Pin::Start)); let weak = area.weak(); let root = area.any(); let mut render = UiRenderState::new(); render.resize((100.0, 100.0)); render.update(&root, &mut rsc); let mut fixture = Fixture { rsc, area: weak, root, render, }; fixture.get().scroll(-400.0); fixture.draw(); assert!((fixture.amt() - 400.0).abs() < 0.01); (fixture, id) } struct Fixture { rsc: TestRsc, area: WeakWidget, root: StrongWidget, render: UiRenderState, } impl Fixture { fn get(&mut self) -> &mut ScrollArea { self.rsc.ui.widgets.get_mut(&self.area).unwrap() } fn draw(&mut self) { self.render.update(&self.root, &mut self.rsc); } fn amt(&self) -> f32 { self.rsc.ui.widgets.get(&self.area).unwrap().amt() } fn fling_frame(&mut self, now: Instant) -> bool { let still = self.get().tick(now); self.draw(); still } } fn press(f: &mut Fixture, id: WidgetId, sense: CursorSense, y: f32, t: Instant) { drag(f, id, sense, Vec2::new(0.0, y), t); } fn drag(f: &mut Fixture, id: WidgetId, sense: CursorSense, pos: Vec2, t: Instant) { let pointer = PointerRequests::default(); let flung = f.get().drag(&pointer, id, sense, pos, t); if flung { let id = f.area.id(); f.rsc.ui.animate(id); } f.draw(); } #[test] fn a_vertical_finger_drag_pans_the_content_with_the_finger() { let (mut f, id) = area(); let t = Instant::now(); press( &mut f, id, CursorSense::PressStart(CursorButton::Left), 0.0, t, ); press( &mut f, id, CursorSense::Pressing(CursorButton::Left), DRAG_SLOP + 30.0, t + Duration::from_millis(20), ); assert!( (f.amt() - 370.0).abs() < 0.01, "expected the 30px past the slop to be applied downward, got amt={}", f.amt() ); press( &mut f, id, CursorSense::Pressing(CursorButton::Left), DRAG_SLOP + 50.0, t + Duration::from_millis(40), ); assert!((f.amt() - 350.0).abs() < 0.01, "amt={}", f.amt()); } #[test] fn a_press_that_stays_inside_the_slop_does_not_scroll() { let (mut f, id) = area(); let t = Instant::now(); press( &mut f, id, CursorSense::PressStart(CursorButton::Left), 0.0, t, ); for (i, y) in [1.0, -2.0, DRAG_SLOP - 0.5].into_iter().enumerate() { press( &mut f, id, CursorSense::Pressing(CursorButton::Left), y, t + Duration::from_millis(10 * (i as u64 + 1)), ); } press( &mut f, id, CursorSense::PressEnd(CursorButton::Left), DRAG_SLOP - 0.5, t + Duration::from_millis(50), ); assert!( (f.amt() - 400.0).abs() < 0.01, "a tap scrolled: amt={}", f.amt() ); } #[test] fn a_horizontal_drag_does_not_scroll() { let (mut f, id) = area(); let t = Instant::now(); drag( &mut f, id, CursorSense::PressStart(CursorButton::Left), Vec2::new(0.0, 0.0), t, ); drag( &mut f, id, CursorSense::Pressing(CursorButton::Left), Vec2::new(120.0, 3.0), t + Duration::from_millis(20), ); assert!((f.amt() - 400.0).abs() < 0.01, "amt={}", f.amt()); } #[test] fn a_pan_past_the_end_clamps_instead_of_running_off() { let (mut f, id) = area(); let t = Instant::now(); drag( &mut f, id, CursorSense::PressStart(CursorButton::Left), Vec2::new(0.0, 0.0), t, ); drag( &mut f, id, CursorSense::Pressing(CursorButton::Left), Vec2::new(0.0, 5000.0), t + Duration::from_millis(20), ); assert!((f.amt() - 0.0).abs() < 0.01, "amt={}", f.amt()); } #[test] fn a_released_pan_flings_and_settles() { for axis in [Axis::X, Axis::Y] { let (mut f, id) = area_on(axis); let t = Instant::now(); let at = |d: f32| Vec2::from_axis(axis, d, 0.0); drag( &mut f, id, CursorSense::PressStart(CursorButton::Left), at(0.0), t, ); for (i, d) in [-40.0, -100.0, -180.0, -280.0].into_iter().enumerate() { drag( &mut f, id, CursorSense::Pressing(CursorButton::Left), at(d), t + Duration::from_millis(8 * (i as u64 + 1)), ); } let at_release = f.amt(); drag( &mut f, id, CursorSense::PressEnd(CursorButton::Left), at(-280.0), t + Duration::from_millis(32), ); assert!( f.get().is_scrolling(), "{axis:?}: a released pan with velocity must fling" ); let mut last_step = f32::INFINITY; let mut ticks = 0; let mut now = t + Duration::from_millis(32); while f.fling_frame(now) { let before = f.amt(); now += Duration::from_millis(8); f.fling_frame(now); let step = (f.amt() - before).abs(); assert!( step <= last_step + 0.01, "{axis:?}: the fling sped up: {last_step} then {step}" ); last_step = step; ticks += 1; assert!(ticks < 10_000, "{axis:?}: the fling never settled"); } assert!( f.amt() > at_release, "{axis:?}: the fling moved the content the wrong way: {at_release} -> {}", f.amt() ); } } #[test] fn a_fling_stops_at_the_end_of_the_content() { for (velocity, wall) in [(50_000.0f32, 0.0f32), (-50_000.0, 900.0)] { let (mut f, _id) = area(); f.get().fling(velocity); let t = Instant::now(); let mut now = t; for _ in 0..1_000 { if !f.fling_frame(now) { break; } now += Duration::from_millis(8); } assert!( !f.get().is_scrolling(), "the fling toward {wall} ran past the content" ); assert!( (f.amt() - wall).abs() < 0.01, "it should have settled on {wall}, got amt={}", f.amt() ); } } #[test] fn a_press_on_a_coasting_area_catches_it() { let (mut f, id) = area(); f.get().fling(-4_000.0); let t = Instant::now(); f.fling_frame(t); f.fling_frame(t + Duration::from_millis(8)); let caught_at = f.amt(); assert!(f.get().is_scrolling(), "the fixture must still be moving"); let down = t + Duration::from_millis(16); drag( &mut f, id, CursorSense::PressStart(CursorButton::Left), Vec2::new(0.0, 0.0), down, ); assert!(!f.get().is_scrolling(), "a touch-down must end the fling"); assert!( (f.amt() - caught_at).abs() < 0.01, "the down itself must not move the content, only stop it" ); drag( &mut f, id, CursorSense::Pressing(CursorButton::Left), Vec2::new(0.0, 2.0), down + Duration::from_millis(8), ); assert!( (f.amt() - (caught_at - 2.0)).abs() < 0.01, "a caught press must pan from its first sample: {} -> {}", caught_at, f.amt() ); } }