Skip to main content

cranpose_ui/widgets/
animated_content.rs

1//! `AnimatedContent`: content that changes with a transition.
2//!
3//! Mirrors Jetpack Compose's `AnimatedContent` from
4//! `androidx.compose.animation`. Each content the container shows has its
5//! own progress, from `0.0` (gone) to `1.0` (at rest), which its layer reads
6//! while it draws: a running transition moves layers and recomposes nothing.
7//! Content that left is removed on the frame its exit ends, and a
8//! [`SizeTransform`] animates the container's size in layout.
9
10use std::{
11    cell::{Cell, RefCell},
12    rc::Rc,
13};
14
15use cranpose_animation::{Animatable, AnimationType};
16use cranpose_core::{
17    MutableState, NodeId, RuntimeHandle, internal::FrameCallbackRegistration, remember,
18    with_current_composer, with_key,
19};
20use cranpose_ui_graphics::{Size, WindowCoordinates};
21use cranpose_ui_layout::{
22    Constraints, Measurable, MeasurePolicy, MeasureResult, MeasureScope, Placement,
23};
24
25use crate::{
26    composable,
27    modifier::{GraphicsLayer, Modifier},
28    widgets::{
29        animated_visibility::{EnterTransition, ExitTransition, TransitionEffects},
30        box_widget::{Box, BoxSpec},
31        layout::Layout,
32        shared_transition::{ProvideSharedEntry, SharedEntry, in_shared_transition},
33    },
34};
35
36/// Progress below which leaving content is gone and leaves the composition.
37const GONE: f32 = 0.001;
38
39/// How an [`AnimatedContent`] changes from one content to the next.
40///
41/// Mirrors Jetpack Compose's `ContentTransform`: build one with
42/// `enter.together_with(exit)` and add a size animation with
43/// [`using`](Self::using).
44#[derive(Clone, Copy, Debug, PartialEq)]
45pub struct ContentTransform {
46    /// Transition that plays on the content that comes in.
47    pub enter: EnterTransition,
48    /// Transition that plays on the content that goes out.
49    pub exit: ExitTransition,
50    /// How the container's size follows the content's; `None` keeps the
51    /// container as large as the largest content during the change.
52    pub size_transform: Option<SizeTransform>,
53}
54
55impl ContentTransform {
56    /// Animates the container's size with `size_transform`. Compose's
57    /// `transform using SizeTransform()`.
58    pub fn using(mut self, size_transform: SizeTransform) -> Self {
59        self.size_transform = Some(size_transform);
60        self
61    }
62}
63
64/// Animates an [`AnimatedContent`]'s size from the content that leaves to
65/// the content that comes. Compose's `SizeTransform`.
66#[derive(Clone, Copy, Debug, PartialEq)]
67pub struct SizeTransform {
68    /// Whether content outside the animating size is clipped.
69    pub clip: bool,
70    /// Animation that drives the size change.
71    pub animation: AnimationType,
72}
73
74impl Default for SizeTransform {
75    fn default() -> Self {
76        Self {
77            clip: true,
78            animation: AnimationType::default(),
79        }
80    }
81}
82
83/// A gesture part of the way from the current content toward another, as
84/// predictive back is: the content `toward` shows at `fraction` and the
85/// current content at `1.0 - fraction`, each with the transition from the
86/// current content to `toward`.
87#[derive(Clone, Debug, PartialEq)]
88pub struct ContentSeek<T> {
89    /// The content that the gesture heads toward.
90    pub toward: T,
91    /// How far the gesture has come toward `toward`, from 0.0 (still at the current content) to 1.0
92    /// (at `toward`).
93    pub fraction: f32,
94}
95
96/// How far one content is in the container, and how it shows on the way.
97pub(crate) struct EntryMotion {
98    progress: RefCell<Animatable<f32>>,
99    size: Rc<Cell<Size>>,
100    effects: Cell<TransitionEffects>,
101    animation: Cell<AnimationType>,
102    /// Whether the content goes, rather than comes or rests.
103    pub(crate) leaving: Cell<bool>,
104    /// Where the content's box is, its layer included, inside a shared
105    /// transition layout.
106    coordinates: Rc<Cell<WindowCoordinates>>,
107}
108
109impl EntryMotion {
110    pub(crate) fn progress(&self) -> f32 {
111        self.progress.borrow().state().value()
112    }
113
114    fn layer(&self) -> GraphicsLayer {
115        self.effects.get().layer(self.progress(), self.size.get())
116    }
117
118    /// Animates toward `target` unless it already runs there or rests there.
119    /// Composition calls it, and reads nothing it would recompose by.
120    fn animate_to(&self, target: f32) {
121        let mut progress = self.progress.borrow_mut();
122        let settled = if progress.is_running() {
123            progress.target()
124        } else {
125            progress.state().get_non_reactive()
126        };
127        if (settled - target).abs() > f32::EPSILON {
128            progress.animateTo(target, self.animation.get());
129        }
130    }
131
132    fn snap_to(&self, target: f32) {
133        self.progress.borrow_mut().snapTo(target);
134    }
135
136    /// Shown in full with nothing running: the content a change leaves
137    /// takes the change's exit transition only from here.
138    fn at_rest(&self) -> bool {
139        let progress = self.progress.borrow();
140        !progress.is_running() && progress.state().get_non_reactive() >= 1.0 - GONE
141    }
142
143    fn gone(&self) -> bool {
144        let progress = self.progress.borrow();
145        !progress.is_running() && progress.state().get_non_reactive() <= GONE
146    }
147}
148
149struct Entry<T> {
150    id: u64,
151    value: T,
152    motion: Rc<EntryMotion>,
153}
154
155type ContentFn<T> = std::boxed::Box<dyn FnMut(T)>;
156
157struct ContentState<T> {
158    runtime: RuntimeHandle,
159    /// Leaving content first, the current content last, on top.
160    entries: RefCell<Vec<Entry<T>>>,
161    next_id: Cell<u64>,
162    last_target: RefCell<Option<T>>,
163    content: RefCell<Option<ContentFn<T>>>,
164    size_transform: Cell<Option<SizeTransform>>,
165    size: RefCell<Option<Animatable<Size>>>,
166    /// Where the container is, inside a shared transition layout.
167    coordinates: Rc<Cell<WindowCoordinates>>,
168    /// The container's size from its last measure, where a size animation
169    /// starts.
170    last_size: Cell<Size>,
171    node: Cell<Option<NodeId>>,
172    /// Bumped when leaving content is removed, so the container recomposes
173    /// without it.
174    removed: MutableState<u64>,
175    watcher: RefCell<Option<FrameCallbackRegistration>>,
176}
177
178impl<T: Clone + PartialEq + 'static> ContentState<T> {
179    fn new(runtime: RuntimeHandle) -> Self {
180        Self {
181            removed: MutableState::with_runtime(0, runtime.clone()),
182            runtime,
183            entries: RefCell::new(Vec::new()),
184            next_id: Cell::new(0),
185            last_target: RefCell::new(None),
186            content: RefCell::new(None),
187            size_transform: Cell::new(None),
188            size: RefCell::new(None),
189            last_size: Cell::new(Size::ZERO),
190            coordinates: Rc::default(),
191            node: Cell::new(None),
192            watcher: RefCell::new(None),
193        }
194    }
195
196    fn push(&self, value: T, progress: f32, effects: TransitionEffects, animation: AnimationType) {
197        let id = self.next_id.get();
198        self.next_id.set(id.wrapping_add(1));
199        self.entries.borrow_mut().push(Entry {
200            id,
201            value,
202            motion: Rc::new(EntryMotion {
203                progress: RefCell::new(Animatable::new(progress, self.runtime.clone())),
204                size: Rc::new(Cell::new(Size::ZERO)),
205                effects: Cell::new(effects),
206                animation: Cell::new(animation),
207                leaving: Cell::new(false),
208                coordinates: Rc::default(),
209            }),
210        });
211    }
212
213    /// Brings the entries to `target`: it comes in with the transition from
214    /// the previous target, everything else leaves, and a seek holds both
215    /// ends where the gesture is.
216    fn sync(
217        &self,
218        target: &T,
219        seek: Option<&ContentSeek<T>>,
220        transition: &dyn Fn(&T, &T) -> ContentTransform,
221    ) {
222        let previous = self.last_target.replace(Some(target.clone()));
223        let change = previous
224            .filter(|previous| previous != target)
225            .map(|previous| transition(&previous, target));
226        if let Some(change) = change {
227            self.size_transform.set(change.size_transform);
228        }
229        let first = self.entries.borrow().is_empty();
230        if !self
231            .entries
232            .borrow()
233            .iter()
234            .any(|entry| entry.value == *target)
235        {
236            let enter = change.map_or_else(EnterTransition::none, |change| change.enter);
237            let progress = if first { 1.0 } else { 0.0 };
238            self.push(target.clone(), progress, enter.effects(), enter.animation());
239        }
240        let seek = seek.filter(|seek| seek.toward != *target);
241        let seeking = seek.map(|seek| (seek, transition(target, &seek.toward)));
242        if let Some((seek, _)) = seeking
243            && !self
244                .entries
245                .borrow()
246                .iter()
247                .any(|entry| entry.value == seek.toward)
248        {
249            self.push(
250                seek.toward.clone(),
251                0.0,
252                TransitionEffects::default(),
253                AnimationType::default(),
254            );
255        }
256        let mut entries = self.entries.borrow_mut();
257        for entry in entries.iter_mut() {
258            let motion = &entry.motion;
259            if entry.value == *target {
260                // New content took its enter transition when it was added;
261                // content that turns back runs its own transition backward.
262                // A gesture that seeks away from it makes it the content that
263                // goes, for the shared elements in it.
264                entry.motion.leaving.set(seeking.is_some());
265                match seeking {
266                    Some((seek, seek_change)) => {
267                        motion.effects.set(seek_change.exit.effects());
268                        motion.snap_to(1.0 - seek.fraction.clamp(0.0, 1.0));
269                    }
270                    None => motion.animate_to(1.0),
271                }
272            } else if let Some((seek, seek_change)) =
273                seeking.filter(|(seek, _)| seek.toward == entry.value)
274            {
275                entry.motion.leaving.set(false);
276                motion.effects.set(seek_change.enter.effects());
277                motion.animation.set(seek_change.enter.animation());
278                motion.snap_to(seek.fraction.clamp(0.0, 1.0));
279            } else {
280                if !entry.motion.leaving.replace(true)
281                    && let Some(change) = change.filter(|_| motion.at_rest())
282                {
283                    motion.effects.set(change.exit.effects());
284                    motion.animation.set(change.exit.animation());
285                }
286                motion.animate_to(0.0);
287            }
288        }
289        // The current content draws on top.
290        if let Some(index) = entries.iter().position(|entry| entry.value == *target) {
291            let current = entries.remove(index);
292            entries.push(current);
293        }
294    }
295
296    fn animating(&self) -> bool {
297        self.entries
298            .borrow()
299            .iter()
300            .any(|entry| entry.motion.leaving.get())
301            || self
302                .size
303                .borrow()
304                .as_ref()
305                .is_some_and(Animatable::is_running)
306    }
307}
308
309/// Removes leaving content on the frame its exit ends, and measures the
310/// container again while its size animates.
311fn watch<T: Clone + PartialEq + 'static>(state: &Rc<ContentState<T>>) {
312    if state.watcher.borrow().is_some() || !state.animating() {
313        return;
314    }
315    let weak = Rc::downgrade(state);
316    let registration = state.runtime.frame_clock().with_frame_nanos(move |_| {
317        let Some(state) = weak.upgrade() else {
318            return;
319        };
320        state.watcher.borrow_mut().take();
321        let removed = {
322            let mut entries = state.entries.borrow_mut();
323            let before = entries.len();
324            // The current content is last; a seek all the way does not
325            // remove it.
326            let current = entries.last().map(|entry| entry.id);
327            entries.retain(|entry| {
328                Some(entry.id) == current || !(entry.motion.leaving.get() && entry.motion.gone())
329            });
330            before != entries.len()
331        };
332        if removed {
333            state.removed.update(|count| *count = count.wrapping_add(1));
334        }
335        if state
336            .size
337            .borrow()
338            .as_ref()
339            .is_some_and(Animatable::is_running)
340            && let Some(node) = state.node.get()
341        {
342            crate::schedule_measure_repass(node);
343            crate::request_render_invalidation();
344        }
345        watch(&state);
346    });
347    *state.watcher.borrow_mut() = Some(registration);
348}
349
350struct StateHandle<T>(Rc<ContentState<T>>);
351
352impl<T> Clone for StateHandle<T> {
353    fn clone(&self) -> Self {
354        Self(Rc::clone(&self.0))
355    }
356}
357
358impl<T> PartialEq for StateHandle<T> {
359    fn eq(&self, other: &Self) -> bool {
360        Rc::ptr_eq(&self.0, &other.0)
361    }
362}
363
364/// Changes `content` with a transition when `target_state` changes.
365///
366/// `transition_spec` receives the state that goes and the state that comes,
367/// and answers how they change: `fade_in().together_with(fade_out())`,
368/// with `.using(SizeTransform::default())` to animate the size between
369/// them. The first content shows at rest. Content that leaves stays
370/// composed until its exit ends; a change back while it leaves brings it
371/// back from where it was.
372///
373/// Mirrors Jetpack Compose:
374/// `AnimatedContent(targetState, transitionSpec = { ... }) { state -> ... }`.
375#[composable(no_skip)]
376pub fn AnimatedContent<T, F>(
377    target_state: T,
378    transition_spec: impl Fn(&T, &T) -> ContentTransform + 'static,
379    content: F,
380) where
381    T: Clone + PartialEq + 'static,
382    F: FnMut(T) + 'static,
383{
384    SeekableAnimatedContent(target_state, None, transition_spec, content);
385}
386
387/// [`AnimatedContent`] that a gesture can hold part of the way toward
388/// another state, as predictive back does: while `seek` is set, its
389/// `toward` content shows at its fraction. When the gesture ends the change
390/// finishes or goes back from where it stood, depending on whether the
391/// target became `toward`.
392#[composable(no_skip)]
393pub fn SeekableAnimatedContent<T, F>(
394    target_state: T,
395    seek: Option<ContentSeek<T>>,
396    transition_spec: impl Fn(&T, &T) -> ContentTransform + 'static,
397    content: F,
398) where
399    T: Clone + PartialEq + 'static,
400    F: FnMut(T) + 'static,
401{
402    let runtime = with_current_composer(|composer| composer.runtime_handle());
403    let state =
404        remember(|| StateHandle(Rc::new(ContentState::<T>::new(runtime)))).with(StateHandle::clone);
405    *state.0.content.borrow_mut() = Some(std::boxed::Box::new(content));
406    state.0.sync(&target_state, seek.as_ref(), &transition_spec);
407    watch(&state.0);
408    // Subscribes this scope to removals of content that left.
409    state.0.removed.get();
410    let clip = state
411        .0
412        .size_transform
413        .get()
414        .is_some_and(|transform| transform.clip);
415    let modifier = if clip {
416        Modifier::empty().clip_to_bounds()
417    } else {
418        Modifier::empty()
419    };
420    // Inside a shared transition layout the container and its contents say
421    // where they are, which costs the layer fast paths; elsewhere they don't.
422    let shared = in_shared_transition();
423    let modifier = if shared {
424        modifier.report_window_coordinates(Rc::clone(&state.0.coordinates))
425    } else {
426        modifier
427    };
428    let policy = ContentMeasurePolicy(state.clone());
429    let shown = state.clone();
430    let node = Layout(modifier, policy, move || {
431        let state = &shown;
432        let entries: Vec<(u64, T, Rc<EntryMotion>)> = state
433            .0
434            .entries
435            .borrow()
436            .iter()
437            .map(|entry| (entry.id, entry.value.clone(), Rc::clone(&entry.motion)))
438            .collect();
439        for (id, value, motion) in entries {
440            let state = state.clone();
441            with_key(&id, move || {
442                let layer = Rc::clone(&motion);
443                let entry = Modifier::empty().report_size(Rc::clone(&motion.size));
444                let entry = if shared {
445                    entry.report_window_coordinates(Rc::clone(&motion.coordinates))
446                } else {
447                    entry
448                };
449                let shared_entry = shared.then(|| SharedEntry {
450                    motion: Rc::clone(&motion),
451                    entry: Rc::clone(&motion.coordinates),
452                    container: Rc::clone(&state.0.coordinates),
453                });
454                Box(
455                    entry.graphics_layer(move || layer.layer()),
456                    BoxSpec::new(),
457                    move || {
458                        let compose = || {
459                            if let Some(content) = state.0.content.borrow_mut().as_mut() {
460                                content(value.clone());
461                            }
462                        };
463                        match shared_entry.clone() {
464                            Some(entry) => ProvideSharedEntry(entry, compose),
465                            None => compose(),
466                        }
467                    },
468                );
469            });
470        }
471    });
472    state.0.node.set(Some(node));
473}
474
475/// Places every content at the top start; the container is as large as the
476/// largest content, or follows the current content's size through a
477/// [`SizeTransform`].
478struct ContentMeasurePolicy<T>(StateHandle<T>);
479
480impl<T> Clone for ContentMeasurePolicy<T> {
481    fn clone(&self) -> Self {
482        Self(self.0.clone())
483    }
484}
485
486impl<T> PartialEq for ContentMeasurePolicy<T> {
487    fn eq(&self, other: &Self) -> bool {
488        self.0 == other.0
489    }
490}
491
492impl<T: Clone + PartialEq + 'static> MeasurePolicy for ContentMeasurePolicy<T> {
493    fn measure(
494        &self,
495        _scope: &dyn MeasureScope,
496        measurables: &[Box<dyn Measurable>],
497        constraints: Constraints,
498    ) -> MeasureResult {
499        let state = &self.0.0;
500        let loose = Constraints {
501            min_width: 0.0,
502            min_height: 0.0,
503            ..constraints
504        };
505        let mut largest = Size::ZERO;
506        let mut current = Size::ZERO;
507        let mut placements = Vec::with_capacity(measurables.len());
508        for measurable in measurables {
509            let placeable = measurable.measure(loose);
510            let size = Size::new(placeable.width(), placeable.height());
511            largest = Size::new(
512                largest.width.max(size.width),
513                largest.height.max(size.height),
514            );
515            // The current content is the last.
516            current = size;
517            placeable.place(0.0, 0.0);
518            placements.push(Placement::new(placeable.node_id(), 0.0, 0.0, 0));
519        }
520        let size = match state.size_transform.get() {
521            Some(transform) => {
522                let mut animated = state.size.borrow_mut();
523                let animated = animated.get_or_insert_with(|| {
524                    Animatable::new(state.last_size.get(), state.runtime.clone())
525                });
526                if animated.target() != current {
527                    animated.animateTo(current, transform.animation);
528                }
529                animated.state().value()
530            }
531            None => {
532                state.size.borrow_mut().take();
533                largest
534            }
535        };
536        let size = Size::new(
537            size.width
538                .clamp(constraints.min_width, constraints.max_width),
539            size.height
540                .clamp(constraints.min_height, constraints.max_height),
541        );
542        state.last_size.set(size);
543        MeasureResult::new(size, placements)
544    }
545
546    fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
547        measurables
548            .iter()
549            .map(|measurable| measurable.min_intrinsic_width(height))
550            .fold(0.0, f32::max)
551    }
552
553    fn max_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
554        measurables
555            .iter()
556            .map(|measurable| measurable.max_intrinsic_width(height))
557            .fold(0.0, f32::max)
558    }
559
560    fn min_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
561        measurables
562            .iter()
563            .map(|measurable| measurable.min_intrinsic_height(width))
564            .fold(0.0, f32::max)
565    }
566
567    fn max_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
568        measurables
569            .iter()
570            .map(|measurable| measurable.max_intrinsic_height(width))
571            .fold(0.0, f32::max)
572    }
573}