use core::time::Duration;
use embedded_touch::Phase;
use crate::{
animation::Animation,
event::{Event, EventContext, EventResult},
layout::ResolvedLayout,
primitives::{Dimensions, Point, ProposedDimension, ProposedDimensions, Size},
render::{Animate, Capsule, Offset, ScrollRenderable},
transition::Opacity,
view::{ViewLayout, ViewMarker},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollDirection {
#[default]
Vertical,
Horizontal,
Both,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ScrollBarConfig {
visibility: ScrollBarVisibility,
padding: u32,
width: u32,
overlaps_content: bool,
minimum_bar_length: u32,
}
impl Default for ScrollBarConfig {
fn default() -> Self {
Self {
visibility: ScrollBarVisibility::default(),
padding: 5,
width: 6,
overlaps_content: false,
minimum_bar_length: 12,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum ScrollBarVisibility {
#[default]
Always,
Never,
}
#[derive(Debug, Clone)]
pub struct ScrollView<Inner> {
inner: Inner,
bar_config: ScrollBarConfig,
direction: ScrollDirection,
}
impl<Inner: ViewMarker> ScrollView<Inner> {
#[must_use]
pub fn new(inner: Inner) -> Self {
Self {
inner,
bar_config: ScrollBarConfig::default(),
direction: ScrollDirection::default(),
}
}
#[must_use]
pub fn with_direction(mut self, direction: ScrollDirection) -> Self {
self.direction = direction;
self
}
#[must_use]
pub fn with_overlapping_bar(mut self, overlaps: bool) -> Self {
self.bar_config.overlaps_content = overlaps;
self
}
#[must_use]
pub fn with_minimum_bar_length(mut self, length: u32) -> Self {
self.bar_config.minimum_bar_length = length;
self
}
#[must_use]
pub fn with_bar_padding(mut self, padding: u32) -> Self {
self.bar_config.padding = padding;
self
}
#[must_use]
pub fn with_bar_width(mut self, width: u32) -> Self {
self.bar_config.width = width;
self
}
#[must_use]
pub fn with_bar_visibility(mut self, visibility: ScrollBarVisibility) -> Self {
self.bar_config.visibility = visibility;
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
enum ScrollInteraction {
#[default]
Idle,
Dragging {
drag_start: Point,
last_point: Point,
is_exclusive: bool,
touch_id: u8,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ContentPinning {
Floating,
Pinned(bool, bool),
}
#[derive(Debug, Clone)]
pub struct ScrollViewState<InnerState> {
scroll_offset: Point,
interaction: ScrollInteraction,
inner_state: InnerState,
content_pinning: ContentPinning,
}
impl<Inner: ViewMarker> ViewMarker for ScrollView<Inner> {
type Renderables = ScrollRenderable<Inner::Renderables>;
type Transition = Opacity;
}
impl<Inner: ViewLayout<Captures>, Captures> ViewLayout<Captures> for ScrollView<Inner> {
type State = ScrollViewState<Inner::State>;
type Sublayout = Dimensions;
fn transition(&self) -> Self::Transition {
Opacity
}
fn build_state(&self, captures: &mut Captures) -> Self::State {
Self::State {
scroll_offset: Point::zero(),
interaction: ScrollInteraction::Idle,
inner_state: self.inner.build_state(captures),
content_pinning: ContentPinning::Floating,
}
}
fn layout(
&self,
offer: &crate::primitives::ProposedDimensions,
_env: &impl crate::environment::LayoutEnvironment,
_captures: &mut Captures,
_state: &mut Self::State,
) -> ResolvedLayout<Self::Sublayout> {
let dimensions = offer.resolve_most_flexible(0, 1);
ResolvedLayout {
sublayouts: dimensions,
resolved_size: dimensions,
}
}
fn render_tree(
&self,
layout: &Self::Sublayout,
origin: crate::primitives::Point,
env: &impl crate::environment::LayoutEnvironment,
captures: &mut Captures,
state: &mut Self::State,
) -> Self::Renderables {
let (horizontal_padding, vertical_padding) = if !self.bar_config.overlaps_content
&& self.bar_config.visibility == ScrollBarVisibility::Always
{
let bar_space = self.bar_config.padding * 2 + self.bar_config.width;
match self.direction {
ScrollDirection::Vertical => (bar_space, 0),
ScrollDirection::Horizontal => (0, bar_space),
ScrollDirection::Both => (bar_space, bar_space),
}
} else {
(0, 0)
};
let (inner_width, inner_height) = match self.direction {
ScrollDirection::Vertical => (
ProposedDimension::Exact(Into::<u32>::into(layout.width) - horizontal_padding),
ProposedDimension::Compact,
),
ScrollDirection::Horizontal => (
ProposedDimension::Compact,
ProposedDimension::Exact(Into::<u32>::into(layout.height) - vertical_padding),
),
ScrollDirection::Both => (ProposedDimension::Compact, ProposedDimension::Compact),
};
let inner_offer = ProposedDimensions::new(inner_width, inner_height);
let inner_layout = self
.inner
.layout(&inner_offer, env, captures, &mut state.inner_state);
let scroll_view_width: u32 = layout.width.into();
let scroll_view_height: u32 = layout.height.into();
let inner_view_width: u32 = inner_layout.resolved_size.width.0;
let inner_view_height: u32 = inner_layout.resolved_size.height.0;
let permitted_offset_x = inner_view_width.saturating_sub(scroll_view_width) as i32;
let permitted_offset_y = inner_view_height.saturating_sub(scroll_view_height) as i32;
if state.interaction == ScrollInteraction::Idle
&& let ContentPinning::Pinned(horizontal, vertical) = state.content_pinning
{
if horizontal {
state.scroll_offset.x = -permitted_offset_x;
}
if vertical {
state.scroll_offset.y = -permitted_offset_y;
}
}
let is_dragging;
let subview_offset = match state.interaction {
ScrollInteraction::Dragging { .. } => {
is_dragging = true;
let mut offset = state.scroll_offset;
if offset.x > 0 {
offset.x /= 2;
} else if -offset.x > permitted_offset_x {
offset.x = offset.x.midpoint(permitted_offset_x) - permitted_offset_x;
}
if offset.y > 0 {
offset.y /= 2;
} else if -offset.y > permitted_offset_y {
offset.y = offset.y.midpoint(permitted_offset_y) - permitted_offset_y;
}
offset
}
ScrollInteraction::Idle => {
is_dragging = false;
state.scroll_offset.x = state.scroll_offset.x.clamp(-permitted_offset_x, 0);
state.scroll_offset.y = state.scroll_offset.y.clamp(-permitted_offset_y, 0);
state.scroll_offset
}
};
let (horizontal_bar, vertical_bar) = self.scroll_bars(
Point::zero(),
Size::new(scroll_view_width, scroll_view_height),
Size::new(inner_view_width, inner_view_height),
subview_offset,
);
let inner_origin = subview_offset;
let inner_render_tree = self.inner.render_tree(
&inner_layout.sublayouts,
Point::zero(),
env,
captures,
&mut state.inner_state,
);
let offset = Offset::new(inner_origin, inner_render_tree);
let animation_time = if is_dragging {
Duration::from_millis(0)
} else {
Duration::from_millis(300)
};
ScrollRenderable::new(
Size::new(scroll_view_width, scroll_view_height),
Size::new(inner_view_width, inner_view_height),
Offset::new(
origin,
Animate::new(
(offset, horizontal_bar, vertical_bar),
Animation::ease_out_cubic(animation_time),
env.app_time(),
is_dragging,
),
),
)
}
#[expect(clippy::too_many_lines)]
fn handle_event(
&self,
event: &crate::event::Event,
context: &EventContext,
render_tree: &mut Self::Renderables,
captures: &mut Captures,
state: &mut Self::State,
) -> EventResult {
let (result, delta) = match event {
Event::Scroll(delta) => (EventResult::new(true, true, true), *delta),
Event::Touch(touch) => {
if let ScrollInteraction::Dragging { touch_id, .. } = state.interaction
&& touch.id != touch_id
{
return self.inner.handle_event(
event,
context,
render_tree.inner_mut(),
captures,
&mut state.inner_state,
);
}
let point = touch.location.into();
match touch.phase {
Phase::Started => {
let bounds = render_tree.bounds();
if bounds.contains(&point) {
state.interaction = ScrollInteraction::Dragging {
drag_start: point,
last_point: point,
is_exclusive: false,
touch_id: touch.id,
};
(
EventResult::new(true, false, true).merging(
self.inner.handle_event(
&event.offset(
-render_tree.offset() - render_tree.inner.offset,
),
context,
render_tree.inner_mut(),
captures,
&mut state.inner_state,
),
),
Point::zero(),
)
} else {
return EventResult::new(false, false, false);
}
}
Phase::Moved => match &mut state.interaction {
ScrollInteraction::Dragging {
drag_start,
last_point,
is_exclusive,
..
} => {
let delta = point - *last_point;
*last_point = point;
let total_scroll = point - *drag_start;
if !*is_exclusive
&& (total_scroll.x.abs() >= 4 || total_scroll.y.abs() >= 4)
{
*is_exclusive = true;
let mut cancel_event = touch.clone();
cancel_event.phase = Phase::Cancelled;
(
EventResult::new(true, false, true).merging(
self.inner.handle_event(
&Event::Touch(cancel_event),
context,
render_tree.inner_mut(),
captures,
&mut state.inner_state,
),
),
delta,
)
} else {
(EventResult::new(true, false, true), delta)
}
}
ScrollInteraction::Idle => (EventResult::default(), Point::zero()),
},
Phase::Ended => match state.interaction {
ScrollInteraction::Dragging {
drag_start: _,
last_point,
is_exclusive,
..
} => {
state.interaction = ScrollInteraction::Idle;
let delta = point - last_point;
if is_exclusive {
render_tree.inner.subtree.value = true;
(EventResult::new(true, true, true), delta)
} else {
let touch_offset = render_tree.offset() + render_tree.inner.offset;
let mut touch = touch.clone();
touch.location -= touch_offset.into();
(
EventResult::new(true, true, true).merging(
self.inner.handle_event(
&Event::Touch(touch),
context,
render_tree.inner_mut(),
captures,
&mut state.inner_state,
),
),
delta,
)
}
}
ScrollInteraction::Idle => (
self.inner.handle_event(
event,
context,
render_tree.inner_mut(),
captures,
&mut state.inner_state,
),
Point::zero(),
),
},
Phase::Cancelled => {
state.interaction = ScrollInteraction::Idle;
(
EventResult::new(true, true, true).merging(self.inner.handle_event(
event,
context,
render_tree.inner_mut(),
captures,
&mut state.inner_state,
)),
Point::zero(),
)
}
Phase::Hovering(_) => (
self.inner.handle_event(
event,
context,
render_tree.inner_mut(),
captures,
&mut state.inner_state,
),
Point::zero(),
),
}
}
_ => (
self.inner.handle_event(
event,
context,
render_tree.inner_mut(),
captures,
&mut state.inner_state,
),
Point::zero(),
),
};
let delta = match self.direction {
ScrollDirection::Vertical => Point::new(0, delta.y),
ScrollDirection::Horizontal => Point::new(delta.x, 0),
ScrollDirection::Both => delta,
};
state.scroll_offset.x = state.scroll_offset.x.saturating_add(delta.x);
state.scroll_offset.y = state.scroll_offset.y.saturating_add(delta.y);
let should_pin_bottom = -state.scroll_offset.y
>= (render_tree
.inner_size
.height
.saturating_sub(render_tree.scroll_size.height)) as i32
&& state.scroll_offset.y != 0;
let should_pin_trailing = -state.scroll_offset.x
>= (render_tree
.inner_size
.width
.saturating_sub(render_tree.scroll_size.width)) as i32
&& state.scroll_offset.x != 0;
state.content_pinning = match (should_pin_trailing, should_pin_bottom) {
(false, false) => ContentPinning::Floating,
(horizontal, vertical) => ContentPinning::Pinned(horizontal, vertical),
};
if delta != Point::zero() && !result.recompute_view {
let subview_offset = {
let permitted_offset_x = render_tree
.inner_size
.width
.saturating_sub(render_tree.scroll_size.width)
as i32;
let permitted_offset_y = render_tree
.inner_size
.height
.saturating_sub(render_tree.scroll_size.height)
as i32;
let mut offset = state.scroll_offset;
if offset.x > 0 {
offset.x /= 2;
} else if -offset.x > permitted_offset_x {
offset.x = offset.x.midpoint(permitted_offset_x) - permitted_offset_x;
}
if offset.y > 0 {
offset.y /= 2;
} else if -offset.y > permitted_offset_y {
offset.y = offset.y.midpoint(permitted_offset_y) - permitted_offset_y;
}
offset
};
*render_tree.offset_mut() = subview_offset;
let (horizontal_bar, vertical_bar) = self.scroll_bars(
Point::zero(),
render_tree.scroll_size,
render_tree.inner_size,
subview_offset,
);
render_tree.set_bars(horizontal_bar, vertical_bar);
}
result
}
}
impl<V> ScrollView<V> {
#[must_use]
fn scroll_bars(
&self,
origin: Point,
scroll_size: Size,
inner_size: Size,
subview_offset: Point,
) -> (Option<Capsule>, Option<Capsule>) {
let overlap_padding = match self.direction {
ScrollDirection::Vertical | ScrollDirection::Horizontal => 0,
ScrollDirection::Both => self.bar_config.width + self.bar_config.padding,
};
let should_show_scrollbar = match self.bar_config.visibility {
ScrollBarVisibility::Always => true,
ScrollBarVisibility::Never => false,
};
let vertical_bar = if should_show_scrollbar
&& matches!(
self.direction,
ScrollDirection::Vertical | ScrollDirection::Both
) {
let (bar_y, bar_height) = bar_size(
scroll_size.height,
inner_size.height,
subview_offset.y,
self.bar_config.minimum_bar_length,
self.bar_config.padding,
self.bar_config.padding + overlap_padding,
);
let bar_x = scroll_size
.width
.saturating_sub(self.bar_config.padding)
.saturating_sub(self.bar_config.width);
Some(Capsule::new(
origin + Point::new(bar_x as i32, bar_y),
Size::new(self.bar_config.width, bar_height),
))
} else {
None
};
let horizontal_bar = if should_show_scrollbar
&& matches!(
self.direction,
ScrollDirection::Horizontal | ScrollDirection::Both
) {
let (bar_x, bar_width) = bar_size(
scroll_size.width,
inner_size.width,
subview_offset.x,
self.bar_config.minimum_bar_length,
self.bar_config.padding,
self.bar_config.padding + overlap_padding,
);
let bar_y = scroll_size
.height
.saturating_sub(self.bar_config.padding)
.saturating_sub(self.bar_config.width);
Some(Capsule::new(
origin + Point::new(bar_x, bar_y as i32),
Size::new(bar_width, self.bar_config.width),
))
} else {
None
};
(vertical_bar, horizontal_bar)
}
}
fn bar_size(
scroll_view_length: u32,
inner_view_length: u32,
scroll_offset: i32,
min_length: u32,
leading_padding: u32,
trailing_padding: u32,
) -> (i32, u32) {
let overscroll_amount = if scroll_offset > 0 {
scroll_offset as u32
} else {
let max_offset = inner_view_length.saturating_sub(scroll_view_length);
((-scroll_offset) as u32).saturating_sub(max_offset)
};
let available_space = scroll_view_length.saturating_sub(leading_padding + trailing_padding);
let perceived_scroll_length = scroll_view_length.saturating_sub(overscroll_amount);
let bar_length = ((available_space * perceived_scroll_length)
/ inner_view_length.max(scroll_view_length))
.max(min_length);
let bar_position = if inner_view_length <= scroll_view_length {
if scroll_offset < 0 {
(leading_padding + available_space.saturating_sub(bar_length)) as i32
} else {
leading_padding as i32
}
} else {
let max_travel = available_space.saturating_sub(bar_length) as i32;
let permitted_offset = (inner_view_length - scroll_view_length) as i32;
let scroll_progress = (-scroll_offset).max(0).min(permitted_offset);
leading_padding as i32 + (scroll_progress * max_travel) / permitted_offset
};
(bar_position, bar_length)
}
#[cfg(test)]
mod tests {
use super::bar_size;
#[test]
fn smaller_inner() {
let scroll_length = 100;
let inner_length = 50;
let scroll_offset = 0;
let min_length = 10;
let leading_padding = 5;
let trailing_padding = 5;
let (bar_y, bar_length) = bar_size(
scroll_length,
inner_length,
scroll_offset,
min_length,
leading_padding,
trailing_padding,
);
assert_eq!(bar_y, 5);
assert_eq!(bar_length, 90);
}
#[test]
fn equal_inner() {
let scroll_length = 100;
let inner_length = 100;
let scroll_offset = 0;
let min_length = 10;
let leading_padding = 5;
let trailing_padding = 5;
let (bar_y, bar_length) = bar_size(
scroll_length,
inner_length,
scroll_offset,
min_length,
leading_padding,
trailing_padding,
);
assert_eq!(bar_y, 5);
assert_eq!(bar_length, 90);
}
#[test]
fn double_inner_top() {
let scroll_length = 100;
let inner_length = 200;
let scroll_offset = 0;
let min_length = 10;
let leading_padding = 5;
let trailing_padding = 5;
let (bar_y, bar_length) = bar_size(
scroll_length,
inner_length,
scroll_offset,
min_length,
leading_padding,
trailing_padding,
);
assert_eq!(bar_y, 5);
assert_eq!(bar_length, 45); }
#[test]
fn double_inner_bottom() {
let scroll_length = 100;
let inner_length = 200;
let scroll_offset = -100;
let min_length = 10;
let leading_padding = 5;
let trailing_padding = 5;
let (bar_y, bar_length) = bar_size(
scroll_length,
inner_length,
scroll_offset,
min_length,
leading_padding,
trailing_padding,
);
assert_eq!(bar_y, 50);
assert_eq!(bar_length, 45); }
#[test]
fn double_inner_slight_overscroll_top() {
let scroll_length = 100;
let inner_length = 200;
let scroll_offset = 20; let min_length = 10;
let leading_padding = 5;
let trailing_padding = 5;
let (bar_y, bar_length) = bar_size(
scroll_length,
inner_length,
scroll_offset,
min_length,
leading_padding,
trailing_padding,
);
assert_eq!(bar_y, 5);
assert_eq!(bar_length, 36); }
#[test]
fn double_inner_slight_overscroll_bottom() {
let scroll_length = 100;
let inner_length = 200;
let scroll_offset = -120; let min_length = 10;
let leading_padding = 5;
let trailing_padding = 5;
let (bar_y, bar_length) = bar_size(
scroll_length,
inner_length,
scroll_offset,
min_length,
leading_padding,
trailing_padding,
);
assert_eq!(bar_y, 59);
assert_eq!(bar_length, 36); }
#[test]
fn min_length_bar_top_rest() {
let scroll_length = 100;
let inner_length = 100_000;
let scroll_offset = 0;
let min_length = 7;
let leading_padding = 10;
let trailing_padding = 10;
let (bar_y, bar_length) = bar_size(
scroll_length,
inner_length,
scroll_offset,
min_length,
leading_padding,
trailing_padding,
);
assert_eq!(bar_y, 10);
assert_eq!(bar_length, 7);
}
#[test]
fn min_length_bar_bottom_rest() {
let scroll_length = 100;
let inner_length = 100_000;
let scroll_offset = -99900;
let min_length = 9;
let leading_padding = 2;
let trailing_padding = 5;
let (bar_y, bar_length) = bar_size(
scroll_length,
inner_length,
scroll_offset,
min_length,
leading_padding,
trailing_padding,
);
assert_eq!(bar_y, 86); assert_eq!(bar_length, 9);
}
#[test]
fn half_length_bar_top_overscrolled() {
let scroll_length = 100;
let inner_length = 10;
let scroll_offset = 50; let min_length = 7;
let leading_padding = 10;
let trailing_padding = 10;
let (bar_y, bar_length) = bar_size(
scroll_length,
inner_length,
scroll_offset,
min_length,
leading_padding,
trailing_padding,
);
assert_eq!(bar_y, 10);
assert_eq!(bar_length, 40); }
#[test]
fn half_length_bar_bottom_overscrolled() {
let scroll_length = 100;
let inner_length = 10;
let scroll_offset = -50; let min_length = 9;
let leading_padding = 2;
let trailing_padding = 2;
let (bar_y, bar_length) = bar_size(
scroll_length,
inner_length,
scroll_offset,
min_length,
leading_padding,
trailing_padding,
);
assert_eq!(bar_y, 50);
assert_eq!(bar_length, 48); }
}