#![allow(non_snake_case)]
use crate::composable;
use crate::modifier::{GraphicsLayer, Modifier, PointerEvent, PointerEventKind};
use crate::subcompose_layout::{Constraints, SubcomposeLayoutScope, SubcomposeMeasureScope};
use crate::widgets::box_widget::{Box, BoxSpec};
use crate::widgets::layout::{BoxWithConstraints, SubcomposeLayout};
use crate::widgets::scopes::BoxWithConstraintsScope;
use cranpose_animation::{spring, Animatable, AnimationType, Spring};
use cranpose_core::internal::FrameCallbackRegistration;
use cranpose_core::{
with_current_composer, NodeId, Owned, OwnedMutableState, RuntimeHandle, SlotId,
};
use cranpose_foundation::DRAG_THRESHOLD;
use cranpose_ui_layout::Placement;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
const DISMISS_SETTLE_EPSILON: f32 = 0.5;
const COLLAPSE_SETTLE_EPSILON: f32 = 0.01;
fn swipe_spring() -> AnimationType {
spring(Spring::DampingRatioNoBouncy, Spring::StiffnessMediumLow)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SwipeDismissSide {
Start,
End,
}
type BackgroundFn = Rc<RefCell<dyn FnMut(SwipeDismissSide)>>;
#[derive(Clone)]
pub struct SwipeToDismissSpec {
pub threshold_fraction: f32,
background: Option<BackgroundFn>,
}
impl SwipeToDismissSpec {
pub fn new() -> Self {
Self {
threshold_fraction: 0.5,
background: None,
}
}
pub fn with_threshold_fraction(mut self, fraction: f32) -> Self {
self.threshold_fraction = fraction;
self
}
pub fn with_background(mut self, background: impl FnMut(SwipeDismissSide) + 'static) -> Self {
self.background = Some(Rc::new(RefCell::new(background)));
self
}
}
impl Default for SwipeToDismissSpec {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum SwipePhase {
Idle,
Tracking {
down_x: f32,
down_y: f32,
start_offset: f32,
},
Dragging { down_x: f32, start_offset: f32 },
LockedOut,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SwipeAxisDecision {
Undecided,
Horizontal,
Vertical,
}
pub(crate) fn decide_axis(total_dx: f32, total_dy: f32, slop: f32) -> SwipeAxisDecision {
let horizontal = total_dx.abs();
let vertical = total_dy.abs();
if horizontal > slop && horizontal >= vertical {
SwipeAxisDecision::Horizontal
} else if vertical > slop && vertical > horizontal {
SwipeAxisDecision::Vertical
} else {
SwipeAxisDecision::Undecided
}
}
pub(crate) fn dismissal_target(offset: f32, width: f32, threshold_fraction: f32) -> Option<f32> {
if !width.is_finite() || width <= 0.0 {
return None;
}
let threshold = width * threshold_fraction.clamp(f32::EPSILON, 1.0);
(offset.abs() >= threshold).then(|| width * offset.signum())
}
pub(crate) fn clamp_offset(offset: f32, width: f32) -> f32 {
if width.is_finite() && width > 0.0 {
offset.clamp(-width, width)
} else {
offset
}
}
static NEXT_SWIPE_ID: AtomicU64 = AtomicU64::new(0);
struct SwipeToDismissController {
id: u64,
runtime: RuntimeHandle,
offset: RefCell<Animatable<f32>>,
revealed: OwnedMutableState<bool>,
collapse: RefCell<Animatable<f32>>,
phase: Cell<SwipePhase>,
width_px: Cell<f32>,
threshold_fraction: Cell<f32>,
on_dismiss: RefCell<Option<Rc<dyn Fn()>>>,
dismissed: Cell<bool>,
node_id: Cell<Option<NodeId>>,
settle_watcher: RefCell<Option<FrameCallbackRegistration>>,
collapse_watcher: RefCell<Option<FrameCallbackRegistration>>,
}
impl SwipeToDismissController {
fn new(runtime: RuntimeHandle) -> Rc<Self> {
Rc::new(Self {
id: NEXT_SWIPE_ID.fetch_add(1, Ordering::Relaxed),
offset: RefCell::new(Animatable::new(0.0, runtime.clone())),
revealed: OwnedMutableState::with_runtime(false, runtime.clone()),
collapse: RefCell::new(Animatable::new(1.0, runtime.clone())),
runtime,
phase: Cell::new(SwipePhase::Idle),
width_px: Cell::new(f32::NAN),
threshold_fraction: Cell::new(0.5),
on_dismiss: RefCell::new(None),
dismissed: Cell::new(false),
node_id: Cell::new(None),
settle_watcher: RefCell::new(None),
collapse_watcher: RefCell::new(None),
})
}
fn current_offset(&self) -> f32 {
self.offset.borrow().state().value()
}
fn revealed_side(&self) -> SwipeDismissSide {
if self.current_offset() >= 0.0 {
SwipeDismissSide::Start
} else {
SwipeDismissSide::End
}
}
fn collapse_fraction(&self) -> f32 {
self.collapse.borrow().state().value()
}
fn revealed(&self) -> bool {
self.revealed.value()
}
fn set_revealed(&self, revealed: bool) {
if self.revealed.get_non_reactive() != revealed {
self.revealed.set_value(revealed);
}
}
fn snap_to(&self, offset: f32) {
self.offset.borrow_mut().snapTo(offset);
self.set_revealed(offset != 0.0);
}
fn animate_to(&self, target: f32) {
self.offset.borrow_mut().animateTo(target, swipe_spring());
if target != 0.0 {
self.set_revealed(true);
}
}
}
fn swipe_gesture_modifier(base: Modifier, controller: Rc<SwipeToDismissController>) -> Modifier {
let key = controller.id;
base.pointer_input(key, move |scope| {
let controller = Rc::clone(&controller);
async move {
scope
.await_pointer_event_scope(|await_scope| async move {
loop {
let event = await_scope.await_pointer_event().await;
handle_swipe_event(&controller, &event);
}
})
.await;
}
})
}
fn handle_swipe_event(controller: &Rc<SwipeToDismissController>, event: &PointerEvent) {
if event.id != 0 && event.kind != PointerEventKind::Cancel {
return;
}
match event.kind {
PointerEventKind::Down => {
if event.is_consumed() {
return;
}
let current = controller.current_offset();
controller.snap_to(current);
controller.phase.set(SwipePhase::Tracking {
down_x: event.global_position.x,
down_y: event.global_position.y,
start_offset: current,
});
}
PointerEventKind::Move => {
if event.is_consumed() {
if matches!(controller.phase.get(), SwipePhase::Dragging { .. }) {
animate_spring_back(controller);
}
controller.phase.set(SwipePhase::Idle);
return;
}
match controller.phase.get() {
SwipePhase::Tracking {
down_x,
down_y,
start_offset,
} => {
let total_dx = event.global_position.x - down_x;
let total_dy = event.global_position.y - down_y;
match decide_axis(total_dx, total_dy, DRAG_THRESHOLD) {
SwipeAxisDecision::Horizontal => {
controller.phase.set(SwipePhase::Dragging {
down_x,
start_offset,
});
let width = controller.width_px.get();
controller.snap_to(clamp_offset(start_offset + total_dx, width));
event.consume();
}
SwipeAxisDecision::Vertical => {
controller.phase.set(SwipePhase::LockedOut);
}
SwipeAxisDecision::Undecided => {}
}
}
SwipePhase::Dragging {
down_x,
start_offset,
} => {
let total_dx = event.global_position.x - down_x;
let width = controller.width_px.get();
controller.snap_to(clamp_offset(start_offset + total_dx, width));
event.consume();
}
SwipePhase::Idle | SwipePhase::LockedOut => {}
}
}
PointerEventKind::Up => {
let phase = controller.phase.get();
controller.phase.set(SwipePhase::Idle);
if let SwipePhase::Dragging { .. } = phase {
settle_release(controller);
event.consume();
}
}
PointerEventKind::Cancel => {
if matches!(controller.phase.get(), SwipePhase::Dragging { .. }) {
animate_spring_back(controller);
}
controller.phase.set(SwipePhase::Idle);
}
PointerEventKind::Scroll
| PointerEventKind::Zoom
| PointerEventKind::Enter
| PointerEventKind::Exit => {}
}
}
fn settle_release(controller: &Rc<SwipeToDismissController>) {
let offset = controller.current_offset();
let width = controller.width_px.get();
match dismissal_target(offset, width, controller.threshold_fraction.get()) {
Some(target) => animate_dismiss(controller, target),
None => animate_spring_back(controller),
}
}
fn animate_dismiss(controller: &Rc<SwipeToDismissController>, target: f32) {
controller.animate_to(target);
watch_settle(controller, true);
}
fn animate_spring_back(controller: &Rc<SwipeToDismissController>) {
controller.animate_to(0.0);
watch_settle(controller, false);
}
fn watch_settle(controller: &Rc<SwipeToDismissController>, dismissing: bool) {
let weak = Rc::downgrade(controller);
let registration =
controller
.runtime
.frame_clock()
.with_frame_nanos(move |_frame_time_nanos| {
let Some(controller) = weak.upgrade() else {
return;
};
controller.settle_watcher.borrow_mut().take();
if dismissing && controller.dismissed.get() {
return;
}
if matches!(controller.phase.get(), SwipePhase::Dragging { .. }) {
return;
}
let target = controller.offset.borrow().target();
let value = controller.current_offset();
if (value - target).abs() <= DISMISS_SETTLE_EPSILON {
controller.set_revealed(false);
if dismissing && !controller.dismissed.get() {
controller.dismissed.set(true);
start_collapse(&controller);
let on_dismiss = controller.on_dismiss.borrow().clone();
if let Some(on_dismiss) = on_dismiss {
on_dismiss();
}
}
} else {
watch_settle(&controller, dismissing);
}
});
*controller.settle_watcher.borrow_mut() = Some(registration);
}
fn start_collapse(controller: &Rc<SwipeToDismissController>) {
controller
.collapse
.borrow_mut()
.animateTo(0.0, swipe_spring());
watch_collapse(controller);
}
fn watch_collapse(controller: &Rc<SwipeToDismissController>) {
let weak = Rc::downgrade(controller);
let registration =
controller
.runtime
.frame_clock()
.with_frame_nanos(move |_frame_time_nanos| {
let Some(controller) = weak.upgrade() else {
return;
};
controller.collapse_watcher.borrow_mut().take();
if let Some(node_id) = controller.node_id.get() {
crate::schedule_measure_repass(node_id);
}
crate::request_render_invalidation();
if controller.collapse_fraction() > COLLAPSE_SETTLE_EPSILON {
watch_collapse(&controller);
}
});
*controller.collapse_watcher.borrow_mut() = Some(registration);
}
#[composable(no_skip)]
pub fn SwipeToDismiss<D, F>(
modifier: Modifier,
spec: SwipeToDismissSpec,
on_dismiss: D,
content: F,
) -> cranpose_core::NodeId
where
D: Fn() + 'static,
F: FnMut() + 'static,
{
let controller: Rc<SwipeToDismissController> = with_current_composer(|composer| {
let runtime = composer.runtime_handle();
let owned: Owned<Rc<SwipeToDismissController>> =
composer.remember(|| SwipeToDismissController::new(runtime));
owned.with(Rc::clone)
});
controller
.threshold_fraction
.set(spec.threshold_fraction.clamp(f32::EPSILON, 1.0));
*controller.on_dismiss.borrow_mut() = Some(Rc::new(on_dismiss));
let background = spec.background.clone();
let content = Rc::new(RefCell::new(content));
let gesture_modifier = swipe_gesture_modifier(modifier, Rc::clone(&controller));
let controller_for_layout = Rc::clone(&controller);
let node = SubcomposeLayout(Modifier::empty(), move |scope, constraints| {
let collapse = controller_for_layout.collapse_fraction().clamp(0.0, 1.0);
let gesture_modifier = gesture_modifier.clone();
let background = background.clone();
let content = Rc::clone(&content);
let controller_for_row = Rc::clone(&controller_for_layout);
let measurables = scope.subcompose(SlotId::new(0), move || {
let background = background.clone();
let content = Rc::clone(&content);
let controller_for_row = Rc::clone(&controller_for_row);
BoxWithConstraints(gesture_modifier.clone(), move |row_scope| {
controller_for_row
.width_px
.set(row_scope.constraints().max_width);
if controller_for_row.revealed() {
if let Some(background) = &background {
let background = Rc::clone(background);
let side = controller_for_row.revealed_side();
Box(Modifier::empty(), BoxSpec::new(), move || {
(background.borrow_mut())(side);
});
}
}
let content = Rc::clone(&content);
let controller_for_layer = Rc::clone(&controller_for_row);
Box(
Modifier::empty().graphics_layer(move || GraphicsLayer {
translation_x: controller_for_layer.current_offset(),
..GraphicsLayer::default()
}),
BoxSpec::new(),
move || {
(content.borrow_mut())();
},
);
});
});
let child_constraints = Constraints {
min_width: constraints.min_width,
max_width: constraints.max_width,
min_height: 0.0,
max_height: constraints.max_height,
};
let mut width = 0.0_f32;
let mut natural_height = 0.0_f32;
let mut placements = Vec::with_capacity(measurables.len());
for measurable in measurables {
let placeable = scope.measure(measurable, child_constraints);
width = width.max(placeable.width());
natural_height = natural_height.max(placeable.height());
placeable.place(0.0, 0.0);
placements.push(Placement::new(placeable.node_id(), 0.0, 0.0, 0));
}
width = width.clamp(constraints.min_width, constraints.max_width);
let height = (natural_height * collapse).clamp(0.0, constraints.max_height);
scope.layout(width, height, placements)
});
controller.node_id.set(Some(node));
node
}
#[cfg(test)]
#[path = "../tests/swipe_to_dismiss_tests.rs"]
mod tests;