Skip to main content

vizia_core/views/
scrollview.rs

1use std::sync::Arc;
2
3use crate::prelude::*;
4
5pub(crate) const SCROLL_SENSITIVITY: f32 = 20.0;
6
7/// Events for setting the properties of a scroll view.
8pub enum ScrollEvent {
9    /// Sets the progress of scroll position between 0 and 1 for the x axis
10    SetX(f32),
11    /// Sets the progress of scroll position between 0 and 1 for the y axis
12    SetY(f32),
13    /// Adds given progress to scroll position for the x axis and clamps between 0 and 1
14    ScrollX(f32),
15    /// Adds given progress to scroll position for the y axis and clamps between 0 and 1
16    ScrollY(f32),
17    /// Sets the size for the inner scroll-content view which holds the content
18    ChildGeo(f32, f32),
19
20    ScrollToView(Entity),
21}
22
23/// A container a view which allows the user to scroll any overflowed content.
24pub struct ScrollView {
25    /// Progress of scroll position between 0 and 1 for the x axis
26    pub scroll_x: Signal<f32>,
27    /// Progress of scroll position between 0 and 1 for the y axis
28    pub scroll_y: Signal<f32>,
29    /// Callback called when the scrollview is scrolled.
30    pub on_scroll: Option<Arc<dyn Fn(&mut EventContext, f32, f32) + Send + Sync>>,
31    /// Width of the inner VStack which holds the content (typically bigger than container_width)
32    pub inner_width: Signal<f32>,
33    /// Height of the inner VStack which holds the content (typically bigger than container_height)
34    pub inner_height: Signal<f32>,
35    /// Width of the outer `ScrollView` which wraps the inner (typically smaller than inner_width)
36    pub container_width: Signal<f32>,
37    /// Height of the outer `ScrollView` which wraps the inner (typically smaller than inner_height)
38    pub container_height: Signal<f32>,
39    /// Whether the scrollbar should move to the cursor when pressed.
40    pub scroll_to_cursor: Signal<bool>,
41    /// Whether the horizontal scrollbar should be visible.
42    pub show_horizontal_scrollbar: Signal<bool>,
43    /// Whether the vertical scrollbar should be visible.
44    pub show_vertical_scrollbar: Signal<bool>,
45}
46
47impl ScrollView {
48    fn map_scroll_x_to_physical(scroll_x: f32, direction: Direction) -> f32 {
49        if direction == Direction::RightToLeft { 1.0 - scroll_x } else { scroll_x }
50    }
51
52    fn map_scroll_x_from_physical(scroll_x: f32, direction: Direction) -> f32 {
53        if direction == Direction::RightToLeft { 1.0 - scroll_x } else { scroll_x }
54    }
55
56    /// Creates a new [ScrollView].
57    pub fn new<F>(cx: &mut Context, content: F) -> Handle<Self>
58    where
59        F: 'static + FnOnce(&mut Context),
60    {
61        let scroll_to_cursor = Signal::new(false);
62        let scroll_x = Signal::new(0.0_f32);
63        let scroll_y = Signal::new(0.0_f32);
64        let inner_width = Signal::new(0.0_f32);
65        let inner_height = Signal::new(0.0_f32);
66        let container_width = Signal::new(0.0_f32);
67        let container_height = Signal::new(0.0_f32);
68        let show_horizontal_scrollbar = Signal::new(true);
69        let show_vertical_scrollbar = Signal::new(true);
70        let direction = cx.environment().direction;
71        let vertical_ratio: Memo<f32> = Memo::new(move |_| {
72            let inner = inner_height.get();
73            if inner == 0.0_f32 {
74                0.0_f32
75            } else {
76                (container_height.get() / inner).clamp(0.0_f32, 1.0_f32)
77            }
78        });
79
80        let horizontal_ratio: Memo<f32> = Memo::new(move |_| {
81            let inner = inner_width.get();
82            if inner == 0.0_f32 {
83                0.0_f32
84            } else {
85                (container_width.get() / inner).clamp(0.0_f32, 1.0_f32)
86            }
87        });
88
89        let has_h_scroll = Memo::new(move |_| container_width.get() < inner_width.get());
90        let has_v_scroll = Memo::new(move |_| container_height.get() < inner_height.get());
91
92        let horizontal_scrollbar_value: Memo<f32> = Memo::new(move |_| {
93            ScrollView::map_scroll_x_to_physical(scroll_x.get(), direction.get())
94        });
95
96        let scroll_state = Memo::new(move |_| {
97            (
98                scroll_x.get(),
99                scroll_y.get(),
100                inner_width.get(),
101                inner_height.get(),
102                container_width.get(),
103                container_height.get(),
104                direction.get(),
105            )
106        });
107        let scroll_state_signal = scroll_state;
108
109        Self {
110            scroll_to_cursor,
111            scroll_x,
112            scroll_y,
113            on_scroll: None,
114            inner_width,
115            inner_height,
116            container_width,
117            container_height,
118            show_horizontal_scrollbar,
119            show_vertical_scrollbar,
120        }
121        .build(cx, move |cx| {
122            // Apply scroll offset as a translation transform on scroll_content.
123            ScrollContent::new(cx, content).bind(scroll_state, move |mut handle| {
124                let (
125                    scroll_x,
126                    scroll_y,
127                    inner_width,
128                    inner_height,
129                    container_width,
130                    container_height,
131                    direction,
132                ) = scroll_state_signal.get();
133                let scale_factor = handle.context().scale_factor();
134                let y_overflow = (inner_height - container_height).max(0.0);
135                let top = (y_overflow * scroll_y).round() / scale_factor;
136                let x_overflow = (inner_width - container_width).max(0.0);
137                let left = (x_overflow * scroll_x).round() / scale_factor;
138
139                let tx = if direction == Direction::RightToLeft { left } else { -left };
140
141                handle.translate(Translate {
142                    x: LengthOrPercentage::Length(Length::px(tx)),
143                    y: LengthOrPercentage::Length(Length::px(-top)),
144                });
145            });
146
147            Binding::new(cx, show_vertical_scrollbar, move |cx| {
148                if show_vertical_scrollbar.get() {
149                    Scrollbar::new(
150                        cx,
151                        scroll_y,
152                        vertical_ratio,
153                        Orientation::Vertical,
154                        |cx, value| {
155                            cx.emit(ScrollEvent::SetY(value));
156                        },
157                    )
158                    .position_type(PositionType::Absolute)
159                    .scroll_to_cursor(scroll_to_cursor);
160                }
161            });
162
163            Binding::new(cx, show_horizontal_scrollbar, move |cx| {
164                if show_horizontal_scrollbar.get() {
165                    Scrollbar::new(
166                        cx,
167                        horizontal_scrollbar_value,
168                        horizontal_ratio,
169                        Orientation::Horizontal,
170                        |cx, value| {
171                            let logical = ScrollView::map_scroll_x_from_physical(
172                                value,
173                                cx.environment().direction.get(),
174                            );
175                            cx.emit(ScrollEvent::SetX(logical));
176                        },
177                    )
178                    .position_type(PositionType::Absolute)
179                    .scroll_to_cursor(scroll_to_cursor);
180                }
181            });
182        })
183        .toggle_class("h-scroll", has_h_scroll)
184        .toggle_class("v-scroll", has_v_scroll)
185    }
186
187    fn reset(&mut self) {
188        if self.inner_width.get() == self.container_width.get() {
189            self.scroll_x.set(0.0);
190        }
191
192        if self.inner_height.get() == self.container_height.get() {
193            self.scroll_y.set(0.0);
194        }
195    }
196}
197
198impl View for ScrollView {
199    fn element(&self) -> Option<&'static str> {
200        Some("scrollview")
201    }
202
203    fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
204        event.map(|scroll_update, meta| {
205            match scroll_update {
206                ScrollEvent::ScrollX(f) => {
207                    self.scroll_x.set((self.scroll_x.get() + *f).clamp(0.0, 1.0));
208
209                    if let Some(callback) = &self.on_scroll {
210                        (callback)(cx, self.scroll_x.get(), self.scroll_y.get());
211                    }
212                }
213
214                ScrollEvent::ScrollY(f) => {
215                    self.scroll_y.set((self.scroll_y.get() + *f).clamp(0.0, 1.0));
216                    if let Some(callback) = &self.on_scroll {
217                        (callback)(cx, self.scroll_x.get(), self.scroll_y.get());
218                    }
219                }
220
221                ScrollEvent::SetX(f) => {
222                    self.scroll_x.set(*f);
223                    if let Some(callback) = &self.on_scroll {
224                        (callback)(cx, self.scroll_x.get(), self.scroll_y.get());
225                    }
226                }
227
228                ScrollEvent::SetY(f) => {
229                    self.scroll_y.set(*f);
230                    if let Some(callback) = &self.on_scroll {
231                        (callback)(cx, self.scroll_x.get(), self.scroll_y.get());
232                    }
233                }
234
235                ScrollEvent::ChildGeo(w, h) => {
236                    let bounds = cx.bounds();
237                    let scale_factor = cx.scale_factor();
238
239                    let mut scroll_x = self.scroll_x.get();
240                    let mut scroll_y = self.scroll_y.get();
241                    let mut inner_width = self.inner_width.get();
242                    let mut inner_height = self.inner_height.get();
243                    let mut container_width = self.container_width.get();
244                    let mut container_height = self.container_height.get();
245
246                    if inner_width != 0.0 && inner_height != 0.0 {
247                        let top =
248                            ((inner_height - container_height) * scroll_y).round() / scale_factor;
249                        let physical_scroll_x = ScrollView::map_scroll_x_to_physical(
250                            scroll_x,
251                            cx.environment().direction.get(),
252                        );
253                        let left = ((inner_width - container_width) * physical_scroll_x).round()
254                            / scale_factor;
255
256                        container_width = bounds.width();
257                        container_height = bounds.height();
258                        inner_width = *w;
259                        inner_height = *h;
260
261                        if inner_width > container_width {
262                            let physical_scroll_x = ((left * scale_factor)
263                                / (inner_width - container_width))
264                                .clamp(0.0, 1.0);
265                            scroll_x = ScrollView::map_scroll_x_from_physical(
266                                physical_scroll_x,
267                                cx.environment().direction.get(),
268                            );
269                        } else {
270                            scroll_x = 0.0;
271                        }
272
273                        if inner_height > container_height {
274                            scroll_y = ((top * scale_factor) / (inner_height - container_height))
275                                .clamp(0.0, 1.0);
276                        } else {
277                            scroll_y = 0.0;
278                        }
279
280                        self.scroll_x.set(scroll_x);
281                        self.scroll_y.set(scroll_y);
282                        self.inner_width.set(inner_width);
283                        self.inner_height.set(inner_height);
284                        self.container_width.set(container_width);
285                        self.container_height.set(container_height);
286
287                        if let Some(callback) = &self.on_scroll {
288                            (callback)(cx, self.scroll_x.get(), self.scroll_y.get());
289                        }
290
291                        self.reset();
292                    }
293
294                    self.inner_width.set(*w);
295                    self.inner_height.set(*h);
296                    self.reset();
297                }
298
299                ScrollEvent::ScrollToView(entity) => {
300                    let view_bounds = cx.transformed_bounds(*entity);
301                    let content_bounds = cx.transformed_bounds(cx.current());
302
303                    let direction = cx.environment().direction.get();
304                    let mut physical_scroll_x =
305                        ScrollView::map_scroll_x_to_physical(self.scroll_x.get(), direction);
306
307                    let dx = content_bounds.right() - view_bounds.right();
308                    let dy = content_bounds.bottom() - view_bounds.bottom();
309
310                    // Calculate the scroll position to bring the child into view.
311                    if dx < 0.0 {
312                        let sx = (-dx / (self.inner_width.get() - self.container_width.get()))
313                            .clamp(0.0, 1.0);
314                        physical_scroll_x = (physical_scroll_x + sx).clamp(0.0, 1.0);
315                    }
316
317                    if dy < 0.0 {
318                        let sy = (-dy / (self.inner_height.get() - self.container_height.get()))
319                            .clamp(0.0, 1.0);
320                        self.scroll_y.set((self.scroll_y.get() + sy).clamp(0.0, 1.0));
321                    }
322
323                    let dx = view_bounds.left() - content_bounds.left();
324                    let dy = view_bounds.top() - content_bounds.top();
325
326                    if dx < 0.0 {
327                        let sx = (-dx / (self.inner_width.get() - self.container_width.get()))
328                            .clamp(0.0, 1.0);
329                        physical_scroll_x = (physical_scroll_x - sx).clamp(0.0, 1.0);
330                    }
331
332                    self.scroll_x
333                        .set(ScrollView::map_scroll_x_from_physical(physical_scroll_x, direction));
334
335                    if dy < 0.0 {
336                        let sy = (-dy / (self.inner_height.get() - self.container_height.get()))
337                            .clamp(0.0, 1.0);
338                        self.scroll_y.set((self.scroll_y.get() - sy).clamp(0.0, 1.0));
339                    }
340
341                    if let Some(callback) = &self.on_scroll {
342                        (callback)(cx, self.scroll_x.get(), self.scroll_y.get());
343                    }
344                }
345            }
346
347            // Prevent scroll events propagating to any parent scrollviews.
348            // TODO: This might be desired behavior when the scrollview is scrolled all the way.
349            meta.consume();
350        });
351
352        event.map(|window_event, meta| match window_event {
353            WindowEvent::GeometryChanged(geo) => {
354                if geo.contains(GeoChanged::WIDTH_CHANGED)
355                    || geo.contains(GeoChanged::HEIGHT_CHANGED)
356                {
357                    let bounds = cx.bounds();
358                    let scale_factor = cx.scale_factor();
359
360                    let mut scroll_x = self.scroll_x.get();
361                    let mut scroll_y = self.scroll_y.get();
362                    let inner_width = self.inner_width.get();
363                    let inner_height = self.inner_height.get();
364                    let mut container_width = self.container_width.get();
365                    let mut container_height = self.container_height.get();
366
367                    if inner_width != 0.0 && inner_height != 0.0 {
368                        let top =
369                            ((inner_height - container_height) * scroll_y).round() / scale_factor;
370                        let physical_scroll_x = ScrollView::map_scroll_x_to_physical(
371                            scroll_x,
372                            cx.environment().direction.get(),
373                        );
374                        let left = ((inner_width - container_width) * physical_scroll_x).round()
375                            / scale_factor;
376
377                        container_width = bounds.width();
378                        container_height = bounds.height();
379
380                        if inner_width > container_width {
381                            let physical_scroll_x = ((left * scale_factor)
382                                / (inner_width - container_width))
383                                .clamp(0.0, 1.0);
384                            scroll_x = ScrollView::map_scroll_x_from_physical(
385                                physical_scroll_x,
386                                cx.environment().direction.get(),
387                            );
388                        } else {
389                            scroll_x = 0.0;
390                        }
391
392                        if inner_height > container_height {
393                            scroll_y = ((top * scale_factor) / (inner_height - container_height))
394                                .clamp(0.0, 1.0);
395                        } else {
396                            scroll_y = 0.0;
397                        }
398
399                        self.scroll_x.set(scroll_x);
400                        self.scroll_y.set(scroll_y);
401                        self.container_width.set(container_width);
402                        self.container_height.set(container_height);
403
404                        if let Some(callback) = &self.on_scroll {
405                            (callback)(cx, self.scroll_x.get(), self.scroll_y.get());
406                        }
407
408                        self.reset();
409                    }
410
411                    self.container_width.set(bounds.width());
412                    self.container_height.set(bounds.height());
413                }
414            }
415
416            WindowEvent::MouseScroll(x, y) => {
417                cx.set_active(true);
418                let (x, y) = if cx.modifiers.shift() { (-*y, -*x) } else { (-*x, -*y) };
419
420                // What percentage of the negative space does this cross?
421                if x != 0.0 && self.inner_width.get() > self.container_width.get() {
422                    let negative_space = self.inner_width.get() - self.container_width.get();
423                    if negative_space != 0.0 {
424                        let logical_delta = x * SCROLL_SENSITIVITY / negative_space;
425                        cx.emit(ScrollEvent::ScrollX(logical_delta));
426                    }
427                    // Prevent event propagating to ancestor scrollviews.
428                    meta.consume();
429                }
430                if y != 0.0 && self.inner_height.get() > self.container_height.get() {
431                    let negative_space = self.inner_height.get() - self.container_height.get();
432                    if negative_space != 0.0 {
433                        let logical_delta = y * SCROLL_SENSITIVITY / negative_space;
434                        cx.emit(ScrollEvent::ScrollY(logical_delta));
435                    }
436                    // Prevent event propagating to ancestor scrollviews.
437                    meta.consume();
438                }
439            }
440
441            WindowEvent::MouseOut => {
442                cx.set_active(false);
443            }
444
445            _ => {}
446        });
447    }
448}
449
450impl Handle<'_, ScrollView> {
451    /// Sets a callback which will be called when a scrollview is scrolled, either with the mouse wheel, touchpad, or using the scroll bars.
452    pub fn on_scroll(
453        self,
454        callback: impl Fn(&mut EventContext, f32, f32) + 'static + Send + Sync,
455    ) -> Self {
456        self.modify(|scrollview| scrollview.on_scroll = Some(Arc::new(callback)))
457    }
458
459    /// Sets whether the scrollbar should move to the cursor when pressed.
460    pub fn scroll_to_cursor(self, scroll_to_cursor: impl Res<bool> + 'static) -> Self {
461        let scroll_to_cursor = scroll_to_cursor.to_signal(self.cx);
462        self.bind(scroll_to_cursor, move |handle| {
463            handle.modify(|scrollview| scrollview.scroll_to_cursor.set(scroll_to_cursor.get()));
464        })
465    }
466
467    /// Set the horizontal scroll position of the [ScrollView]. Accepts a value or signal of type `f32` between 0 and 1.
468    pub fn scroll_x(self, scrollx: impl Res<f32> + 'static) -> Self {
469        let scrollx = scrollx.to_signal(self.cx);
470        self.bind(scrollx, move |handle| {
471            handle.modify(|scrollview| scrollview.scroll_x.set(scrollx.get()));
472        })
473    }
474
475    /// Set the vertical scroll position of the [ScrollView]. Accepts a value or signal of type `f32` between 0 and 1.
476    pub fn scroll_y(self, scrolly: impl Res<f32> + 'static) -> Self {
477        let scrolly = scrolly.to_signal(self.cx);
478        self.bind(scrolly, move |handle| {
479            handle.modify(|scrollview| scrollview.scroll_y.set(scrolly.get()));
480        })
481    }
482
483    /// Sets whether the horizontal scrollbar should be visible.
484    pub fn show_horizontal_scrollbar(self, flag: impl Res<bool> + 'static) -> Self {
485        let flag = flag.to_signal(self.cx);
486        self.bind(flag, move |handle| {
487            handle.modify(|scrollview| scrollview.show_horizontal_scrollbar.set(flag.get()));
488        })
489    }
490
491    /// Sets whether the vertical scrollbar should be visible.
492    pub fn show_vertical_scrollbar(self, flag: impl Res<bool> + 'static) -> Self {
493        let flag = flag.to_signal(self.cx);
494        self.bind(flag, move |handle| {
495            handle.modify(|scrollview| scrollview.show_vertical_scrollbar.set(flag.get()));
496        })
497    }
498}
499
500struct ScrollContent {}
501
502impl ScrollContent {
503    pub fn new(cx: &mut Context, content: impl FnOnce(&mut Context)) -> Handle<Self> {
504        Self {}.build(cx, content)
505    }
506}
507
508impl View for ScrollContent {
509    fn element(&self) -> Option<&'static str> {
510        Some("scroll-content")
511    }
512
513    fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
514        event.map(|window_event, _| match window_event {
515            WindowEvent::GeometryChanged(geo) => {
516                if geo.contains(GeoChanged::WIDTH_CHANGED)
517                    || geo.contains(GeoChanged::HEIGHT_CHANGED)
518                {
519                    let bounds = cx.bounds();
520                    // If the width or height have changed then send this back up to the ScrollData.
521                    cx.emit(ScrollEvent::ChildGeo(bounds.w, bounds.h));
522                }
523            }
524
525            _ => {}
526        });
527    }
528}