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}