vizia_core/views/
scrollview.rs

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