Skip to main content

cranpose_ui/
scroll.rs

1//! Scroll state and node implementation for cranpose.
2//!
3//! This module provides the core scrolling components:
4//! - `ScrollState`: Holds scroll position and provides scroll control methods
5//! - `ScrollNode`: Layout modifier that applies scroll offset to content
6//! - `ScrollElement`: Element for creating ScrollNode instances
7//!
8//! The actual `Modifier.horizontal_scroll()` and `Modifier.vertical_scroll()`
9//! extension methods are defined in `modifier/scroll.rs`.
10
11use cranpose_core::{MutableState, NodeId};
12use cranpose_foundation::{
13    Constraints, DelegatableNode, LayoutModifierNode, Measurable, ModifierNode,
14    ModifierNodeContext, ModifierNodeElement, NodeCapabilities, NodeState,
15};
16use cranpose_ui_graphics::Size;
17use cranpose_ui_layout::LayoutModifierMeasureResult;
18use std::cell::{Cell, RefCell};
19use std::collections::HashMap;
20use std::hash::{DefaultHasher, Hash, Hasher};
21use std::rc::{Rc, Weak};
22
23/// State object for scroll position tracking.
24///
25/// Holds the current scroll offset and provides methods to programmatically
26/// control scrolling. Can be created with `rememberScrollState()`.
27///
28/// This is a pure scroll model - it does NOT store ephemeral gesture/pointer state.
29/// Gesture state is managed locally in the scroll modifier.
30#[derive(Clone, Copy)]
31pub struct ScrollState {
32    value: MutableState<f32>,
33    inner: MutableState<Rc<ScrollStateInner>>,
34}
35
36pub(crate) struct ScrollStateInner {
37    max_value: RefCell<f32>,
38    invalidate_callbacks: RefCell<HashMap<u64, Rc<dyn Fn()>>>,
39    next_invalidate_callback_id: Cell<u64>,
40    pending_invalidation: Cell<bool>,
41    settle_policy: RefCell<Option<ScrollSettlePolicy>>,
42}
43
44/// Remaps where a scroll comes to rest once the user's interaction ends — the
45/// `UIScrollView targetContentOffset` analog. Receives the naturally proposed
46/// rest offset (the fling's predicted end, or the current offset for a plain
47/// release/wheel idle) and the release velocity in offset units/sec; returns
48/// the offset the scroll should settle at. Used e.g. by the liquid nav bar to
49/// snap out of the large-title collapse band so the title never rests
50/// half-faded.
51pub type ScrollSettlePolicy = Rc<dyn Fn(f32, f32) -> f32>;
52
53#[derive(Clone)]
54pub(crate) struct OverscrollEffect {
55    inner: Rc<OverscrollEffectInner>,
56}
57
58struct OverscrollEffectInner {
59    offset: Cell<f32>,
60    limit: Cell<f32>,
61    invalidate_callbacks: RefCell<HashMap<u64, Rc<dyn Fn()>>>,
62    next_callback_id: Cell<u64>,
63}
64
65impl OverscrollEffect {
66    fn new() -> Self {
67        Self {
68            inner: Rc::new(OverscrollEffectInner {
69                offset: Cell::new(0.0),
70                limit: Cell::new(160.0),
71                invalidate_callbacks: RefCell::new(HashMap::new()),
72                next_callback_id: Cell::new(1),
73            }),
74        }
75    }
76
77    pub(crate) fn offset(&self) -> f32 {
78        self.inner.offset.get()
79    }
80
81    pub(crate) fn set_limit(&self, limit: f32) {
82        if !limit.is_finite() || limit <= 0.0 {
83            return;
84        }
85        self.inner.limit.set(limit);
86        self.set_offset(self.offset().clamp(-limit, limit));
87    }
88
89    pub(crate) fn apply_drag_delta(&self, delta: f32) -> f32 {
90        if !delta.is_finite() || delta.abs() <= f32::EPSILON {
91            return 0.0;
92        }
93        let limit = self.inner.limit.get();
94        let offset = self.offset();
95        let resistance = (1.0 - offset.abs() / limit).clamp(0.12, 1.0) * 0.5;
96        let next = (offset + delta * resistance).clamp(-limit, limit);
97        let applied = next - offset;
98        self.set_offset(next);
99        applied
100    }
101
102    pub(crate) fn apply_settle_delta(&self, delta: f32) -> f32 {
103        let offset = self.offset();
104        if offset.abs() <= f32::EPSILON {
105            return 0.0;
106        }
107        let proposed = offset + delta;
108        let crosses_edge = offset.abs() > f32::EPSILON && proposed.signum() != offset.signum();
109        let next = if crosses_edge {
110            0.0
111        } else {
112            proposed.clamp(-self.inner.limit.get(), self.inner.limit.get())
113        };
114        let applied = next - offset;
115        self.set_offset(next);
116        applied
117    }
118
119    pub(crate) fn apply_to_scroll<F>(&self, delta: f32, perform_scroll: F) -> f32
120    where
121        F: FnOnce(f32) -> f32,
122    {
123        if !delta.is_finite() || delta.abs() <= f32::EPSILON {
124            return 0.0;
125        }
126        let mut remaining = delta;
127        let mut consumed = 0.0;
128        let offset = self.offset();
129        if offset.abs() > f32::EPSILON && delta.signum() != offset.signum() {
130            let release = delta.abs().min(offset.abs()) * delta.signum();
131            let released = self.apply_settle_delta(release);
132            consumed += released;
133            remaining -= released;
134        }
135        if remaining.abs() > f32::EPSILON {
136            let target_consumed = perform_scroll(remaining);
137            consumed += target_consumed;
138            let unconsumed = remaining - target_consumed;
139            if unconsumed.abs() > f32::EPSILON {
140                consumed += self.apply_drag_delta(unconsumed);
141            }
142        }
143        consumed
144    }
145
146    pub(crate) fn apply_to_fling<F>(&self, delta: f32, perform_scroll: F) -> f32
147    where
148        F: FnOnce(f32) -> f32,
149    {
150        let consumed = perform_scroll(delta);
151        let unconsumed = delta - consumed;
152        if unconsumed.abs() > f32::EPSILON {
153            self.apply_drag_delta(-unconsumed);
154        }
155        consumed
156    }
157
158    pub(crate) fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
159        let id = self.inner.next_callback_id.get();
160        self.inner.next_callback_id.set(id.saturating_add(1));
161        self.inner
162            .invalidate_callbacks
163            .borrow_mut()
164            .insert(id, Rc::from(callback));
165        id
166    }
167
168    pub(crate) fn remove_invalidate_callback(&self, id: u64) {
169        self.inner.invalidate_callbacks.borrow_mut().remove(&id);
170    }
171
172    pub(crate) fn ptr_eq(&self, other: &Self) -> bool {
173        Rc::ptr_eq(&self.inner, &other.inner)
174    }
175
176    fn set_offset(&self, offset: f32) {
177        if (offset - self.offset()).abs() <= f32::EPSILON {
178            return;
179        }
180        self.inner.offset.set(offset);
181        let callbacks = self
182            .inner
183            .invalidate_callbacks
184            .borrow()
185            .values()
186            .cloned()
187            .collect::<Vec<_>>();
188        for callback in callbacks {
189            callback();
190        }
191    }
192}
193
194impl PartialEq for ScrollState {
195    /// Two handles are equal when they share the same underlying state
196    /// (identity, not value — composable-skip semantics).
197    fn eq(&self, other: &Self) -> bool {
198        self.inner == other.inner
199    }
200}
201
202impl ScrollState {
203    /// Creates a new ScrollState with the given initial scroll position.
204    pub fn new(initial: f32) -> Self {
205        let runtime = cranpose_core::current_runtime_handle()
206            .expect("ScrollState::new requires an active runtime");
207        Self {
208            value: MutableState::with_runtime(initial, runtime.clone()),
209            inner: MutableState::with_runtime(
210                Rc::new(ScrollStateInner {
211                    max_value: RefCell::new(0.0),
212                    invalidate_callbacks: RefCell::new(HashMap::new()),
213                    next_invalidate_callback_id: Cell::new(1),
214                    pending_invalidation: Cell::new(false),
215                    settle_policy: RefCell::new(None),
216                }),
217                runtime,
218            ),
219        }
220    }
221
222    fn inner(&self) -> Rc<ScrollStateInner> {
223        self.inner.get_non_reactive()
224    }
225
226    /// Installs (or clears) the settle policy consulted when interactions end.
227    pub fn set_settle_policy(&self, policy: Option<ScrollSettlePolicy>) {
228        *self.inner().settle_policy.borrow_mut() = policy;
229    }
230
231    /// The currently installed settle policy, if any.
232    pub fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
233        self.inner().settle_policy.borrow().clone()
234    }
235
236    /// Get the unique ID of this ScrollState
237    pub fn id(&self) -> u64 {
238        let mut hasher = DefaultHasher::new();
239        self.inner.runtime_state_id().hash(&mut hasher);
240        hasher.finish()
241    }
242
243    /// Gets the current scroll position in pixels (reactive - triggers recomposition).
244    ///
245    /// Use this in Composable functions when you want UI to update on scroll.
246    /// Example: `Text("Scroll position: ${scrollState.value()}")`
247    pub fn value(&self) -> f32 {
248        self.value.value()
249    }
250
251    /// Gets the current scroll position in pixels (non-reactive).
252    ///
253    /// Use this in layout/measure phase to avoid triggering recomposition.
254    /// This is called internally by ScrollNode::measure().
255    pub fn value_non_reactive(&self) -> f32 {
256        self.value.get_non_reactive()
257    }
258
259    /// Gets the maximum scroll value.
260    pub fn max_value(&self) -> f32 {
261        *self.inner().max_value.borrow()
262    }
263
264    /// Scrolls by the given delta, clamping to valid range [0, max_value].
265    /// Returns the actual amount scrolled.
266    pub fn dispatch_raw_delta(&self, delta: f32) -> f32 {
267        let current = self.value_non_reactive();
268        let max = self.max_value();
269        let new_value = (current + delta).clamp(0.0, max);
270        let actual_delta = new_value - current;
271
272        if actual_delta.abs() > 0.001 {
273            // Use MutableState::set which triggers snapshot observers for reactive updates
274            self.value.set(new_value);
275
276            self.invalidate();
277        }
278
279        actual_delta
280    }
281
282    /// Sets the maximum scroll value (internal use by ScrollNode).
283    pub(crate) fn set_max_value(&self, max: f32) {
284        *self.inner().max_value.borrow_mut() = max;
285    }
286
287    /// Scrolls to the given position immediately.
288    pub fn scroll_to(&self, position: f32) {
289        let max = self.max_value();
290        let clamped = position.clamp(0.0, max);
291
292        self.value.set(clamped);
293
294        self.invalidate();
295    }
296
297    /// Adds an invalidation callback and returns its ID
298    pub(crate) fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
299        let inner = self.inner();
300        let id = inner.next_invalidate_callback_id.get();
301        inner.next_invalidate_callback_id.set(id.saturating_add(1));
302        let callback: Rc<dyn Fn()> = Rc::from(callback);
303        inner
304            .invalidate_callbacks
305            .borrow_mut()
306            .insert(id, Rc::clone(&callback));
307        if inner.pending_invalidation.replace(false) {
308            callback();
309        }
310        id
311    }
312
313    /// Removes an invalidation callback by ID
314    pub(crate) fn remove_invalidate_callback(&self, id: u64) {
315        self.inner().invalidate_callbacks.borrow_mut().remove(&id);
316    }
317
318    fn invalidate(&self) {
319        let inner = self.inner();
320        let callbacks: Vec<Rc<dyn Fn()>> = {
321            let callbacks = inner.invalidate_callbacks.borrow();
322            if callbacks.is_empty() {
323                inner.pending_invalidation.set(true);
324                return;
325            }
326            callbacks.values().cloned().collect()
327        };
328        for callback in callbacks {
329            callback();
330        }
331    }
332}
333
334#[derive(Clone)]
335pub(crate) struct ScrollMotionContext {
336    inner: Rc<ScrollMotionContextInner>,
337}
338
339#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
340pub(crate) enum ScrollMotionContextKey {
341    ScrollState {
342        state_id: u64,
343        is_vertical: bool,
344        reverse_scrolling: bool,
345    },
346    LazyList {
347        state_identity: usize,
348        is_vertical: bool,
349        reverse_scrolling: bool,
350    },
351}
352
353struct ScrollMotionContextInner {
354    active: Cell<bool>,
355    transient_active: Cell<bool>,
356    generation: Cell<u64>,
357    invalidate_callbacks: RefCell<HashMap<u64, Rc<dyn Fn()>>>,
358    next_invalidate_callback_id: Cell<u64>,
359    pending_invalidation: Cell<bool>,
360    overscroll: OverscrollEffect,
361}
362
363pub(crate) struct ScrollMotionContextStore {
364    contexts: RefCell<HashMap<ScrollMotionContextKey, Weak<ScrollMotionContextInner>>>,
365}
366
367impl ScrollMotionContextStore {
368    pub(crate) fn new() -> Self {
369        Self {
370            contexts: RefCell::new(HashMap::new()),
371        }
372    }
373
374    fn context_for_key(&self, key: ScrollMotionContextKey) -> ScrollMotionContext {
375        let mut contexts = self.contexts.borrow_mut();
376        if let Some(inner) = contexts.get(&key).and_then(Weak::upgrade) {
377            return ScrollMotionContext { inner };
378        }
379
380        let context = ScrollMotionContext::new();
381        contexts.insert(key, Rc::downgrade(&context.inner));
382        contexts.retain(|_, weak| weak.strong_count() > 0);
383        context
384    }
385
386    pub(crate) fn clear_transient_after_frame(&self) {
387        let contexts = {
388            let mut contexts = self.contexts.borrow_mut();
389            let live = contexts
390                .values()
391                .filter_map(Weak::upgrade)
392                .collect::<Vec<_>>();
393            contexts.retain(|_, weak| weak.strong_count() > 0);
394            live
395        };
396        for inner in contexts {
397            ScrollMotionContext { inner }.clear_transient_after_frame();
398        }
399    }
400}
401
402pub(crate) fn scroll_motion_context_for_key(key: ScrollMotionContextKey) -> ScrollMotionContext {
403    crate::render_state::with_scroll_motion_context_store(|store| store.context_for_key(key))
404}
405
406impl ScrollMotionContext {
407    pub(crate) fn new() -> Self {
408        Self {
409            inner: Rc::new(ScrollMotionContextInner {
410                active: Cell::new(false),
411                transient_active: Cell::new(false),
412                generation: Cell::new(0),
413                invalidate_callbacks: RefCell::new(HashMap::new()),
414                next_invalidate_callback_id: Cell::new(1),
415                pending_invalidation: Cell::new(false),
416                overscroll: OverscrollEffect::new(),
417            }),
418        }
419    }
420
421    pub(crate) fn is_active(&self) -> bool {
422        self.inner.active.get() || self.inner.transient_active.get()
423    }
424
425    pub(crate) fn ptr_eq(&self, other: &Self) -> bool {
426        Rc::ptr_eq(&self.inner, &other.inner)
427    }
428
429    pub(crate) fn stable_key(&self) -> usize {
430        Rc::as_ptr(&self.inner) as usize
431    }
432
433    pub(crate) fn overscroll(&self) -> OverscrollEffect {
434        self.inner.overscroll.clone()
435    }
436
437    pub(crate) fn set_active(&self, active: bool) {
438        let was_active = self.is_active();
439        self.inner.active.set(active);
440        if !active {
441            self.inner.transient_active.set(false);
442        }
443        if was_active != self.is_active() {
444            self.bump_generation();
445            self.invalidate();
446        }
447    }
448
449    pub(crate) fn activate_for_current_frame(&self) {
450        let was_active = self.is_active();
451        self.inner.transient_active.set(true);
452        self.bump_generation();
453        if !was_active {
454            self.invalidate();
455        }
456    }
457
458    pub(crate) fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
459        let id = self.inner.next_invalidate_callback_id.get();
460        self.inner
461            .next_invalidate_callback_id
462            .set(id.saturating_add(1));
463        let callback: Rc<dyn Fn()> = Rc::from(callback);
464        self.inner
465            .invalidate_callbacks
466            .borrow_mut()
467            .insert(id, Rc::clone(&callback));
468        if self.inner.pending_invalidation.replace(false) {
469            callback();
470        }
471        id
472    }
473
474    pub(crate) fn remove_invalidate_callback(&self, id: u64) {
475        self.inner.invalidate_callbacks.borrow_mut().remove(&id);
476    }
477
478    fn bump_generation(&self) -> u64 {
479        let next = self.inner.generation.get().wrapping_add(1);
480        self.inner.generation.set(next);
481        next
482    }
483
484    fn clear_transient_after_frame(&self) {
485        let was_active = self.is_active();
486        if self.inner.transient_active.replace(false) {
487            self.bump_generation();
488            if was_active != self.is_active() {
489                self.invalidate();
490            }
491        }
492    }
493
494    fn invalidate(&self) {
495        let callbacks: Vec<Rc<dyn Fn()>> = {
496            let callbacks = self.inner.invalidate_callbacks.borrow();
497            if callbacks.is_empty() {
498                self.inner.pending_invalidation.set(true);
499                return;
500            }
501            callbacks.values().cloned().collect()
502        };
503        for callback in callbacks {
504            callback();
505        }
506    }
507}
508
509/// Element for creating a ScrollNode.
510#[derive(Clone)]
511pub struct ScrollElement {
512    state: ScrollState,
513    overscroll: OverscrollEffect,
514    is_vertical: bool,
515    reverse_scrolling: bool,
516}
517
518impl ScrollElement {
519    pub(crate) fn new(
520        state: ScrollState,
521        overscroll: OverscrollEffect,
522        is_vertical: bool,
523        reverse_scrolling: bool,
524    ) -> Self {
525        Self {
526            state,
527            overscroll,
528            is_vertical,
529            reverse_scrolling,
530        }
531    }
532}
533
534impl std::fmt::Debug for ScrollElement {
535    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536        f.debug_struct("ScrollElement")
537            .field("is_vertical", &self.is_vertical)
538            .field("reverse_scrolling", &self.reverse_scrolling)
539            .finish()
540    }
541}
542
543impl PartialEq for ScrollElement {
544    fn eq(&self, other: &Self) -> bool {
545        // ScrollStates are equal if they point to the same underlying state
546        self.state == other.state
547            && self.is_vertical == other.is_vertical
548            && self.reverse_scrolling == other.reverse_scrolling
549    }
550}
551
552impl Eq for ScrollElement {}
553
554impl Hash for ScrollElement {
555    fn hash<H: Hasher>(&self, state: &mut H) {
556        self.state.inner.runtime_state_id().hash(state);
557        self.is_vertical.hash(state);
558        self.reverse_scrolling.hash(state);
559    }
560}
561
562impl ModifierNodeElement for ScrollElement {
563    type Node = ScrollNode;
564
565    fn create(&self) -> Self::Node {
566        // println!("ScrollElement::create");
567        ScrollNode::new(
568            self.state,
569            self.overscroll.clone(),
570            self.is_vertical,
571            self.reverse_scrolling,
572        )
573    }
574
575    fn key(&self) -> Option<u64> {
576        let mut hasher = DefaultHasher::new();
577        self.state.id().hash(&mut hasher);
578        self.reverse_scrolling.hash(&mut hasher);
579        self.is_vertical.hash(&mut hasher);
580        Some(hasher.finish())
581    }
582
583    fn update(&self, node: &mut Self::Node) {
584        let needs_invalidation = node.state != self.state
585            || node.is_vertical != self.is_vertical
586            || node.reverse_scrolling != self.reverse_scrolling
587            || !node.overscroll.ptr_eq(&self.overscroll);
588
589        if needs_invalidation {
590            node.state = self.state;
591            node.is_vertical = self.is_vertical;
592            node.reverse_scrolling = self.reverse_scrolling;
593            node.overscroll = self.overscroll.clone();
594        }
595    }
596
597    fn capabilities(&self) -> NodeCapabilities {
598        NodeCapabilities::LAYOUT
599    }
600}
601
602/// ScrollNode layout modifier that physically moves content based on scroll position.
603/// This is the component that actually reads ScrollState and applies the visual offset.
604pub struct ScrollNode {
605    state: ScrollState,
606    overscroll: OverscrollEffect,
607    is_vertical: bool,
608    reverse_scrolling: bool,
609    node_state: NodeState,
610    /// ID of the invalidation callback registered with ScrollState
611    invalidation_callback_id: Option<u64>,
612    overscroll_callback_id: Option<u64>,
613    /// We capture the NodeId when attached to ensure correct invalidation scope
614    node_id: Option<NodeId>,
615}
616
617impl ScrollNode {
618    pub(crate) fn new(
619        state: ScrollState,
620        overscroll: OverscrollEffect,
621        is_vertical: bool,
622        reverse_scrolling: bool,
623    ) -> Self {
624        Self {
625            state,
626            overscroll,
627            is_vertical,
628            reverse_scrolling,
629            node_state: NodeState::default(),
630            invalidation_callback_id: None,
631            overscroll_callback_id: None,
632            node_id: None,
633        }
634    }
635
636    /// Returns a reference to the ScrollState.
637    pub fn state(&self) -> &ScrollState {
638        &self.state
639    }
640}
641
642impl DelegatableNode for ScrollNode {
643    fn node_state(&self) -> &NodeState {
644        &self.node_state
645    }
646}
647
648impl ModifierNode for ScrollNode {
649    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
650        // Set up the invalidation callback to trigger layout when scroll state changes.
651        // We capture the node_id directly from the context, avoiding any global registry.
652
653        let node_id = context.node_id();
654        self.node_id = node_id;
655
656        if let Some(node_id) = node_id {
657            let callback_id = self.state.add_invalidate_callback(Box::new(move || {
658                // Schedule scoped layout repass for this node
659                crate::schedule_layout_repass(node_id);
660            }));
661            self.invalidation_callback_id = Some(callback_id);
662            let callback_id = self.overscroll.add_invalidate_callback(Box::new(move || {
663                crate::schedule_layout_repass(node_id);
664            }));
665            self.overscroll_callback_id = Some(callback_id);
666        } else {
667            log::debug!(
668                "ScrollNode attached without a NodeId; deferring invalidation registration."
669            );
670        }
671
672        // Initial invalidation
673        context.invalidate(cranpose_foundation::InvalidationKind::Layout);
674    }
675
676    fn on_detach(&mut self) {
677        // Remove invalidation callback
678        if let Some(id) = self.invalidation_callback_id.take() {
679            self.state.remove_invalidate_callback(id);
680        }
681        if let Some(id) = self.overscroll_callback_id.take() {
682            self.overscroll.remove_invalidate_callback(id);
683        }
684    }
685
686    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
687        Some(self)
688    }
689
690    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
691        Some(self)
692    }
693}
694
695impl LayoutModifierNode for ScrollNode {
696    fn measure(
697        &self,
698        _context: &mut dyn ModifierNodeContext,
699        measurable: &dyn Measurable,
700        constraints: Constraints,
701    ) -> LayoutModifierMeasureResult {
702        // Step 1: Give child infinite space in scroll direction
703        let scroll_constraints = if self.is_vertical {
704            Constraints {
705                min_height: 0.0,
706                max_height: f32::INFINITY,
707                ..constraints
708            }
709        } else {
710            Constraints {
711                min_width: 0.0,
712                max_width: f32::INFINITY,
713                ..constraints
714            }
715        };
716
717        // Step 2: Measure child
718        let placeable = measurable.measure(scroll_constraints);
719
720        // Step 3: Calculate viewport size (constrained size)
721        let width = placeable.width().min(constraints.max_width);
722        let height = placeable.height().min(constraints.max_height);
723
724        // Step 4: Calculate max scroll
725        let max_scroll = if self.is_vertical {
726            (placeable.height() - height).max(0.0)
727        } else {
728            (placeable.width() - width).max(0.0)
729        };
730
731        // Step 5: Update state with max scroll value
732        // Only update if the viewport is constrained (not infinite probe)
733        if (self.is_vertical && constraints.max_height.is_finite())
734            || (!self.is_vertical && constraints.max_width.is_finite())
735        {
736            self.state.set_max_value(max_scroll);
737            self.overscroll
738                .set_limit((if self.is_vertical { height } else { width }) * 0.5);
739        }
740
741        // Step 6: Read scroll value and calculate offset
742        // IMPORTANT: Use value_non_reactive() during measure to avoid triggering recomposition
743        let scroll = self.state.value_non_reactive().clamp(0.0, max_scroll);
744
745        let abs_scroll = if self.reverse_scrolling {
746            scroll - max_scroll
747        } else {
748            -scroll
749        };
750        let abs_scroll = abs_scroll + self.overscroll.offset();
751
752        let (x_offset, y_offset) = if self.is_vertical {
753            (0.0, abs_scroll)
754        } else {
755            (abs_scroll, 0.0)
756        };
757
758        // Step 7: Return result with viewport size and scroll offset as placement_offset
759        // This makes the scroll offset part of the layout modifier's placement, which will be
760        // correctly applied to children by the layout system
761        LayoutModifierMeasureResult::new(Size { width, height }, x_offset, y_offset)
762    }
763
764    fn min_intrinsic_width(&self, measurable: &dyn Measurable, height: f32) -> f32 {
765        measurable.min_intrinsic_width(height)
766    }
767
768    fn max_intrinsic_width(&self, measurable: &dyn Measurable, height: f32) -> f32 {
769        measurable.max_intrinsic_width(height)
770    }
771
772    fn min_intrinsic_height(&self, measurable: &dyn Measurable, width: f32) -> f32 {
773        measurable.min_intrinsic_height(width)
774    }
775
776    fn max_intrinsic_height(&self, measurable: &dyn Measurable, width: f32) -> f32 {
777        measurable.max_intrinsic_height(width)
778    }
779}
780
781/// Creates a remembered ScrollState.
782///
783/// This is a convenience function for use in composable functions.
784#[macro_export]
785macro_rules! rememberScrollState {
786    ($initial:expr) => {
787        cranpose_core::remember(|| $crate::scroll::ScrollState::new($initial))
788            .with(|state| state.clone())
789    };
790    () => {
791        rememberScrollState!(0.0)
792    };
793}
794
795#[cfg(test)]
796#[path = "tests/scroll_tests.rs"]
797mod tests;