1use std::{
12 cell::{Cell, RefCell},
13 collections::HashMap,
14 hash::{DefaultHasher, Hash, Hasher},
15 rc::{Rc, Weak},
16};
17
18use cranpose_core::{MutableState, NodeId};
19use cranpose_foundation::{
20 Constraints, DelegatableNode, LayoutModifierNode, Measurable, ModifierNode,
21 ModifierNodeContext, ModifierNodeElement, NodeCapabilities, NodeState,
22};
23use cranpose_ui_graphics::Size;
24use cranpose_ui_layout::LayoutModifierMeasureResult;
25
26#[derive(Clone, Copy)]
34pub struct ScrollState {
35 value: MutableState<f32>,
36 inner: MutableState<Rc<ScrollStateInner>>,
37}
38
39pub(crate) struct ScrollStateInner {
40 max_value: RefCell<f32>,
41 viewport_extent: RefCell<f32>,
45 invalidate_callbacks: RefCell<HashMap<u64, Rc<dyn Fn()>>>,
46 next_invalidate_callback_id: Cell<u64>,
47 pending_invalidation: Cell<bool>,
48 settle_policy: RefCell<Option<ScrollSettlePolicy>>,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq)]
53pub struct ScrollMetrics {
54 pub offset: f32,
56 pub max_offset: f32,
58 pub viewport_extent: f32,
60}
61
62impl ScrollMetrics {
63 pub fn content_extent(self) -> f32 {
65 self.viewport_extent + self.max_offset
66 }
67
68 pub fn progress(self) -> f32 {
70 if self.max_offset <= 0.0 {
71 0.0
72 } else {
73 (self.offset / self.max_offset).clamp(0.0, 1.0)
74 }
75 }
76
77 pub fn thumb(
80 self,
81 bounds: crate::scrollbar::ThumbBounds,
82 ) -> Option<crate::scrollbar::ThumbGeometry> {
83 crate::scrollbar::thumb_geometry(
84 self.content_extent(),
85 self.viewport_extent,
86 self.offset,
87 bounds,
88 )
89 }
90}
91
92pub type ScrollSettlePolicy = Rc<dyn Fn(f32, f32) -> f32>;
100
101#[derive(Clone)]
102pub(crate) struct OverscrollEffect {
103 inner: Rc<OverscrollEffectInner>,
104}
105
106struct OverscrollEffectInner {
107 offset: Cell<f32>,
108 limit: Cell<f32>,
109 invalidate_callbacks: RefCell<HashMap<u64, Rc<dyn Fn()>>>,
110 next_callback_id: Cell<u64>,
111}
112
113impl OverscrollEffect {
114 fn new() -> Self {
115 Self {
116 inner: Rc::new(OverscrollEffectInner {
117 offset: Cell::new(0.0),
118 limit: Cell::new(160.0),
119 invalidate_callbacks: RefCell::new(HashMap::new()),
120 next_callback_id: Cell::new(1),
121 }),
122 }
123 }
124
125 pub(crate) fn offset(&self) -> f32 {
126 self.inner.offset.get()
127 }
128
129 pub(crate) fn set_limit(&self, limit: f32) {
130 if !limit.is_finite() || limit <= 0.0 {
131 return;
132 }
133 self.inner.limit.set(limit);
134 self.set_offset(self.offset().clamp(-limit, limit));
135 }
136
137 pub(crate) fn apply_drag_delta(&self, delta: f32) -> f32 {
138 if !delta.is_finite() || delta.abs() <= f32::EPSILON {
139 return 0.0;
140 }
141 let limit = self.inner.limit.get();
142 let offset = self.offset();
143 let resistance = (1.0 - offset.abs() / limit).clamp(0.12, 1.0) * 0.5;
144 let next = (offset + delta * resistance).clamp(-limit, limit);
145 let applied = next - offset;
146 self.set_offset(next);
147 applied
148 }
149
150 pub(crate) fn apply_settle_delta(&self, delta: f32) -> f32 {
151 let offset = self.offset();
152 if offset.abs() <= f32::EPSILON {
153 return 0.0;
154 }
155 let proposed = offset + delta;
156 let crosses_edge = offset.abs() > f32::EPSILON && proposed.signum() != offset.signum();
157 let next = if crosses_edge {
158 0.0
159 } else {
160 proposed.clamp(-self.inner.limit.get(), self.inner.limit.get())
161 };
162 let applied = next - offset;
163 self.set_offset(next);
164 applied
165 }
166
167 pub(crate) fn apply_to_scroll<F>(&self, delta: f32, perform_scroll: F) -> f32
168 where
169 F: FnOnce(f32) -> f32,
170 {
171 if !delta.is_finite() || delta.abs() <= f32::EPSILON {
172 return 0.0;
173 }
174 let mut remaining = delta;
175 let mut consumed = 0.0;
176 let offset = self.offset();
177 if offset.abs() > f32::EPSILON && delta.signum() != offset.signum() {
178 let release = delta.abs().min(offset.abs()) * delta.signum();
179 let released = self.apply_settle_delta(release);
180 consumed += released;
181 remaining -= released;
182 }
183 if remaining.abs() > f32::EPSILON {
184 let target_consumed = perform_scroll(remaining);
185 consumed += target_consumed;
186 let unconsumed = remaining - target_consumed;
187 if unconsumed.abs() > f32::EPSILON {
188 consumed += self.apply_drag_delta(unconsumed);
189 }
190 }
191 consumed
192 }
193
194 pub(crate) fn apply_to_fling<F>(&self, delta: f32, perform_scroll: F) -> f32
195 where
196 F: FnOnce(f32) -> f32,
197 {
198 let consumed = perform_scroll(delta);
199 let unconsumed = delta - consumed;
200 if unconsumed.abs() > f32::EPSILON {
201 self.apply_drag_delta(-unconsumed);
202 }
203 consumed
204 }
205
206 pub(crate) fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
207 let id = self.inner.next_callback_id.get();
208 self.inner.next_callback_id.set(id.saturating_add(1));
209 self.inner
210 .invalidate_callbacks
211 .borrow_mut()
212 .insert(id, Rc::from(callback));
213 id
214 }
215
216 pub(crate) fn remove_invalidate_callback(&self, id: u64) {
217 self.inner.invalidate_callbacks.borrow_mut().remove(&id);
218 }
219
220 pub(crate) fn ptr_eq(&self, other: &Self) -> bool {
221 Rc::ptr_eq(&self.inner, &other.inner)
222 }
223
224 fn set_offset(&self, offset: f32) {
225 if (offset - self.offset()).abs() <= f32::EPSILON {
226 return;
227 }
228 self.inner.offset.set(offset);
229 let callbacks = self
230 .inner
231 .invalidate_callbacks
232 .borrow()
233 .values()
234 .cloned()
235 .collect::<Vec<_>>();
236 for callback in callbacks {
237 callback();
238 }
239 }
240}
241
242impl PartialEq for ScrollState {
243 fn eq(&self, other: &Self) -> bool {
246 self.inner == other.inner
247 }
248}
249
250impl ScrollState {
251 pub fn new(initial: f32) -> Self {
253 let runtime = cranpose_core::current_runtime_handle()
254 .expect("ScrollState::new requires an active runtime");
255 Self {
256 value: MutableState::with_runtime(initial, runtime.clone()),
257 inner: MutableState::with_runtime(
258 Rc::new(ScrollStateInner {
259 max_value: RefCell::new(0.0),
260 viewport_extent: RefCell::new(0.0),
261 invalidate_callbacks: RefCell::new(HashMap::new()),
262 next_invalidate_callback_id: Cell::new(1),
263 pending_invalidation: Cell::new(false),
264 settle_policy: RefCell::new(None),
265 }),
266 runtime,
267 ),
268 }
269 }
270
271 fn inner(&self) -> Rc<ScrollStateInner> {
272 self.inner.get_non_reactive()
273 }
274
275 pub fn set_settle_policy(&self, policy: Option<ScrollSettlePolicy>) {
277 *self.inner().settle_policy.borrow_mut() = policy;
278 }
279
280 pub fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
282 self.inner().settle_policy.borrow().clone()
283 }
284
285 pub fn id(&self) -> u64 {
287 let mut hasher = DefaultHasher::new();
288 self.inner.runtime_state_id().hash(&mut hasher);
289 hasher.finish()
290 }
291
292 pub fn value(&self) -> f32 {
297 self.value.value()
298 }
299
300 pub fn value_non_reactive(&self) -> f32 {
305 self.value.get_non_reactive()
306 }
307
308 pub fn max_value(&self) -> f32 {
310 *self.inner().max_value.borrow()
311 }
312
313 pub fn dispatch_raw_delta(&self, delta: f32) -> f32 {
316 let current = self.value_non_reactive();
317 let max = self.max_value();
318 let new_value = (current + delta).clamp(0.0, max);
319 let actual_delta = new_value - current;
320
321 if actual_delta.abs() > 0.001 {
322 self.value.set(new_value);
324
325 self.invalidate();
326 }
327
328 actual_delta
329 }
330
331 pub fn viewport_extent(&self) -> f32 {
334 *self.inner().viewport_extent.borrow()
335 }
336
337 pub fn content_extent(&self) -> f32 {
339 self.viewport_extent() + self.max_value()
340 }
341
342 pub fn metrics(&self) -> ScrollMetrics {
348 let inner = self.inner();
349 let max_offset = *inner.max_value.borrow();
350 let viewport_extent = *inner.viewport_extent.borrow();
351 ScrollMetrics {
352 offset: self.value_non_reactive(),
353 max_offset,
354 viewport_extent,
355 }
356 }
357
358 pub(crate) fn set_max_value(&self, max: f32) {
360 *self.inner().max_value.borrow_mut() = max;
361 }
362
363 pub(crate) fn set_viewport_extent(&self, extent: f32) {
365 *self.inner().viewport_extent.borrow_mut() = extent;
366 }
367
368 pub fn scroll_to(&self, position: f32) {
370 let max = self.max_value();
371 let clamped = position.clamp(0.0, max);
372
373 self.value.set(clamped);
374
375 self.invalidate();
376 }
377
378 pub(crate) fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
380 let inner = self.inner();
381 let id = inner.next_invalidate_callback_id.get();
382 inner.next_invalidate_callback_id.set(id.saturating_add(1));
383 let callback: Rc<dyn Fn()> = Rc::from(callback);
384 inner
385 .invalidate_callbacks
386 .borrow_mut()
387 .insert(id, Rc::clone(&callback));
388 if inner.pending_invalidation.replace(false) {
389 callback();
390 }
391 id
392 }
393
394 pub(crate) fn remove_invalidate_callback(&self, id: u64) {
396 self.inner().invalidate_callbacks.borrow_mut().remove(&id);
397 }
398
399 fn invalidate(&self) {
400 let inner = self.inner();
401 let callbacks: Vec<Rc<dyn Fn()>> = {
402 let callbacks = inner.invalidate_callbacks.borrow();
403 if callbacks.is_empty() {
404 inner.pending_invalidation.set(true);
405 return;
406 }
407 callbacks.values().cloned().collect()
408 };
409 for callback in callbacks {
410 callback();
411 }
412 }
413}
414
415#[derive(Clone)]
416pub(crate) struct ScrollMotionContext {
417 inner: Rc<ScrollMotionContextInner>,
418}
419
420#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
421pub(crate) enum ScrollMotionContextKey {
422 ScrollState {
423 state_id: u64,
424 is_vertical: bool,
425 reverse_scrolling: bool,
426 },
427 LazyList {
428 state_identity: usize,
429 is_vertical: bool,
430 reverse_scrolling: bool,
431 },
432 Draggable {
436 state_identity: usize,
437 is_vertical: bool,
438 },
439}
440
441struct ScrollMotionContextInner {
442 active: Cell<bool>,
443 transient_active: Cell<bool>,
444 generation: Cell<u64>,
445 invalidate_callbacks: RefCell<HashMap<u64, Rc<dyn Fn()>>>,
446 next_invalidate_callback_id: Cell<u64>,
447 pending_invalidation: Cell<bool>,
448 overscroll: OverscrollEffect,
449}
450
451pub(crate) struct ScrollMotionContextStore {
452 contexts: RefCell<HashMap<ScrollMotionContextKey, Weak<ScrollMotionContextInner>>>,
453}
454
455impl ScrollMotionContextStore {
456 pub(crate) fn new() -> Self {
457 Self {
458 contexts: RefCell::new(HashMap::new()),
459 }
460 }
461
462 fn context_for_key(&self, key: ScrollMotionContextKey) -> ScrollMotionContext {
463 let mut contexts = self.contexts.borrow_mut();
464 if let Some(inner) = contexts.get(&key).and_then(Weak::upgrade) {
465 return ScrollMotionContext { inner };
466 }
467
468 let context = ScrollMotionContext::new();
469 contexts.insert(key, Rc::downgrade(&context.inner));
470 contexts.retain(|_, weak| weak.strong_count() > 0);
471 context
472 }
473
474 pub(crate) fn clear_transient_after_frame(&self) {
475 let contexts = {
476 let mut contexts = self.contexts.borrow_mut();
477 let live = contexts
478 .values()
479 .filter_map(Weak::upgrade)
480 .collect::<Vec<_>>();
481 contexts.retain(|_, weak| weak.strong_count() > 0);
482 live
483 };
484 for inner in contexts {
485 ScrollMotionContext { inner }.clear_transient_after_frame();
486 }
487 }
488}
489
490pub(crate) fn scroll_motion_context_for_key(key: ScrollMotionContextKey) -> ScrollMotionContext {
491 crate::render_state::with_scroll_motion_context_store(|store| store.context_for_key(key))
492}
493
494impl ScrollMotionContext {
495 pub(crate) fn new() -> Self {
496 Self {
497 inner: Rc::new(ScrollMotionContextInner {
498 active: Cell::new(false),
499 transient_active: Cell::new(false),
500 generation: Cell::new(0),
501 invalidate_callbacks: RefCell::new(HashMap::new()),
502 next_invalidate_callback_id: Cell::new(1),
503 pending_invalidation: Cell::new(false),
504 overscroll: OverscrollEffect::new(),
505 }),
506 }
507 }
508
509 pub(crate) fn is_active(&self) -> bool {
510 self.inner.active.get() || self.inner.transient_active.get()
511 }
512
513 pub(crate) fn ptr_eq(&self, other: &Self) -> bool {
514 Rc::ptr_eq(&self.inner, &other.inner)
515 }
516
517 pub(crate) fn stable_key(&self) -> usize {
518 Rc::as_ptr(&self.inner) as usize
519 }
520
521 pub(crate) fn overscroll(&self) -> OverscrollEffect {
522 self.inner.overscroll.clone()
523 }
524
525 pub(crate) fn set_active(&self, active: bool) {
526 let was_active = self.is_active();
527 self.inner.active.set(active);
528 if !active {
529 self.inner.transient_active.set(false);
530 }
531 if was_active != self.is_active() {
532 self.bump_generation();
533 self.invalidate();
534 }
535 }
536
537 pub(crate) fn activate_for_current_frame(&self) {
538 let was_active = self.is_active();
539 self.inner.transient_active.set(true);
540 self.bump_generation();
541 if !was_active {
542 self.invalidate();
543 }
544 }
545
546 pub(crate) fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
547 let id = self.inner.next_invalidate_callback_id.get();
548 self.inner
549 .next_invalidate_callback_id
550 .set(id.saturating_add(1));
551 let callback: Rc<dyn Fn()> = Rc::from(callback);
552 self.inner
553 .invalidate_callbacks
554 .borrow_mut()
555 .insert(id, Rc::clone(&callback));
556 if self.inner.pending_invalidation.replace(false) {
557 callback();
558 }
559 id
560 }
561
562 pub(crate) fn remove_invalidate_callback(&self, id: u64) {
563 self.inner.invalidate_callbacks.borrow_mut().remove(&id);
564 }
565
566 fn bump_generation(&self) -> u64 {
567 let next = self.inner.generation.get().wrapping_add(1);
568 self.inner.generation.set(next);
569 next
570 }
571
572 fn clear_transient_after_frame(&self) {
573 let was_active = self.is_active();
574 if self.inner.transient_active.replace(false) {
575 self.bump_generation();
576 if was_active != self.is_active() {
577 self.invalidate();
578 }
579 }
580 }
581
582 fn invalidate(&self) {
583 let callbacks: Vec<Rc<dyn Fn()>> = {
584 let callbacks = self.inner.invalidate_callbacks.borrow();
585 if callbacks.is_empty() {
586 self.inner.pending_invalidation.set(true);
587 return;
588 }
589 callbacks.values().cloned().collect()
590 };
591 for callback in callbacks {
592 callback();
593 }
594 }
595}
596
597#[derive(Clone)]
599pub struct ScrollElement {
600 state: ScrollState,
601 overscroll: OverscrollEffect,
602 is_vertical: bool,
603 reverse_scrolling: bool,
604}
605
606impl ScrollElement {
607 pub(crate) fn new(
608 state: ScrollState,
609 overscroll: OverscrollEffect,
610 is_vertical: bool,
611 reverse_scrolling: bool,
612 ) -> Self {
613 Self {
614 state,
615 overscroll,
616 is_vertical,
617 reverse_scrolling,
618 }
619 }
620}
621
622impl std::fmt::Debug for ScrollElement {
623 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
624 f.debug_struct("ScrollElement")
625 .field("is_vertical", &self.is_vertical)
626 .field("reverse_scrolling", &self.reverse_scrolling)
627 .finish()
628 }
629}
630
631impl PartialEq for ScrollElement {
632 fn eq(&self, other: &Self) -> bool {
633 self.state == other.state
635 && self.is_vertical == other.is_vertical
636 && self.reverse_scrolling == other.reverse_scrolling
637 }
638}
639
640impl Eq for ScrollElement {}
641
642impl Hash for ScrollElement {
643 fn hash<H: Hasher>(&self, state: &mut H) {
644 self.state.inner.runtime_state_id().hash(state);
645 self.is_vertical.hash(state);
646 self.reverse_scrolling.hash(state);
647 }
648}
649
650impl ModifierNodeElement for ScrollElement {
651 type Node = ScrollNode;
652
653 fn create(&self) -> Self::Node {
654 ScrollNode::new(
656 self.state,
657 self.overscroll.clone(),
658 self.is_vertical,
659 self.reverse_scrolling,
660 )
661 }
662
663 fn key(&self) -> Option<u64> {
664 let mut hasher = DefaultHasher::new();
665 self.state.id().hash(&mut hasher);
666 self.reverse_scrolling.hash(&mut hasher);
667 self.is_vertical.hash(&mut hasher);
668 Some(hasher.finish())
669 }
670
671 fn update(&self, node: &mut Self::Node) {
672 let needs_invalidation = node.state != self.state
673 || node.is_vertical != self.is_vertical
674 || node.reverse_scrolling != self.reverse_scrolling
675 || !node.overscroll.ptr_eq(&self.overscroll);
676
677 if needs_invalidation {
678 node.state = self.state;
679 node.is_vertical = self.is_vertical;
680 node.reverse_scrolling = self.reverse_scrolling;
681 node.overscroll = self.overscroll.clone();
682 }
683 }
684
685 fn capabilities(&self) -> NodeCapabilities {
686 NodeCapabilities::LAYOUT
687 }
688}
689
690pub struct ScrollNode {
693 state: ScrollState,
694 overscroll: OverscrollEffect,
695 is_vertical: bool,
696 reverse_scrolling: bool,
697 node_state: NodeState,
698 invalidation_callback_id: Option<u64>,
700 overscroll_callback_id: Option<u64>,
701 node_id: Option<NodeId>,
703}
704
705impl ScrollNode {
706 pub(crate) fn new(
707 state: ScrollState,
708 overscroll: OverscrollEffect,
709 is_vertical: bool,
710 reverse_scrolling: bool,
711 ) -> Self {
712 Self {
713 state,
714 overscroll,
715 is_vertical,
716 reverse_scrolling,
717 node_state: NodeState::default(),
718 invalidation_callback_id: None,
719 overscroll_callback_id: None,
720 node_id: None,
721 }
722 }
723
724 pub fn state(&self) -> &ScrollState {
726 &self.state
727 }
728}
729
730impl DelegatableNode for ScrollNode {
731 fn node_state(&self) -> &NodeState {
732 &self.node_state
733 }
734}
735
736impl ModifierNode for ScrollNode {
737 fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
738 let node_id = context.node_id();
742 self.node_id = node_id;
743
744 if let Some(node_id) = node_id {
745 let callback_id = self.state.add_invalidate_callback(Box::new(move || {
746 crate::schedule_layout_repass(node_id);
748 }));
749 self.invalidation_callback_id = Some(callback_id);
750 let callback_id = self.overscroll.add_invalidate_callback(Box::new(move || {
751 crate::schedule_layout_repass(node_id);
752 }));
753 self.overscroll_callback_id = Some(callback_id);
754 } else {
755 log::debug!(
756 "ScrollNode attached without a NodeId; deferring invalidation registration."
757 );
758 }
759
760 context.invalidate(cranpose_foundation::InvalidationKind::Layout);
762 }
763
764 fn on_detach(&mut self) {
765 if let Some(id) = self.invalidation_callback_id.take() {
767 self.state.remove_invalidate_callback(id);
768 }
769 if let Some(id) = self.overscroll_callback_id.take() {
770 self.overscroll.remove_invalidate_callback(id);
771 }
772 }
773
774 fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
775 Some(self)
776 }
777
778 fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
779 Some(self)
780 }
781}
782
783impl LayoutModifierNode for ScrollNode {
784 fn measure(
785 &self,
786 _context: &mut dyn ModifierNodeContext,
787 measurable: &dyn Measurable,
788 constraints: Constraints,
789 ) -> LayoutModifierMeasureResult {
790 let scroll_constraints = if self.is_vertical {
792 Constraints {
793 min_height: 0.0,
794 max_height: f32::INFINITY,
795 ..constraints
796 }
797 } else {
798 Constraints {
799 min_width: 0.0,
800 max_width: f32::INFINITY,
801 ..constraints
802 }
803 };
804
805 let placeable = measurable.measure(scroll_constraints);
807
808 let width = placeable.width().min(constraints.max_width);
810 let height = placeable.height().min(constraints.max_height);
811
812 let max_scroll = if self.is_vertical {
814 (placeable.height() - height).max(0.0)
815 } else {
816 (placeable.width() - width).max(0.0)
817 };
818
819 if (self.is_vertical && constraints.max_height.is_finite())
822 || (!self.is_vertical && constraints.max_width.is_finite())
823 {
824 self.state.set_max_value(max_scroll);
825 self.state
826 .set_viewport_extent(if self.is_vertical { height } else { width });
827 self.overscroll
828 .set_limit((if self.is_vertical { height } else { width }) * 0.5);
829 }
830
831 let scroll = self.state.value_non_reactive().clamp(0.0, max_scroll);
834
835 let abs_scroll = if self.reverse_scrolling {
836 scroll - max_scroll
837 } else {
838 -scroll
839 };
840 let abs_scroll = abs_scroll + self.overscroll.offset();
841
842 let (x_offset, y_offset) = if self.is_vertical {
843 (0.0, abs_scroll)
844 } else {
845 (abs_scroll, 0.0)
846 };
847
848 LayoutModifierMeasureResult::new(Size { width, height }, x_offset, y_offset)
852 }
853
854 fn min_intrinsic_width(&self, measurable: &dyn Measurable, height: f32) -> f32 {
855 measurable.min_intrinsic_width(height)
856 }
857
858 fn max_intrinsic_width(&self, measurable: &dyn Measurable, height: f32) -> f32 {
859 measurable.max_intrinsic_width(height)
860 }
861
862 fn min_intrinsic_height(&self, measurable: &dyn Measurable, width: f32) -> f32 {
863 measurable.min_intrinsic_height(width)
864 }
865
866 fn max_intrinsic_height(&self, measurable: &dyn Measurable, width: f32) -> f32 {
867 measurable.max_intrinsic_height(width)
868 }
869}
870
871#[macro_export]
875macro_rules! rememberScrollState {
876 ($initial:expr) => {
877 cranpose_core::remember(|| $crate::scroll::ScrollState::new($initial))
878 .with(|state| state.clone())
879 };
880 () => {
881 rememberScrollState!(0.0)
882 };
883}
884
885#[cfg(test)]
886#[path = "tests/scroll_tests.rs"]
887mod tests;