Skip to main content

cranpose_ui/widgets/
animated_visibility.rs

1//! AnimatedVisibility composable
2//!
3//! Mirrors Jetpack Compose's `AnimatedVisibility` from
4//! `androidx.compose.animation.AnimatedVisibility`, with a minimal set of
5//! enter/exit transitions: `fade_in`/`fade_out` and
6//! `slide_in_vertically`/`slide_out_vertically`.
7
8#![allow(non_snake_case)]
9
10use crate::composable;
11use crate::modifier::{GraphicsLayer, Modifier};
12use crate::widgets::box_widget::{Box, BoxSpec};
13use crate::widgets::crossfade::animate_float_with_initial;
14use cranpose_animation::AnimationType;
15
16/// Progress below which exiting content is considered fully hidden and can
17/// leave the composition.
18const VISIBILITY_PROGRESS_EPSILON: f32 = 0.001;
19
20/// Defines how an [`AnimatedVisibility`] content appears.
21///
22/// Mirrors Jetpack Compose's `EnterTransition`. Transitions are combined
23/// with `+`, like Compose's `fadeIn() + slideInVertically()`.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct EnterTransition {
26    fade: bool,
27    slide_vertical_fraction: Option<f32>,
28    animation: Option<AnimationType>,
29}
30
31impl EnterTransition {
32    /// An empty enter transition: content appears without any effect.
33    ///
34    /// Mirrors Jetpack Compose: `EnterTransition.None`.
35    pub fn none() -> Self {
36        Self {
37            fade: false,
38            slide_vertical_fraction: None,
39            animation: None,
40        }
41    }
42
43    /// Use a custom animation spec for this transition. In Compose the spec
44    /// is a parameter of each transition factory (e.g.
45    /// `fadeIn(animationSpec = tween(300))`); here it is a builder method:
46    /// `fade_in().with_animation(tween(300, Easing::LinearEasing))`.
47    pub fn with_animation(mut self, animation: AnimationType) -> Self {
48        self.animation = Some(animation);
49        self
50    }
51
52    pub(crate) fn has_fade(&self) -> bool {
53        self.fade
54    }
55
56    pub(crate) fn slide_vertical_fraction(&self) -> Option<f32> {
57        self.slide_vertical_fraction
58    }
59
60    pub(crate) fn animation(&self) -> AnimationType {
61        self.animation.unwrap_or_default()
62    }
63}
64
65impl std::ops::Add for EnterTransition {
66    type Output = Self;
67
68    /// Combines two enter transitions, mirroring Compose's
69    /// `EnterTransition.plus`: the resulting transition applies every effect
70    /// of both operands. The left-hand animation spec wins when both sides
71    /// carry one.
72    fn add(self, other: Self) -> Self {
73        Self {
74            fade: self.fade || other.fade,
75            slide_vertical_fraction: self
76                .slide_vertical_fraction
77                .or(other.slide_vertical_fraction),
78            animation: self.animation.or(other.animation),
79        }
80    }
81}
82
83/// Defines how an [`AnimatedVisibility`] content disappears.
84///
85/// Mirrors Jetpack Compose's `ExitTransition`. Transitions are combined
86/// with `+`, like Compose's `fadeOut() + slideOutVertically()`.
87#[derive(Clone, Copy, Debug, PartialEq)]
88pub struct ExitTransition {
89    fade: bool,
90    slide_vertical_fraction: Option<f32>,
91    animation: Option<AnimationType>,
92}
93
94impl ExitTransition {
95    /// An empty exit transition: content disappears without any effect.
96    ///
97    /// Mirrors Jetpack Compose: `ExitTransition.None`.
98    pub fn none() -> Self {
99        Self {
100            fade: false,
101            slide_vertical_fraction: None,
102            animation: None,
103        }
104    }
105
106    /// Use a custom animation spec for this transition (see
107    /// [`EnterTransition::with_animation`]).
108    pub fn with_animation(mut self, animation: AnimationType) -> Self {
109        self.animation = Some(animation);
110        self
111    }
112
113    pub(crate) fn has_fade(&self) -> bool {
114        self.fade
115    }
116
117    pub(crate) fn slide_vertical_fraction(&self) -> Option<f32> {
118        self.slide_vertical_fraction
119    }
120
121    pub(crate) fn animation(&self) -> AnimationType {
122        self.animation.unwrap_or_default()
123    }
124}
125
126impl std::ops::Add for ExitTransition {
127    type Output = Self;
128
129    /// Combines two exit transitions, mirroring Compose's
130    /// `ExitTransition.plus`: the resulting transition applies every effect
131    /// of both operands. The left-hand animation spec wins when both sides
132    /// carry one.
133    fn add(self, other: Self) -> Self {
134        Self {
135            fade: self.fade || other.fade,
136            slide_vertical_fraction: self
137                .slide_vertical_fraction
138                .or(other.slide_vertical_fraction),
139            animation: self.animation.or(other.animation),
140        }
141    }
142}
143
144/// Fades in the content of an [`AnimatedVisibility`], from transparent to
145/// fully opaque.
146///
147/// Mirrors Jetpack Compose: `fadeIn()`.
148pub fn fade_in() -> EnterTransition {
149    EnterTransition {
150        fade: true,
151        ..EnterTransition::none()
152    }
153}
154
155/// Fades out the content of an [`AnimatedVisibility`], from fully opaque to
156/// transparent.
157///
158/// Mirrors Jetpack Compose: `fadeOut()`.
159pub fn fade_out() -> ExitTransition {
160    ExitTransition {
161        fade: true,
162        ..ExitTransition::none()
163    }
164}
165
166/// Slides in the content vertically from `initial_offset_fraction` of its
167/// own height (negative values start above the resting position, mirroring
168/// Compose's default of `-fullHeight / 2`).
169///
170/// Mirrors Jetpack Compose: `slideInVertically { fullHeight -> ... }`, with
171/// the offset expressed as a fraction of the content height instead of a
172/// lambda over the measured height.
173pub fn slide_in_vertically(initial_offset_fraction: f32) -> EnterTransition {
174    EnterTransition {
175        slide_vertical_fraction: Some(initial_offset_fraction),
176        ..EnterTransition::none()
177    }
178}
179
180/// Slides out the content vertically towards `target_offset_fraction` of
181/// its own height.
182///
183/// Mirrors Jetpack Compose: `slideOutVertically { fullHeight -> ... }`, with
184/// the offset expressed as a fraction of the content height instead of a
185/// lambda over the measured height.
186pub fn slide_out_vertically(target_offset_fraction: f32) -> ExitTransition {
187    ExitTransition {
188        slide_vertical_fraction: Some(target_offset_fraction),
189        ..ExitTransition::none()
190    }
191}
192
193/// Animates the appearance and disappearance of its content when `visible`
194/// changes.
195///
196/// Mirrors Jetpack Compose:
197/// `AnimatedVisibility(visible, enter = ..., exit = ...) { ... }`.
198///
199/// Compose semantics: content that is visible on first composition appears
200/// without an animation; when `visible` turns `false` the content stays
201/// composed for the whole exit transition and leaves the composition once it
202/// completes; toggling `visible` mid-transition retargets the running
203/// animation from its current value.
204///
205/// Divergence from Compose: enter and exit each drive a single shared
206/// progress (instead of one `Transition` animation per effect), so all
207/// effects of a combined transition share one animation spec. Exiting with
208/// `ExitTransition::none()` still holds the content for the duration of the
209/// exit spec before removal.
210#[composable]
211pub fn AnimatedVisibility<F>(
212    visible: bool,
213    enter: EnterTransition,
214    exit: ExitTransition,
215    content: F,
216) where
217    F: FnMut() + 'static,
218{
219    let target = if visible { 1.0 } else { 0.0 };
220    let animation = if visible {
221        enter.animation()
222    } else {
223        exit.animation()
224    };
225    // First composition seeds the progress at the target so an initially
226    // visible content appears without an enter animation, like Compose.
227    let progress_state = animate_float_with_initial(target, target, animation);
228    // Reading here subscribes this recompose scope: each animation frame
229    // re-evaluates whether the exiting content can leave the composition.
230    let progress = progress_state.value();
231
232    let composed = visible || progress > VISIBILITY_PROGRESS_EPSILON;
233    if composed {
234        let fade = if visible {
235            enter.has_fade()
236        } else {
237            exit.has_fade()
238        };
239        let slide_fraction = if visible {
240            enter.slide_vertical_fraction()
241        } else {
242            exit.slide_vertical_fraction()
243        };
244
245        let alpha = if fade { progress.clamp(0.0, 1.0) } else { 1.0 };
246        let mut modifier = Modifier::empty().graphics_layer_value(GraphicsLayer {
247            alpha,
248            ..Default::default()
249        });
250        if let Some(fraction) = slide_fraction {
251            modifier = modifier.offset_fraction(0.0, fraction * (1.0 - progress));
252        }
253
254        Box(modifier, BoxSpec::new(), content);
255    }
256}