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::{ContainerFrame, 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    /// The container that slides across its edge measure from.
101    container: Rc<Cell<ContainerFrame>>,
102    effects: Cell<TransitionEffects>,
103    animation: Cell<AnimationType>,
104    /// Whether the content goes, rather than comes or rests.
105    pub(crate) leaving: Cell<bool>,
106    /// Where the content's box is, its layer included, inside a shared
107    /// transition layout.
108    coordinates: Rc<Cell<WindowCoordinates>>,
109}
110
111impl EntryMotion {
112    pub(crate) fn progress(&self) -> f32 {
113        self.progress.borrow().state().value()
114    }
115
116    fn layer(&self) -> GraphicsLayer {
117        self.effects
118            .get()
119            .layer(self.progress(), self.size.get(), self.container.get())
120    }
121
122    /// Animates toward `target` unless it already runs there or rests there.
123    /// Composition calls it, and reads nothing it would recompose by.
124    fn animate_to(&self, target: f32) {
125        let mut progress = self.progress.borrow_mut();
126        let settled = if progress.is_running() {
127            progress.target()
128        } else {
129            progress.state().get_non_reactive()
130        };
131        if (settled - target).abs() > f32::EPSILON {
132            progress.animateTo(target, self.animation.get());
133        }
134    }
135
136    fn snap_to(&self, target: f32) {
137        self.progress.borrow_mut().snapTo(target);
138    }
139
140    /// Shown in full with nothing running: the content a change leaves
141    /// takes the change's exit transition only from here.
142    fn at_rest(&self) -> bool {
143        let progress = self.progress.borrow();
144        !progress.is_running() && progress.state().get_non_reactive() >= 1.0 - GONE
145    }
146
147    fn gone(&self) -> bool {
148        let progress = self.progress.borrow();
149        !progress.is_running() && progress.state().get_non_reactive() <= GONE
150    }
151}
152
153struct Entry<T> {
154    id: u64,
155    value: T,
156    motion: Rc<EntryMotion>,
157}
158
159type ContentFn<T> = std::boxed::Box<dyn FnMut(T)>;
160
161struct ContentState<T> {
162    runtime: RuntimeHandle,
163    /// Leaving content first, the current content last, on top.
164    entries: RefCell<Vec<Entry<T>>>,
165    next_id: Cell<u64>,
166    last_target: RefCell<Option<T>>,
167    content: RefCell<Option<ContentFn<T>>>,
168    size_transform: Cell<Option<SizeTransform>>,
169    size: RefCell<Option<Animatable<Size>>>,
170    /// Where the container is, inside a shared transition layout.
171    coordinates: Rc<Cell<WindowCoordinates>>,
172    /// The container's size from its last measure, where a size animation
173    /// starts, and its layout direction.
174    frame: Rc<Cell<ContainerFrame>>,
175    node: Cell<Option<NodeId>>,
176    /// Bumped when leaving content is removed, so the container recomposes
177    /// without it.
178    removed: MutableState<u64>,
179    watcher: RefCell<Option<FrameCallbackRegistration>>,
180}
181
182impl<T: Clone + PartialEq + 'static> ContentState<T> {
183    fn new(runtime: RuntimeHandle) -> Self {
184        Self {
185            removed: MutableState::with_runtime(0, runtime.clone()),
186            runtime,
187            entries: RefCell::new(Vec::new()),
188            next_id: Cell::new(0),
189            last_target: RefCell::new(None),
190            content: RefCell::new(None),
191            size_transform: Cell::new(None),
192            size: RefCell::new(None),
193            frame: Rc::default(),
194            coordinates: Rc::default(),
195            node: Cell::new(None),
196            watcher: RefCell::new(None),
197        }
198    }
199
200    fn push(&self, value: T, progress: f32, effects: TransitionEffects, animation: AnimationType) {
201        let id = self.next_id.get();
202        self.next_id.set(id.wrapping_add(1));
203        self.entries.borrow_mut().push(Entry {
204            id,
205            value,
206            motion: Rc::new(EntryMotion {
207                progress: RefCell::new(Animatable::new(progress, self.runtime.clone())),
208                size: Rc::new(Cell::new(Size::ZERO)),
209                container: Rc::clone(&self.frame),
210                effects: Cell::new(effects),
211                animation: Cell::new(animation),
212                leaving: Cell::new(false),
213                coordinates: Rc::default(),
214            }),
215        });
216    }
217
218    /// Gives the entry that shows the content of `value` that state, and
219    /// answers whether there is one: a state with the key of a content shown
220    /// changes it in place.
221    fn show(&self, value: &T, same: &dyn Fn(&T, &T) -> bool) -> bool {
222        let mut entries = self.entries.borrow_mut();
223        let Some(entry) = entries.iter_mut().find(|entry| same(&entry.value, value)) else {
224            return false;
225        };
226        if entry.value != *value {
227            entry.value = value.clone();
228        }
229        true
230    }
231
232    /// Brings the entries to `target`: it comes in with the transition from
233    /// the previous target, everything else leaves, and a seek holds both
234    /// ends where the gesture is.
235    fn sync(
236        &self,
237        target: &T,
238        seek: Option<&ContentSeek<T>>,
239        transition: &dyn Fn(&T, &T) -> ContentTransform,
240        same: &dyn Fn(&T, &T) -> bool,
241    ) {
242        let previous = self.last_target.replace(Some(target.clone()));
243        let change = previous
244            .filter(|previous| !same(previous, target))
245            .map(|previous| transition(&previous, target));
246        if let Some(change) = change {
247            self.size_transform.set(change.size_transform);
248        }
249        let first = self.entries.borrow().is_empty();
250        if !self.show(target, same) {
251            let enter = change.map_or_else(EnterTransition::none, |change| change.enter);
252            let progress = if first { 1.0 } else { 0.0 };
253            self.push(target.clone(), progress, enter.effects(), enter.animation());
254        }
255        let seek = seek.filter(|seek| !same(&seek.toward, target));
256        let seeking = seek.map(|seek| (seek, transition(target, &seek.toward)));
257        if let Some((seek, _)) = seeking
258            && !self.show(&seek.toward, same)
259        {
260            self.push(
261                seek.toward.clone(),
262                0.0,
263                TransitionEffects::default(),
264                AnimationType::default(),
265            );
266        }
267        let mut entries = self.entries.borrow_mut();
268        for entry in entries.iter_mut() {
269            let motion = &entry.motion;
270            if same(&entry.value, target) {
271                // New content took its enter transition when it was added;
272                // content that turns back runs its own transition backward.
273                // A gesture that seeks away from it makes it the content that
274                // goes, for the shared elements in it.
275                entry.motion.leaving.set(seeking.is_some());
276                match seeking {
277                    Some((seek, seek_change)) => {
278                        motion.effects.set(seek_change.exit.effects());
279                        motion.snap_to(1.0 - seek.fraction.clamp(0.0, 1.0));
280                    }
281                    None => motion.animate_to(1.0),
282                }
283            } else if let Some((seek, seek_change)) =
284                seeking.filter(|(seek, _)| same(&seek.toward, &entry.value))
285            {
286                entry.motion.leaving.set(false);
287                motion.effects.set(seek_change.enter.effects());
288                motion.animation.set(seek_change.enter.animation());
289                motion.snap_to(seek.fraction.clamp(0.0, 1.0));
290            } else {
291                if !entry.motion.leaving.replace(true)
292                    && let Some(change) = change.filter(|_| motion.at_rest())
293                {
294                    motion.effects.set(change.exit.effects());
295                    motion.animation.set(change.exit.animation());
296                }
297                motion.animate_to(0.0);
298            }
299        }
300        // The current content draws on top.
301        if let Some(index) = entries.iter().position(|entry| same(&entry.value, target)) {
302            let current = entries.remove(index);
303            entries.push(current);
304        }
305    }
306
307    /// Notes the layout direction when a slide toward the start or the end
308    /// needs it.
309    fn note_direction(&self) {
310        let uses_direction = self
311            .entries
312            .borrow()
313            .iter()
314            .any(|entry| entry.motion.effects.get().uses_direction());
315        if uses_direction {
316            let frame = self.frame.get();
317            self.frame.set(ContainerFrame {
318                rtl: crate::layout_direction::layout_direction().is_rtl(),
319                ..frame
320            });
321        }
322    }
323
324    fn animating(&self) -> bool {
325        self.entries
326            .borrow()
327            .iter()
328            .any(|entry| entry.motion.leaving.get())
329            || self
330                .size
331                .borrow()
332                .as_ref()
333                .is_some_and(Animatable::is_running)
334    }
335}
336
337/// Removes leaving content on the frame its exit ends, and measures the
338/// container again while its size animates.
339fn watch<T: Clone + PartialEq + 'static>(state: &Rc<ContentState<T>>) {
340    if state.watcher.borrow().is_some() || !state.animating() {
341        return;
342    }
343    let weak = Rc::downgrade(state);
344    let registration = state.runtime.frame_clock().with_frame_nanos(move |_| {
345        let Some(state) = weak.upgrade() else {
346            return;
347        };
348        state.watcher.borrow_mut().take();
349        let removed = {
350            let mut entries = state.entries.borrow_mut();
351            let before = entries.len();
352            // The current content is last; a seek all the way does not
353            // remove it.
354            let current = entries.last().map(|entry| entry.id);
355            entries.retain(|entry| {
356                Some(entry.id) == current || !(entry.motion.leaving.get() && entry.motion.gone())
357            });
358            before != entries.len()
359        };
360        if removed {
361            state.removed.update(|count| *count = count.wrapping_add(1));
362        }
363        if state
364            .size
365            .borrow()
366            .as_ref()
367            .is_some_and(Animatable::is_running)
368            && let Some(node) = state.node.get()
369        {
370            crate::schedule_measure_repass(node);
371            crate::request_render_invalidation();
372        }
373        watch(&state);
374    });
375    *state.watcher.borrow_mut() = Some(registration);
376}
377
378struct StateHandle<T>(Rc<ContentState<T>>);
379
380impl<T> Clone for StateHandle<T> {
381    fn clone(&self) -> Self {
382        Self(Rc::clone(&self.0))
383    }
384}
385
386impl<T> PartialEq for StateHandle<T> {
387    fn eq(&self, other: &Self) -> bool {
388        Rc::ptr_eq(&self.0, &other.0)
389    }
390}
391
392/// Changes `content` with a transition when `target_state` changes.
393///
394/// `transition_spec` receives the state that goes and the state that comes,
395/// and answers how they change: `fade_in().together_with(fade_out())`,
396/// with `.using(SizeTransform::default())` to animate the size between
397/// them. The first content shows at rest. Content that leaves stays
398/// composed until its exit ends; a change back while it leaves brings it
399/// back from where it was.
400///
401/// Mirrors Jetpack Compose:
402/// `AnimatedContent(targetState, transitionSpec = { ... }) { state -> ... }`.
403#[composable(no_skip)]
404pub fn AnimatedContent<T, F>(
405    target_state: T,
406    transition_spec: impl Fn(&T, &T) -> ContentTransform + 'static,
407    content: F,
408) where
409    T: Clone + PartialEq + 'static,
410    F: FnMut(T) + 'static,
411{
412    SeekableAnimatedContent(target_state, None, transition_spec, content);
413}
414
415/// [`AnimatedContent`] where `content_key` tells contents apart: a target
416/// whose key is the key of the content shown changes that content in place,
417/// with the new state and without a transition — Compose's `contentKey`.
418#[composable(no_skip)]
419pub fn AnimatedContentWithKey<T, K, F>(
420    target_state: T,
421    content_key: impl Fn(&T) -> K + 'static,
422    transition_spec: impl Fn(&T, &T) -> ContentTransform + 'static,
423    content: F,
424) where
425    T: Clone + PartialEq + 'static,
426    K: PartialEq,
427    F: FnMut(T) + 'static,
428{
429    animated_content(
430        target_state,
431        None,
432        &transition_spec,
433        &|a, b| content_key(a) == content_key(b),
434        content,
435    );
436}
437
438/// [`AnimatedContent`] that a gesture can hold part of the way toward
439/// another state, as predictive back does: while `seek` is set, its
440/// `toward` content shows at its fraction. When the gesture ends the change
441/// finishes or goes back from where it stood, depending on whether the
442/// target became `toward`.
443#[composable(no_skip)]
444pub fn SeekableAnimatedContent<T, F>(
445    target_state: T,
446    seek: Option<ContentSeek<T>>,
447    transition_spec: impl Fn(&T, &T) -> ContentTransform + 'static,
448    content: F,
449) where
450    T: Clone + PartialEq + 'static,
451    F: FnMut(T) + 'static,
452{
453    animated_content(
454        target_state,
455        seek,
456        &transition_spec,
457        &|a, b| a == b,
458        content,
459    );
460}
461
462/// The body of the animated content composables; `same` says whether two
463/// states show the same content.
464fn animated_content<T, F>(
465    target_state: T,
466    seek: Option<ContentSeek<T>>,
467    transition_spec: &dyn Fn(&T, &T) -> ContentTransform,
468    same: &dyn Fn(&T, &T) -> bool,
469    content: F,
470) where
471    T: Clone + PartialEq + 'static,
472    F: FnMut(T) + 'static,
473{
474    let runtime = with_current_composer(cranpose_core::Composer::runtime_handle);
475    let state =
476        remember(|| StateHandle(Rc::new(ContentState::<T>::new(runtime)))).with(StateHandle::clone);
477    *state.0.content.borrow_mut() = Some(std::boxed::Box::new(content));
478    state
479        .0
480        .sync(&target_state, seek.as_ref(), transition_spec, same);
481    state.0.note_direction();
482    watch(&state.0);
483    // Subscribes this scope to removals of content that left.
484    state.0.removed.get();
485    let clip = state
486        .0
487        .size_transform
488        .get()
489        .is_some_and(|transform| transform.clip);
490    let modifier = if clip {
491        Modifier::empty().clip_to_bounds()
492    } else {
493        Modifier::empty()
494    };
495    // Inside a shared transition layout the container and its contents say
496    // where they are, which costs the layer fast paths; elsewhere they don't.
497    let shared = in_shared_transition();
498    let modifier = if shared {
499        modifier.report_window_coordinates(Rc::clone(&state.0.coordinates))
500    } else {
501        modifier
502    };
503    let policy = ContentMeasurePolicy(state.clone());
504    let shown = state.clone();
505    let node = Layout(modifier, policy, move || {
506        let state = &shown;
507        let entries: Vec<(u64, T, Rc<EntryMotion>)> = state
508            .0
509            .entries
510            .borrow()
511            .iter()
512            .map(|entry| (entry.id, entry.value.clone(), Rc::clone(&entry.motion)))
513            .collect();
514        for (id, value, motion) in entries {
515            let state = state.clone();
516            with_key(&id, move || {
517                let layer = Rc::clone(&motion);
518                let entry = Modifier::empty().report_size(Rc::clone(&motion.size));
519                let entry = if shared {
520                    entry.report_window_coordinates(Rc::clone(&motion.coordinates))
521                } else {
522                    entry
523                };
524                let shared_entry = shared.then(|| SharedEntry {
525                    motion: Rc::clone(&motion),
526                    entry: Rc::clone(&motion.coordinates),
527                    container: Rc::clone(&state.0.coordinates),
528                });
529                Box(
530                    entry.graphics_layer(move || layer.layer()),
531                    BoxSpec::new(),
532                    move || {
533                        let compose = || {
534                            if let Some(content) = state.0.content.borrow_mut().as_mut() {
535                                content(value.clone());
536                            }
537                        };
538                        match shared_entry.clone() {
539                            Some(entry) => ProvideSharedEntry(entry, compose),
540                            None => compose(),
541                        }
542                    },
543                );
544            });
545        }
546    });
547    state.0.node.set(Some(node));
548}
549
550/// Places every content at the top start; the container is as large as the
551/// largest content, or follows the current content's size through a
552/// [`SizeTransform`].
553struct ContentMeasurePolicy<T>(StateHandle<T>);
554
555impl<T> Clone for ContentMeasurePolicy<T> {
556    fn clone(&self) -> Self {
557        Self(self.0.clone())
558    }
559}
560
561impl<T> PartialEq for ContentMeasurePolicy<T> {
562    fn eq(&self, other: &Self) -> bool {
563        self.0 == other.0
564    }
565}
566
567impl<T: Clone + PartialEq + 'static> MeasurePolicy for ContentMeasurePolicy<T> {
568    fn measure(
569        &self,
570        _scope: &dyn MeasureScope,
571        measurables: &[Box<dyn Measurable>],
572        constraints: Constraints,
573    ) -> MeasureResult {
574        let state = &self.0.0;
575        let loose = Constraints {
576            min_width: 0.0,
577            min_height: 0.0,
578            ..constraints
579        };
580        let mut largest = Size::ZERO;
581        let mut current = Size::ZERO;
582        let mut placements = Vec::with_capacity(measurables.len());
583        for measurable in measurables {
584            let placeable = measurable.measure(loose);
585            let size = Size::new(placeable.width(), placeable.height());
586            largest = Size::new(
587                largest.width.max(size.width),
588                largest.height.max(size.height),
589            );
590            // The current content is the last.
591            current = size;
592            placeable.place(0.0, 0.0);
593            placements.push(Placement::new(placeable.node_id(), 0.0, 0.0, 0));
594        }
595        let size = match state.size_transform.get() {
596            Some(transform) => {
597                let mut animated = state.size.borrow_mut();
598                let animated = animated.get_or_insert_with(|| {
599                    Animatable::new(state.frame.get().size, state.runtime.clone())
600                });
601                if animated.target() != current {
602                    animated.animateTo(current, transform.animation);
603                }
604                animated.state().value()
605            }
606            None => {
607                state.size.borrow_mut().take();
608                largest
609            }
610        };
611        let size = Size::new(
612            size.width
613                .clamp(constraints.min_width, constraints.max_width),
614            size.height
615                .clamp(constraints.min_height, constraints.max_height),
616        );
617        state.frame.set(ContainerFrame {
618            size,
619            ..state.frame.get()
620        });
621        MeasureResult::new(size, placements)
622    }
623
624    fn min_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
625        measurables
626            .iter()
627            .map(|measurable| measurable.min_intrinsic_width(height))
628            .fold(0.0, f32::max)
629    }
630
631    fn max_intrinsic_width(&self, measurables: &[Box<dyn Measurable>], height: f32) -> f32 {
632        measurables
633            .iter()
634            .map(|measurable| measurable.max_intrinsic_width(height))
635            .fold(0.0, f32::max)
636    }
637
638    fn min_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
639        measurables
640            .iter()
641            .map(|measurable| measurable.min_intrinsic_height(width))
642            .fold(0.0, f32::max)
643    }
644
645    fn max_intrinsic_height(&self, measurables: &[Box<dyn Measurable>], width: f32) -> f32 {
646        measurables
647            .iter()
648            .map(|measurable| measurable.max_intrinsic_height(width))
649            .fold(0.0, f32::max)
650    }
651}