From bcbe5595307f3c34675970bc5dfe50c89f2be7a1 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 16:56:47 +0200 Subject: [PATCH 1/4] feat(native): view transitions with a named pair of screens --- .changeset/view-transitions.md | 28 + examples/demo.test.tsx | 48 +- examples/demo/app.tsx | 2 + examples/demo/navigation.tsx | 143 ++++ packages/native/index.d.ts | 21 + packages/native/src/motion.rs | 31 +- packages/native/src/renderer.rs | 121 +++- packages/native/src/renderer/frame.rs | 25 + .../native/src/renderer/view_transition.rs | 613 ++++++++++++++++++ packages/native/src/style.rs | 4 + packages/native/src/test_renderer.rs | 30 + .../src/__tests__/view-transitions.test.tsx | 170 +++++ packages/react/src/index.ts | 7 + packages/react/src/testing.ts | 18 + packages/react/src/types/host.ts | 12 + packages/react/src/view-transitions.ts | 64 ++ 16 files changed, 1327 insertions(+), 10 deletions(-) create mode 100644 .changeset/view-transitions.md create mode 100644 examples/demo/navigation.tsx create mode 100644 packages/native/src/renderer/view_transition.rs create mode 100644 packages/react/src/__tests__/view-transitions.test.tsx create mode 100644 packages/react/src/view-transitions.ts diff --git a/.changeset/view-transitions.md b/.changeset/view-transitions.md new file mode 100644 index 00000000..d66af72a --- /dev/null +++ b/.changeset/view-transitions.md @@ -0,0 +1,28 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Add the View Transitions API. + +`startViewTransition(renderer, update, options)` captures every element that +carries a `viewTransitionName`, applies the React update synchronously, and +animates each name from its old place to its new one. The renderer clones the +named subtrees before the update and paints the frozen copies over the live +tree while the transition runs, so the leaving screen stays visible under, or +over, the arriving one. + +Options take a duration, a delay, and an ease per name, plus `translateX`, +`translateY` and `opacity` ranges for the old side and the new side. Percent +lengths resolve against the size of the named element, so +`translateX: ["100%", "0%"]` slides a screen in from the right at any width. +A name with no options crossfades. A name that only enters animates against +its own bounds. + +The new side moves through the motion channel, so the live element and its +hitboxes move together, and input lands where the screen paints. The frozen +copy takes fresh ids where the live tree still uses them, so a surviving +element and its copy never share GPUI element state. + +Limits in this version: a name that only leaves paints nothing, and the +frozen copy takes no input. diff --git a/examples/demo.test.tsx b/examples/demo.test.tsx index c15fd816..bd2d5330 100644 --- a/examples/demo.test.tsx +++ b/examples/demo.test.tsx @@ -20,6 +20,7 @@ import { Inheritance } from "./demo/inheritance" import { Lengths } from "./demo/lengths" import { motion } from "@gpuix/react" import { Motion } from "./demo/motion-panel" +import { Navigation } from "./demo/navigation" import { IntoView, Scrollbars } from "./demo/scrollbars" import { Variables } from "./demo/variables" import { resolveClassName } from "./demo/classes" @@ -42,6 +43,7 @@ const PANELS = [ ["classes", ], ["motion", ], ["scrollbars", ], + ["navigation", ], ] as const describeNative("demo panels", () => { @@ -242,6 +244,50 @@ describeNative("the scrollbars panel", () => { }) }) +describeNative("the navigation panel", () => { + it("pushes the General screen from the right and pops it back", () => { + const test = root() + test.render( +
+ +
+ ) + test.renderer.clockPause() + + const general = test.renderer.findByTestId("nav-row-General")! + const [gx, gy] = test.renderer.getElementBounds(general.id)! + test.renderer.nativeSimulateClick(gx + 4, gy + 4) + + // At the start of the push, the General screen sits one screen width to + // the right of where it will rest. The phone is 320 wide with a 1px + // border on each side, so the screen is 318. + const about = test.renderer.findByText("About")! + const startX = test.renderer.getElementBounds(about.id)![0] + test.renderer.clockFastForward(600) + const endX = test.renderer.getElementBounds(about.id)![0] + expect(startX - endX).toBeCloseTo(318, 0) + + const back = test.renderer.findByTestId("nav-back")! + const [bx, by] = test.renderer.getElementBounds(back.id)! + test.renderer.nativeSimulateClick(bx + 4, by + 4) + test.renderer.clockFastForward(600) + expect(test.renderer.findByTestId("nav-row-General")).toBeDefined() + expect(test.renderer.findByText("About")).toBeUndefined() + + test.renderer.clockResume() + test.unmount() + }) +}) + describeNative("the whole application", () => { /// Walk the sidebar and paint each section, so the whole application is /// covered rather than the one it opens on. The test renderer has the frame @@ -251,7 +297,7 @@ describeNative("the whole application", () => { test.render() expect(test.renderer.getPaintedText()).toContain("GPUIX") - for (const title of ["Lengths", "Variables", "Inheritance", "className", "Motion", "Scrollbars", "Performance", "Colours"]) { + for (const title of ["Lengths", "Variables", "Inheritance", "className", "Motion", "Scrollbars", "Navigation", "Performance", "Colours"]) { const item = test.renderer.findByText(title) expect(item, `no sidebar item named ${title}`).toBeDefined() const bounds = test.renderer.getElementBounds(item!.id) diff --git a/examples/demo/app.tsx b/examples/demo/app.tsx index 42ca5f08..e3009f9e 100644 --- a/examples/demo/app.tsx +++ b/examples/demo/app.tsx @@ -16,6 +16,7 @@ import { Effects } from "./effects.js" import { Inheritance } from "./inheritance.js" import { Lengths } from "./lengths.js" import { Motion } from "./motion-panel.js" +import { Navigation } from "./navigation.js" import { frameOverlay, Perf } from "./perf.js" import { Scrollbars } from "./scrollbars.js" import { Variables } from "./variables.js" @@ -78,6 +79,7 @@ const SECTIONS = [ { id: "classes", title: "className", render: () => }, { id: "motion", title: "Motion", render: () => }, { id: "scrollbars", title: "Scrollbars", render: () => }, + { id: "navigation", title: "Navigation", render: () => }, ] as const type SectionId = (typeof SECTIONS)[number]["id"] | "perf" diff --git a/examples/demo/navigation.tsx b/examples/demo/navigation.tsx new file mode 100644 index 00000000..5d5c4c1c --- /dev/null +++ b/examples/demo/navigation.tsx @@ -0,0 +1,143 @@ +/// View transitions, shown as the push and pop of the iOS Settings app. +/// +/// The two screens carry the same `viewTransitionName`, so one +/// `startViewTransition` call animates them as a pair. On a push, the new +/// screen slides in from the right over the old one, and the old one slides +/// 30% of its width to the left. On a pop, the same move runs backwards, and +/// the leaving screen stays on top while it slides out. + +import React, { useState } from "react" +import { startViewTransition, useGpuix } from "@gpuix/react" +import type { NativeRenderer, ViewTransitionOptions } from "@gpuix/react" +import { Panel } from "./ui.js" + +const PUSH: ViewTransitionOptions = { + groups: { + screen: { + duration: 0.35, + ease: "easeOut", + old: { translateX: ["0%", "-30%"] }, + new: { translateX: ["100%", "0%"] }, + }, + }, +} + +const POP: ViewTransitionOptions = { + groups: { + screen: { + duration: 0.35, + ease: "easeOut", + old: { translateX: ["0%", "100%"], onTop: true }, + new: { translateX: ["-30%", "0%"] }, + }, + }, +} + +const GENERAL_ROWS = ["About", "Software Update", "Storage", "AppleCare", "AirDrop"] +const ROOT_ROWS = ["General", "Display", "Sound", "Focus", "Battery"] + +function NavRow({ label, detail, onClick }: { + label: string + detail?: string + onClick?: () => void +}) { + return ( +
+ {label} + {detail ?? (onClick ? ">" : "")} +
+ ) +} + +function TitleBar({ title, onBack }: { title: string; onBack?: () => void }) { + return ( +
+ {onBack ? ( +
+ {"< Settings"} +
+ ) : null} +
+ {title} +
+ {onBack ?
: null} +
+ ) +} + +/// One screen of the stack. The name pairs it with the screen it replaces, +/// and the key makes React mount a new element instead of an update in +/// place, the way a real navigation swaps components. +function Screen({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function Phone({ renderer }: { renderer: NativeRenderer | null }) { + const [screen, setScreen] = useState<"root" | "general">("root") + const go = (next: "root" | "general", options: ViewTransitionOptions) => { + if (renderer) { + startViewTransition(renderer, () => setScreen(next), options) + } else { + setScreen(next) + } + } + + return ( +
+ {screen === "root" ? ( + + + {ROOT_ROWS.map((label) => ( + go("general", PUSH) : undefined} + /> + ))} + + ) : ( + + go("root", POP)} /> + {GENERAL_ROWS.map((label) => ( + + ))} + + )} +
+ ) +} + +export function Navigation() { + const { renderer } = useGpuix() + return ( + + + + ) +} diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index 860bb16b..f47fd99a 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -75,6 +75,17 @@ export declare class GpuixRenderer { * box apply. */ scrollIntoView(elementId: number, block?: string | undefined | null, inline?: string | undefined | null): void + /** + * Clone every element that has a `viewTransitionName`, with its painted + * bounds. Call this before the React update, then `viewTransitionStart` + * after it. `startViewTransition` in `@gpuix/react` does both. + */ + viewTransitionCapture(): void + /** + * Animate every captured name toward its new element. `options` is the + * JSON of a `ViewTransitionOptions` value, or nothing for a crossfade. + */ + viewTransitionStart(options?: string | undefined | null): void /** Hidden → minimal → full → hidden. */ cycleDebugFrameOverlay(): string getDebugFrameOverlay(): string @@ -304,6 +315,16 @@ export declare class TestGpuixRenderer { * web scrollIntoView. Call flush() after to apply and re-render. */ scrollIntoView(elementId: number, block?: string | undefined | null, inline?: string | undefined | null): void + /** + * Clone every element that has a `viewTransitionName`, with its painted + * bounds. Call flush() first, so the bounds are current. + */ + viewTransitionCapture(): void + /** + * Animate every captured name toward its new element. Call flush() + * after, and move the automation clock to step through the frames. + */ + viewTransitionStart(options?: string | undefined | null): void /** * Scroll a child into view by its index in the children list. * Call flush() after to apply and re-render. diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index 69d0e99c..37f2bcc3 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -180,7 +180,7 @@ impl MotionHeight { } /// One step of a linear interpolation. -fn mix(from: f64, to: f64, progress: f64) -> f64 { +pub(crate) fn mix(from: f64, to: f64, progress: f64) -> f64 { from + (to - from) * progress } @@ -251,7 +251,7 @@ enum MotionInitial { #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(untagged)] -enum MotionEase { +pub(crate) enum MotionEase { Name(String), CubicBezier([f64; 4]), } @@ -329,6 +329,29 @@ impl MotionFrame { pub(crate) fn measured_height(&self) -> Option { self.style.height.filter(|height| height.needs_content()) } + + /// A frame a view transition composes for the arriving element of a pair. + /// It carries only the opacity of this animation frame. The transition + /// element applies the movement at paint. + pub(crate) fn view_transition_opacity(opacity: f64) -> Self { + Self { + style: MotionStyle { + opacity: Some(opacity), + ..MotionStyle::default() + }, + active: true, + content: None, + measured: ContentHeight::default(), + } + } + + /// Fold a view-transition opacity into this frame. The transition owns the + /// element while it runs, so its opacity replaces the motion one. + pub(crate) fn with_view_transition_opacity(mut self, opacity: f64) -> Self { + self.style.opacity = Some(opacity); + self.active = true; + self + } } pub(crate) struct MotionState { @@ -534,7 +557,7 @@ fn validate_seconds(value: f64, name: &str) -> Result<(), String> { Ok(()) } -fn validate_ease(ease: &MotionEase) -> Result<(), String> { +pub(crate) fn validate_ease(ease: &MotionEase) -> Result<(), String> { match ease { MotionEase::Name(name) if matches!( @@ -561,7 +584,7 @@ fn seconds(value: f64) -> Duration { Duration::try_from_secs_f64(value).expect("motion durations are validated when parsed") } -fn ease(progress: f64, ease: &MotionEase) -> f64 { +pub(crate) fn ease(progress: f64, ease: &MotionEase) -> f64 { let curve = match ease { MotionEase::CubicBezier(curve) => *curve, MotionEase::Name(name) => match name.as_str() { diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index 6bafaf23..089f61e5 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -50,6 +50,7 @@ mod batch; mod frame; pub(crate) mod scroll_into_view; pub(crate) mod scrollbar; +pub(crate) mod view_transition; mod virtual_list; pub use batch::apply_batch_to_tree; @@ -291,6 +292,10 @@ enum UiCommand { block: scroll_into_view::Align, inline: scroll_into_view::Align, }, + ViewTransitionCapture, + ViewTransitionStart { + options: String, + }, GetScrollOffset { id: u64, response: SyncSender>, @@ -431,6 +436,19 @@ async fn run_ui_commands( .ok(); refresh_ui_window(window, cx) } + UiCommand::ViewTransitionCapture => window.update(cx, |view, _window, _cx| { + view.view_transition_capture(); + }), + UiCommand::ViewTransitionStart { options } => { + window + .update(cx, move |view, _window, _cx| { + if let Err(error) = view.view_transition_start(&options) { + log::warn!("Invalid view transition options: {error}"); + } + }) + .ok(); + refresh_ui_window(window, cx) + } UiCommand::GetScrollOffset { id, response } => { let offset = VIRTUAL_LIST_STATES .with(|cell| { @@ -1261,6 +1279,51 @@ impl GpuixRenderer { Err(Error::from_reason("Unsupported operating system")) } + /// Clone every element that has a `viewTransitionName`, with its painted + /// bounds. Call this before the React update, then `viewTransitionStart` + /// after it. `startViewTransition` in `@gpuix/react` does both. + #[napi] + pub fn view_transition_capture(&self) -> Result<()> { + #[cfg(target_os = "macos")] + return update_window(|view, _window, _cx| view.view_transition_capture()); + + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] + return self.send_ui_command(UiCommand::ViewTransitionCapture); + + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd" + )))] + Err(Error::from_reason("Unsupported operating system")) + } + + /// Animate every captured name toward its new element. `options` is the + /// JSON of a `ViewTransitionOptions` value, or nothing for a crossfade. + #[napi] + pub fn view_transition_start(&self, options: Option) -> Result<()> { + let options = options.unwrap_or_else(|| "{}".to_string()); + #[cfg(target_os = "macos")] + { + update_window(move |view, _window, _cx| { + view.view_transition_start(&options).map_err(Error::from_reason) + })??; + return invalidate_window(); + } + + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))] + return self.send_ui_command(UiCommand::ViewTransitionStart { options }); + + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd" + )))] + Err(Error::from_reason("Unsupported operating system")) + } + /// Hidden → minimal → full → hidden. #[napi] pub fn cycle_debug_frame_overlay(&self) -> Result { @@ -2640,6 +2703,10 @@ pub(crate) struct GpuixView { /// Resolved `highlight` state, keyed by the element that declared it. /// Empty in every app that does not use search. highlights: HashMap, + /// The running view transition, when one runs. + view_transition: Option, + /// Captures `viewTransitionCapture` took, waiting for the start call. + pending_view_transition: Option>, } /// Two-level cache for one element's `highlight`. @@ -2773,9 +2840,33 @@ impl GpuixView { clock: crate::automation::AutomationClock::new(), root_cascade: RefCell::new(None), highlights: HashMap::new(), + view_transition: None, + pending_view_transition: None, } } + /// Clone every named element and its painted bounds, before the tree + /// swaps. `view_transition_start` consumes the result. + pub(crate) fn view_transition_capture(&mut self) { + let tree = self.tree.lock().unwrap(); + self.pending_view_transition = Some(view_transition::capture(&tree)); + } + + /// Start the transition against the tree as it is now. A start without a + /// capture still animates the names the new tree carries. + pub(crate) fn view_transition_start( + &mut self, + options_json: &str, + ) -> std::result::Result<(), String> { + let options = view_transition::VtOptions::parse(options_json)?; + let captures = self.pending_view_transition.take().unwrap_or_default(); + let tree = self.tree.lock().unwrap(); + let state = view_transition::VtState::new(captures, options, &tree); + drop(tree); + self.view_transition = Some(state); + Ok(()) + } + /// The root cascade for `theme`, reusing the last one while the theme /// holds still. fn root_cascade(&self, theme: &Theme, rem_size: gpui::Pixels) -> crate::inheritance::Inherited { @@ -2886,6 +2977,7 @@ impl GpuixView { highlight, highlights: &mut self.highlights, highlight_events: &mut highlight_events, + vt: self.view_transition.as_ref(), }; let child = build_element(expected_child_id, &mut build_ctx, window, cx); emit_highlight_events(&callback, &highlight_events); @@ -3077,25 +3169,38 @@ impl gpui::Render for GpuixView { // Sync focus handles before building elements. self.sync_focus_handles(&tree, &callback, window, cx); + // One frame of the running view transition, taken out of `self` so + // the build can borrow the rest of the view. A transition past its + // end drops here, and the frame paints the live tree alone. + let now = self.clock.now(); + let view_transition = self.view_transition.take().and_then(|mut transition| { + transition.tick(now).then_some(transition) + }); + let kept_by_transition = |id: &u64| { + view_transition + .as_ref() + .is_some_and(|transition| transition.keeps(*id)) + }; + // Ensure custom element instances are destroyed when their IDs disappear. + // A frozen view-transition copy keeps its instances until it fades. self.custom_registry - .prune_missing(|id| tree.elements.contains_key(&id)); + .prune_missing(|id| tree.elements.contains_key(&id) || kept_by_transition(&id)); // Clean up scroll handles for destroyed elements (IDs removed from tree). // Scrollability-based cleanup (element still exists but style changed // from scroll to non-scroll) is handled inside build_div(). self.scroll_handles - .retain(|id, _| tree.elements.contains_key(id)); + .retain(|id, _| tree.elements.contains_key(id) || kept_by_transition(id)); self.virtual_lists - .retain(|id, _| tree.elements.contains_key(id)); + .retain(|id, _| tree.elements.contains_key(id) || kept_by_transition(id)); self.motion_states - .retain(|id, _| tree.elements.contains_key(id)); + .retain(|id, _| tree.elements.contains_key(id) || kept_by_transition(id)); // Build the element tree. custom_registry, focus_handles, and scroll_handles // are different fields of self, so Rust allows borrowing all simultaneously. let theme = Theme::dark(); let root_cascade = self.root_cascade(&theme, window.rem_size()); - let now = self.clock.now(); let mut motion_active = false; // Pruned by DECLARATION, not existence: an element that drops its // `highlight` prop keeps living, and its cached group list holds a copy @@ -3124,11 +3229,17 @@ impl gpui::Render for GpuixView { highlight: None, highlights: &mut self.highlights, highlight_events: &mut highlight_events, + vt: view_transition.as_ref(), }; build_element(root_id, &mut ctx, window, cx) } None => gpui::Empty.into_any_element(), }; + // A transition animates every frame until it comes to rest. + if view_transition.is_some() { + motion_active = true; + } + self.view_transition = view_transition; // Flushed after the root build so a `setState` in the handler cannot // re-enter this build. emit_highlight_events(&callback, &highlight_events); diff --git a/packages/native/src/renderer/frame.rs b/packages/native/src/renderer/frame.rs index 3eb483cf..15abfaee 100644 --- a/packages/native/src/renderer/frame.rs +++ b/packages/native/src/renderer/frame.rs @@ -49,6 +49,9 @@ pub(super) struct BuildCtx<'a> { /// would re-enter the build and emit again. They are flushed once the root /// build has returned. pub highlight_events: &'a mut Vec<(u64, usize)>, + /// The running view transition, or `None`. Cleared inside the build of a + /// frozen copy, so a name inside the copy never starts a nested one. + pub vt: Option<&'a super::view_transition::VtState>, } // ── Element builders ───────────────────────────────────────────────── @@ -93,6 +96,24 @@ pub(super) fn build_element( }; let style = element.style.as_deref(); + // This frame of the view transition, when one runs and the element + // carries a name. The opacity of the arriving side folds into the motion + // channel here, and the movement applies at paint in the wrapper below. + let vt_frame = ctx.vt.and_then(|vt| { + let name = style?.view_transition_name.as_deref()?; + if name.is_empty() || name == "none" { + return None; + } + vt.frame_for(name) + }); + let motion = match vt_frame.as_ref().and_then(|frame| frame.new_opacity()) { + Some(opacity) => Some(match motion { + Some(frame) => frame.with_view_transition_opacity(opacity), + None => crate::motion::MotionFrame::view_transition_opacity(opacity), + }), + None => motion, + }; + // Inheritable style resolves before the element's own style, because a // custom property declared here is in scope for the `var()` next to it. let parent_cascade = ctx.cascade.clone(); @@ -196,6 +217,10 @@ pub(super) fn build_element( }; let built = super::auto_height::wrap(id, built, motion.as_ref(), resolved.as_deref()); + let built = match vt_frame { + Some(frame) => super::view_transition::wrap(element, built, frame, ctx, window, cx), + None => built, + }; ctx.cascade = parent_cascade; ctx.highlight = parent_highlight; diff --git a/packages/native/src/renderer/view_transition.rs b/packages/native/src/renderer/view_transition.rs new file mode 100644 index 00000000..b35a2ef2 --- /dev/null +++ b/packages/native/src/renderer/view_transition.rs @@ -0,0 +1,613 @@ +//! View transitions: freeze the named elements, swap the tree, then animate +//! each name from its old place to its new one. +//! +//! `viewTransitionCapture` clones the subtree and the painted bounds of every +//! element that has a `viewTransitionName`. `viewTransitionStart` parses the +//! options and starts the clock. While the transition runs, `build_element` +//! wraps each named live element in a `VtGroup`. The group takes the layout of +//! the live element, so the transition never disturbs the surrounding layout. +//! It paints the frozen copy at its captured place, then paints the live +//! element moved by this frame's offset. Opacity for the live element rides +//! the same style channel that `motion` uses, and the frozen copy carries its +//! opacity on a wrapper element. +//! +//! Known limits, on purpose: +//! - A name that disappears without a successor paints nothing. Give both +//! screens one name to animate an exit as a pair. +//! - When the named element survives the swap, its frozen copy takes fresh +//! ids, so the copy paints without the old scroll offsets. +//! - The frozen copy keeps its event listeners, but their elements are gone +//! on the React side, so input over the copy does nothing. + +use std::collections::{HashMap, HashSet}; + +use gpui::{ + AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId, + InspectorElementId, IntoElement, IsolatedLayout, LayoutId, Pixels, Point, Size, Window, point, + px, size, +}; +use serde::Deserialize; +use web_time::Instant; + +use super::frame::{build_element, BuildCtx}; +use crate::motion::{self, MotionEase}; +use crate::retained_tree::{RetainedElement, RetainedTree}; + +// ── Options ────────────────────────────────────────────────────────── + +/// A translation distance: pixels, or a share of the element's size. +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(try_from = "LenWire")] +pub(crate) enum VtLen { + Px(f64), + Percent(f64), +} + +impl VtLen { + fn resolve(self, extent: f64) -> f64 { + match self { + Self::Px(value) => value, + Self::Percent(value) => value / 100.0 * extent, + } + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum LenWire { + Number(f64), + Text(String), +} + +impl TryFrom for VtLen { + type Error = String; + + fn try_from(wire: LenWire) -> Result { + let text = match wire { + LenWire::Number(value) if value.is_finite() => return Ok(Self::Px(value)), + LenWire::Number(value) => { + return Err(format!("view transition length must be finite, got {value}")) + } + LenWire::Text(text) => text, + }; + let trimmed = text.trim(); + let (number, percent) = match trimmed.strip_suffix('%') { + Some(number) => (number, true), + None => (trimmed.strip_suffix("px").unwrap_or(trimmed), false), + }; + let value = number + .trim() + .parse::() + .ok() + .filter(|value| value.is_finite()) + .ok_or_else(|| format!("bad view transition length: {text:?}"))?; + Ok(if percent { + Self::Percent(value) + } else { + Self::Px(value) + }) + } +} + +/// What one side of a pair does over the transition. Every field is a +/// `[from, to]` pair. A missing field holds still. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct SideSpec { + translate_x: Option<[VtLen; 2]>, + translate_y: Option<[VtLen; 2]>, + opacity: Option<[f64; 2]>, + /// Paint this side over the other one. Only read on the old side. + on_top: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct GroupSpec { + duration: Option, + delay: Option, + ease: Option, + old: Option, + new: Option, +} + +/// The whole options payload of one `viewTransitionStart` call. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub(crate) struct VtOptions { + /// Seconds, like the `motion` prop. + duration: Option, + delay: Option, + ease: Option, + groups: HashMap, +} + +const DEFAULT_DURATION: f64 = 0.3; + +impl VtOptions { + pub(crate) fn parse(json: &str) -> Result { + let options: Self = serde_json::from_str(json).map_err(|error| error.to_string())?; + for (ease, duration, delay) in std::iter::once((&options.ease, options.duration, options.delay)) + .chain( + options + .groups + .values() + .map(|group| (&group.ease, group.duration, group.delay)), + ) + { + if let Some(ease) = ease { + motion::validate_ease(ease)?; + } + for (name, value) in [("duration", duration), ("delay", delay)] { + if let Some(value) = value { + if !value.is_finite() || value < 0.0 { + return Err(format!( + "view transition {name} must be a finite non-negative number" + )); + } + } + } + } + Ok(options) + } + + fn timing(&self, name: &str) -> (f64, f64, MotionEase) { + let group = self.groups.get(name); + let duration = group + .and_then(|group| group.duration) + .or(self.duration) + .unwrap_or(DEFAULT_DURATION); + let delay = group.and_then(|group| group.delay).or(self.delay).unwrap_or(0.0); + let ease = group + .and_then(|group| group.ease.clone()) + .or_else(|| self.ease.clone()) + .unwrap_or(MotionEase::Name("easeInOut".to_string())); + (duration, delay, ease) + } + + /// When the last group comes to rest, in seconds from the start. + fn longest_end(&self) -> f64 { + let default_end = + self.delay.unwrap_or(0.0) + self.duration.unwrap_or(DEFAULT_DURATION); + self.groups + .keys() + .map(|name| { + let (duration, delay, _) = self.timing(name); + delay + duration + }) + .fold(default_end, f64::max) + } +} + +// ── Capture ────────────────────────────────────────────────────────── + +/// One frozen named element: its cloned subtree and its painted place. +pub(crate) struct VtCapture { + pub(crate) tree: RetainedTree, + pub(crate) root: u64, + pub(crate) origin: Point, + pub(crate) size: Size, +} + +/// Clone every named element's subtree, with the bounds it painted at. +/// An element that never painted has no place to animate from, so it is +/// skipped and its name enters as a new element. +pub(crate) fn capture(tree: &RetainedTree) -> HashMap { + let bounds = crate::automation::all_bounds(); + let mut captures = HashMap::new(); + for (&id, element) in &tree.elements { + let Some(name) = element + .style + .as_deref() + .and_then(|style| style.view_transition_name.as_deref()) + else { + continue; + }; + if name.is_empty() || name == "none" { + continue; + } + let Some(rect) = bounds.get(&id) else { + continue; + }; + let mut frozen = RetainedTree::new(); + clone_subtree(tree, id, None, &mut frozen); + frozen.root_id = Some(id); + captures.insert( + name.to_string(), + VtCapture { + tree: frozen, + root: id, + origin: point(px(rect.x as f32), px(rect.y as f32)), + size: size(px(rect.width as f32), px(rect.height as f32)), + }, + ); + } + captures +} + +fn clone_subtree(source: &RetainedTree, id: u64, parent: Option, into: &mut RetainedTree) { + let Some(element) = source.elements.get(&id) else { + return; + }; + let mut clone = RetainedElement::new(id, element.element_type.clone(), element.subtree_revision); + clone.style = element.style.clone(); + clone.content = element.content.clone(); + clone.events = element.events.clone(); + clone.children = element.children.clone(); + clone.parent = parent; + clone.custom_props = element.custom_props.clone(); + // The copy is a still image. Without this, a fresh `MotionState` would + // replay the initial-to-animate run inside it. + clone.custom_props.remove("motion"); + // Locators must find the live element, never the copy. + clone.test_id = None; + clone.search_revision = element.search_revision; + let children = clone.children.clone(); + into.elements.insert(id, clone); + for child in children { + clone_subtree(source, child, Some(id), into); + } +} + +/// Fresh ids for clones whose original survives the swap. Far above what the +/// JS counter reaches, so the two ranges never meet. +const REMAP_BASE: u64 = 1 << 62; + +/// Give a clone a fresh id when its original is still in the live tree. +/// Building both under one id would hand them one GPUI element state. +/// A clone of a destroyed element keeps its id, and with it its scroll +/// offsets, which is the common pair case. +fn remap_live_ids(captures: &mut HashMap, live: &RetainedTree, next: &mut u64) { + for capture in captures.values_mut() { + let colliding: Vec = capture + .tree + .elements + .keys() + .copied() + .filter(|id| live.elements.contains_key(id)) + .collect(); + for from in colliding { + let to = *next; + *next += 1; + remap(&mut capture.tree, from, to); + if capture.root == from { + capture.root = to; + } + } + } +} + +fn remap(tree: &mut RetainedTree, from: u64, to: u64) { + let Some(mut element) = tree.elements.remove(&from) else { + return; + }; + element.id = to; + let parent = element.parent; + let children = element.children.clone(); + tree.elements.insert(to, element); + if let Some(parent) = parent.and_then(|id| tree.elements.get_mut(&id)) { + for child in &mut parent.children { + if *child == from { + *child = to; + } + } + } + for child in children { + if let Some(child) = tree.elements.get_mut(&child) { + child.parent = Some(to); + } + } + if tree.root_id == Some(from) { + tree.root_id = Some(to); + } +} + +// ── State ──────────────────────────────────────────────────────────── + +/// One running transition. The view holds at most one. A new start replaces +/// the one before it. +pub(crate) struct VtState { + captures: HashMap, + options: VtOptions, + started: Option, + frame_now: Option, + /// Every id inside a frozen tree. The view keeps the scroll handles and + /// custom element instances of these ids alive while the transition runs. + ids: HashSet, +} + +impl VtState { + pub(crate) fn new( + mut captures: HashMap, + options: VtOptions, + live: &RetainedTree, + ) -> Self { + let mut next = REMAP_BASE; + remap_live_ids(&mut captures, live, &mut next); + let ids = captures + .values() + .flat_map(|capture| capture.tree.elements.keys().copied()) + .collect(); + Self { + captures, + options, + started: None, + frame_now: None, + ids, + } + } + + /// Bring the clock up to this frame. Returns whether the transition still + /// runs. Called once per frame, before the tree builds. + pub(crate) fn tick(&mut self, now: Instant) -> bool { + let started = *self.started.get_or_insert(now); + self.frame_now = Some(now); + let elapsed = now.duration_since(started).as_secs_f64(); + elapsed < self.options.longest_end() + } + + /// Whether the view must keep per-id state alive for a frozen clone. + pub(crate) fn keeps(&self, id: u64) -> bool { + self.ids.contains(&id) + } + + fn capture(&self, name: &str) -> Option<&VtCapture> { + self.captures.get(name) + } + + /// This frame's animation values for one name, or `None` before the first + /// tick. + pub(crate) fn frame_for(&self, name: &str) -> Option { + let started = self.started?; + let now = self.frame_now?; + let (duration, delay, ease_spec) = self.options.timing(name); + let elapsed = now.duration_since(started).as_secs_f64(); + let raw = if duration <= 0.0 { + 1.0 + } else { + ((elapsed - delay) / duration).clamp(0.0, 1.0) + }; + let t = motion::ease(raw, &ease_spec); + + let group = self.options.groups.get(name); + // A group that names neither side crossfades, like the web default. + // A group that names a side animates only what that side says. + let explicit = group.is_some_and(|group| group.old.is_some() || group.new.is_some()); + let old = group.and_then(|group| group.old.clone()).unwrap_or_else(|| SideSpec { + opacity: (!explicit).then_some([1.0, 0.0]), + ..SideSpec::default() + }); + let new = group.and_then(|group| group.new.clone()).unwrap_or_else(|| SideSpec { + opacity: (!explicit).then_some([0.0, 1.0]), + ..SideSpec::default() + }); + let old_on_top = old.on_top.unwrap_or(false); + Some(VtElementFrame { + t, + old, + new, + old_on_top, + }) + } +} + +/// The values one named element animates with on one frame. +pub(crate) struct VtElementFrame { + t: f64, + old: SideSpec, + new: SideSpec, + old_on_top: bool, +} + +impl VtElementFrame { + /// The live element's opacity this frame, or `None` when it holds still. + pub(crate) fn new_opacity(&self) -> Option { + self.new + .opacity + .map(|[from, to]| motion::mix(from, to, self.t)) + } + + fn offset(x: Option<[VtLen; 2]>, y: Option<[VtLen; 2]>, t: f64, extent: Size) -> Point { + let resolve = |lens: Option<[VtLen; 2]>, extent: f32| { + lens.map_or(0.0, |[from, to]| { + motion::mix(from.resolve(extent as f64), to.resolve(extent as f64), t) + }) + }; + point( + px(resolve(x, f32::from(extent.width)) as f32), + px(resolve(y, f32::from(extent.height)) as f32), + ) + } + + fn new_offset(&self, extent: Size) -> Point { + Self::offset(self.new.translate_x, self.new.translate_y, self.t, extent) + } + + fn old_offset(&self, extent: Size) -> Point { + Self::offset(self.old.translate_x, self.old.translate_y, self.t, extent) + } + + fn old_opacity(&self) -> f64 { + self.old + .opacity + .map_or(1.0, |[from, to]| motion::mix(from, to, self.t)) + } +} + +// ── The transition element ─────────────────────────────────────────── + +/// Wrap one named live element for this frame of the transition. +pub(super) fn wrap( + element: &RetainedElement, + built: AnyElement, + frame: VtElementFrame, + ctx: &mut BuildCtx, + window: &mut Window, + cx: &mut gpui::Context, +) -> AnyElement { + use gpui::prelude::*; + + let name = element + .style + .as_deref() + .and_then(|style| style.view_transition_name.as_deref()) + .unwrap_or_default(); + let vt = ctx.vt; + let old = vt.and_then(|vt| vt.capture(name)).map(|capture| { + // The frozen tree builds through the same walk as the live one. The + // nested context clears `vt`, so a name inside the copy never starts + // a transition of its own. + let mut frozen_ctx = BuildCtx { + tree: &capture.tree, + event_callback: ctx.event_callback, + focus_handles: ctx.focus_handles, + scroll_handles: &mut *ctx.scroll_handles, + custom_registry: &mut *ctx.custom_registry, + virtual_lists: &mut *ctx.virtual_lists, + motion_states: &mut *ctx.motion_states, + scrollbars: &mut *ctx.scrollbars, + now: ctx.now, + motion_active: &mut *ctx.motion_active, + selection: ctx.selection.clone(), + cascade: ctx.cascade.clone(), + highlight: None, + highlights: &mut *ctx.highlights, + highlight_events: &mut *ctx.highlight_events, + vt: None, + }; + let content = build_element(capture.root, &mut frozen_ctx, window, cx); + // The shell fixes the copy at its captured size and carries this + // frame's opacity down the whole copy. + let mut shell = gpui::div() + .w(capture.size.width) + .h(capture.size.height) + .overflow_hidden(); + shell.style().opacity = Some(frame.old_opacity() as f32); + OldCopy { + element: shell.child(content).into_any_element(), + layout: IsolatedLayout::new(), + origin: capture.origin + frame.old_offset(capture.size), + size: capture.size, + } + }); + VtGroup { + child: built, + old, + frame, + } + .into_any_element() +} + +/// The frozen copy of one name, ready to paint at its captured place. +struct OldCopy { + element: AnyElement, + /// The copy lays out here rather than in the window's tree, because its + /// captured size is fixed and must not join the live layout. + layout: IsolatedLayout, + origin: Point, + size: Size, +} + +/// One named element while the transition runs. +/// +/// The group hands the live child's layout through untouched, so the page +/// around a transition lays out exactly as it will at rest. Movement happens +/// at paint: the child prepaints under an element offset, and the frozen copy +/// prepaints at its captured bounds. Both paint inside the group's bounds as +/// a mask, so a slide stays inside the element's own area. +struct VtGroup { + child: AnyElement, + old: Option, + frame: VtElementFrame, +} + +impl Element for VtGroup { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, ()) { + (self.child.request_layout(window, cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut (), + window: &mut Window, + cx: &mut App, + ) { + let offset = self.frame.new_offset(bounds.size); + window.with_content_mask(Some(ContentMask { bounds }), |window| { + if let Some(old) = &mut self.old { + let element = &mut old.element; + let origin = old.origin; + let extent = old.size; + old.layout.enter(window, |window| { + element.layout_as_root( + size( + AvailableSpace::Definite(extent.width), + AvailableSpace::Definite(extent.height), + ), + window, + cx, + ); + element.prepaint_at(origin, window, cx); + }); + } + window.with_element_offset(offset, |window| { + self.child.prepaint(window, cx); + }); + }); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut (), + _prepaint: &mut (), + window: &mut Window, + cx: &mut App, + ) { + window.with_content_mask(Some(ContentMask { bounds }), |window| { + let paint_old = |old: &mut Option, window: &mut Window, cx: &mut App| { + if let Some(old) = old { + let element = &mut old.element; + old.layout.enter(window, |window| element.paint(window, cx)); + } + }; + if self.frame.old_on_top { + self.child.paint(window, cx); + paint_old(&mut self.old, window, cx); + } else { + paint_old(&mut self.old, window, cx); + self.child.paint(window, cx); + } + }); + } +} + +impl IntoElement for VtGroup { + type Element = Self; + + fn into_element(self) -> Self { + self + } +} diff --git a/packages/native/src/style.rs b/packages/native/src/style.rs index 84901362..a44beab1 100644 --- a/packages/native/src/style.rs +++ b/packages/native/src/style.rs @@ -507,6 +507,10 @@ style_desc! { scroll_padding_bottom: Option = "scrollPaddingBottom", scroll_padding_left: Option = "scrollPaddingLeft", + // View transitions. The name pairs the element that leaves with the + // element that arrives across one `startViewTransition` call. + view_transition_name: Option = "viewTransitionName", + // Cursor cursor: Option = "cursor", /// `"auto"` blocks mouse hits behind this element. `"none"` never does. diff --git a/packages/native/src/test_renderer.rs b/packages/native/src/test_renderer.rs index 6979d306..435cef12 100644 --- a/packages/native/src/test_renderer.rs +++ b/packages/native/src/test_renderer.rs @@ -738,6 +738,36 @@ impl TestGpuixRenderer { }) } + /// Clone every element that has a `viewTransitionName`, with its painted + /// bounds. Call flush() first, so the bounds are current. + #[napi] + pub fn view_transition_capture(&self) -> Result<()> { + with_test_state(|cx, window, view| { + let view = view.clone(); + cx.update_window(window, |_, _window, app| { + view.update(app, |view, _cx| view.view_transition_capture()); + }) + .map_err(|e| Error::from_reason(e.to_string()))?; + Ok(()) + }) + } + + /// Animate every captured name toward its new element. Call flush() + /// after, and move the automation clock to step through the frames. + #[napi] + pub fn view_transition_start(&self, options: Option) -> Result<()> { + let options = options.unwrap_or_else(|| "{}".to_string()); + with_test_state(|cx, window, view| { + let view = view.clone(); + let result = cx + .update_window(window, |_, _window, app| { + view.update(app, |view, _cx| view.view_transition_start(&options)) + }) + .map_err(|e| Error::from_reason(e.to_string()))?; + result.map_err(Error::from_reason) + }) + } + /// Scroll a child into view by its index in the children list. /// Call flush() after to apply and re-render. #[napi] diff --git a/packages/react/src/__tests__/view-transitions.test.tsx b/packages/react/src/__tests__/view-transitions.test.tsx new file mode 100644 index 00000000..58d45c9e --- /dev/null +++ b/packages/react/src/__tests__/view-transitions.test.tsx @@ -0,0 +1,170 @@ +/** + * View transitions. The automation clock is paused, so every frame of the + * animation is read at an exact time. Bounds come from the paint trackers, + * which record where an element really painted, moved or not. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import React from "react" +import { createTestRoot, hasNativeTestRenderer, type TestRoot } from "../testing" +import { startViewTransition, type ViewTransitionOptions } from "../view-transitions" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +/** The `key` makes each screen its own element, the way a navigation swaps + * one screen component for another. Without it, React updates one element + * in place, which is the separate case the last test covers. */ +function Screen({ label, color }: { label: string; color: string }) { + return ( +
+ {label} +
+ ) +} + +/** The iOS push: the new screen slides in from the right over the old one, + * and the old one slides a third of the way out to the left. */ +const PUSH: ViewTransitionOptions = { + groups: { + screen: { + duration: 0.3, + ease: "linear", + old: { translateX: ["0%", "-30%"] }, + new: { translateX: ["100%", "0%"] }, + }, + }, +} + +describeNative("view transitions", () => { + let root: TestRoot + beforeEach(() => { + root = createTestRoot() + }) + afterEach(() => { + root.unmount() + }) + + const screenId = () => root.renderer.findByType("div")[0]!.id + const boundsOf = (id: number) => root.renderer.getElementBounds(id) + + it("slides the pair like an iOS push", () => { + const { render, renderer } = root + renderer.clockPause() + render() + const oldId = screenId() + const baseX = boundsOf(oldId)![0] + + startViewTransition(renderer, () => render(), PUSH) + const newId = screenId() + expect(newId).not.toBe(oldId) + + // At the start, the new screen sits one width to the right, and the + // frozen copy of the old one still paints at its place. + expect(boundsOf(newId)![0]).toBeCloseTo(baseX + 300, 0) + expect(boundsOf(oldId)![0]).toBeCloseTo(baseX, 0) + + // Halfway, with a linear ease: the new screen covered half its way in, + // and the old copy moved 15% of its width out. + renderer.clockFastForward(150) + expect(boundsOf(newId)![0]).toBeCloseTo(baseX + 150, 0) + expect(boundsOf(oldId)![0]).toBeCloseTo(baseX - 45, 0) + + // Past the end: the new screen rests at its layout place, and the copy + // paints no more. + renderer.clockFastForward(400) + expect(boundsOf(newId)![0]).toBeCloseTo(baseX, 0) + expect(boundsOf(oldId)).toBeNull() + }) + + it("crossfades by default without moving anything", () => { + const { render, renderer } = root + renderer.clockPause() + render() + const oldId = screenId() + const baseX = boundsOf(oldId)![0] + + startViewTransition(renderer, () => render()) + const newId = screenId() + + renderer.clockFastForward(150) + expect(boundsOf(newId)![0]).toBeCloseTo(baseX, 0) + expect(boundsOf(oldId)![0]).toBeCloseTo(baseX, 0) + + renderer.clockFastForward(400) + expect(boundsOf(oldId)).toBeNull() + }) + + it("animates a name that enters without a captured pair", () => { + const { render, renderer } = root + renderer.clockPause() + render(
) + + startViewTransition( + renderer, + () => render(), + { groups: { screen: { duration: 0.3, ease: "linear", new: { translateY: ["100%", "0%"] } } } } + ) + const id = screenId() + const baseY = 200 * 1.0 + + // The screen is 200 high, so it starts one height down and slides up. + expect(boundsOf(id)![1]).toBeCloseTo(baseY, 0) + renderer.clockFastForward(150) + expect(boundsOf(id)![1]).toBeCloseTo(baseY / 2, 0) + renderer.clockFastForward(400) + expect(boundsOf(id)![1]).toBeCloseTo(0, 0) + }) + + it("a fresh start replaces a running transition", () => { + const { render, renderer } = root + renderer.clockPause() + render() + const baseX = boundsOf(screenId())![0] + + startViewTransition(renderer, () => render(), PUSH) + renderer.clockFastForward(150) + + // Start again mid-flight. The second transition captures the moved pair + // and runs on its own clock from here. + startViewTransition(renderer, () => render(), PUSH) + const thirdId = screenId() + expect(boundsOf(thirdId)![0]).toBeCloseTo(baseX + 300, 0) + renderer.clockFastForward(500) + expect(boundsOf(thirdId)![0]).toBeCloseTo(baseX, 0) + }) + + it("transitions an element React updates in place", () => { + const { render, renderer } = root + renderer.clockPause() + // No keys: React keeps the element and only swaps its style. The frozen + // copy takes a fresh id, so the live element and the copy never share + // GPUI element state. + render(
) + const id = screenId() + + startViewTransition(renderer, () => + render( +
+ ) + ) + expect(screenId()).toBe(id) + renderer.clockFastForward(500) + expect(boundsOf(id)![3]).toBeCloseTo(100, 0) + }) + + it("runs the update alone on a renderer without the native methods", () => { + let ran = false + const bare = {} as Parameters[0] + startViewTransition(bare, () => { + ran = true + }) + expect(ran).toBe(true) + }) +}) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 140eddc7..f58b1d78 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -9,6 +9,13 @@ export { startFrameLoop, } from "./reconciler/renderer.js" export { GpuixContext, useGpuix, useGpuixRequired } from "./hooks/use-gpuix.js" +export { startViewTransition } from "./view-transitions.js" +export type { + ViewTransitionGroupOptions, + ViewTransitionLength, + ViewTransitionOptions, + ViewTransitionSide, +} from "./view-transitions.js" export { useWindowInsets, useWindowSize } from "./hooks/use-window-size.js" export { findRanges, useTextSearch } from "./hooks/use-text-search.js" export type { diff --git a/packages/react/src/testing.ts b/packages/react/src/testing.ts index e8a870de..d011795d 100644 --- a/packages/react/src/testing.ts +++ b/packages/react/src/testing.ts @@ -65,6 +65,8 @@ interface NativeTestRendererApi extends NativeRenderer { scrollToItem(elementId: number, index: number): void scrollIntoView(elementId: number, block?: string, inline?: string): void getScrollOffset(elementId: number): number[] | null + viewTransitionCapture(): void + viewTransitionStart(options?: string): void setDebugFrameOverlay(mode: DebugFrameOverlayMode): string getDebugFrameOverlay(): string cycleDebugFrameOverlay(): string @@ -551,6 +553,22 @@ export class TestRenderer implements NativeRenderer { return [result[0], result[1]] } + // ── View transitions ──────────────────────────────────────────── + + /** Clone every element that has a `viewTransitionName`, with its painted + * bounds. The flush first makes those bounds current. */ + viewTransitionCapture(): void { + this.native.flush() + this.native.viewTransitionCapture() + } + + /** Animate every captured name toward its new element. Pause the clock + * first and move it to step through the frames. */ + viewTransitionStart(options?: string): void { + this.native.viewTransitionStart(options) + this.native.flush() + } + // ── Selection API ─────────────────────────────────────────────── /** Drag-select from (x1,y1) to (x2,y2) and return the selected text. diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index 10a4f76a..06f70279 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -305,6 +305,9 @@ export interface StyleDesc { scrollPaddingRight?: Numeric scrollPaddingBottom?: Numeric scrollPaddingLeft?: Numeric + /** The name that pairs this element across a `startViewTransition` call: + * the old element with this name animates into the new one. */ + viewTransitionName?: string /** `auto`, `contain` or `none`, one word for both axes or two with the x * axis first. A scroll box keeps a wheel event it can scroll with. At its * end, `auto` hands the event to the nearest scroll box around it and @@ -784,6 +787,15 @@ export interface NativeRenderer { /** Get the current scroll offset [x, y] or null if element is not scrollable. */ getScrollOffset?(elementId: number): Array | null + // ── View transitions ─────────────────────────────────────────── + /** Clone every element that has a `viewTransitionName`, with its painted + * bounds. Call before the update, then `viewTransitionStart` after it. + * `startViewTransition` does both. */ + viewTransitionCapture?(): void + /** Animate every captured name toward its new element. `options` is the + * JSON of a `ViewTransitionOptions` value, or nothing for a crossfade. */ + viewTransitionStart?(options?: string): void + // ── Selection API ────────────────────────────────────────────── /** The current text selection joined in document order, or null. */ getSelectedText?(): string | null diff --git a/packages/react/src/view-transitions.ts b/packages/react/src/view-transitions.ts new file mode 100644 index 00000000..03b80e39 --- /dev/null +++ b/packages/react/src/view-transitions.ts @@ -0,0 +1,64 @@ +// The View Transitions API: capture the named elements, apply the React +// update synchronously, then animate each name from its old place to its +// new one. The native renderer owns the animation, so React renders once. + +import { flushSync } from "./reconciler/reconciler.js" +import type { MotionEase, NativeRenderer } from "./types/host.js" + +/** A translation distance: pixels as a number or "Npx", or a share of the + * element's size as "N%". */ +export type ViewTransitionLength = number | string + +/** What one side of a pair does over the transition. Every field is a + * `[from, to]` pair. A missing field holds still. */ +export interface ViewTransitionSide { + translateX?: [ViewTransitionLength, ViewTransitionLength] + translateY?: [ViewTransitionLength, ViewTransitionLength] + opacity?: [number, number] + /** Paint this side over the other one. Only read on `old`. */ + onTop?: boolean +} + +export interface ViewTransitionGroupOptions { + /** Seconds, like the `motion` prop. The default is 0.3. */ + duration?: number + /** Seconds before this group starts. */ + delay?: number + ease?: MotionEase + /** The element that leaves. When a group gives neither `old` nor `new`, + * the pair crossfades. */ + old?: ViewTransitionSide + /** The element that arrives. */ + new?: ViewTransitionSide +} + +export interface ViewTransitionOptions { + /** Seconds, for every group that does not set its own. */ + duration?: number + delay?: number + ease?: MotionEase + /** Options per `viewTransitionName`. A name with no entry crossfades. */ + groups?: Record +} + +/** + * Run `update` and animate every element that carries a + * `viewTransitionName` from its place before the update to its place after + * it. Give the leaving screen and the arriving screen the same name to + * animate a navigation as a pair. + * + * On a renderer without the native methods, this runs `update` alone. + */ +export function startViewTransition( + renderer: NativeRenderer, + update: () => void, + options?: ViewTransitionOptions, +): void { + if (!renderer.viewTransitionCapture || !renderer.viewTransitionStart) { + update() + return + } + renderer.viewTransitionCapture() + flushSync(update) + renderer.viewTransitionStart(JSON.stringify(options ?? {})) +} From 96907e93835aec842ce8f3e916b5b8980f7581ee Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 17:44:57 +0200 Subject: [PATCH 2/4] feat(view-transitions): blur channel and exit copies for names without a successor --- .changeset/view-transitions.md | 13 +- examples/demo/navigation.tsx | 138 +++++++-- packages/native/src/motion.rs | 55 +++- packages/native/src/renderer.rs | 13 +- packages/native/src/renderer/frame.rs | 30 +- .../native/src/renderer/view_transition.rs | 284 +++++++++++++++--- packages/native/src/style/resolve.rs | 3 + .../src/__tests__/view-transitions.test.tsx | 26 ++ packages/react/src/types/host.ts | 2 + packages/react/src/view-transitions.ts | 6 +- 10 files changed, 474 insertions(+), 96 deletions(-) diff --git a/.changeset/view-transitions.md b/.changeset/view-transitions.md index d66af72a..7eced04c 100644 --- a/.changeset/view-transitions.md +++ b/.changeset/view-transitions.md @@ -13,16 +13,19 @@ tree while the transition runs, so the leaving screen stays visible under, or over, the arriving one. Options take a duration, a delay, and an ease per name, plus `translateX`, -`translateY` and `opacity` ranges for the old side and the new side. Percent -lengths resolve against the size of the named element, so +`translateY`, `opacity` and `blur` ranges for the old side and the new side. +Percent lengths resolve against the size of the named element, so `translateX: ["100%", "0%"]` slides a screen in from the right at any width. A name with no options crossfades. A name that only enters animates against -its own bounds. +its own bounds. A name that only leaves paints its frozen copy over the tree +while the `old` side runs, without the clip of its former ancestors. + +The `motion` prop takes a `blur` field too: a `filter: blur()` sigma in +pixels that interpolates like `opacity`. The new side moves through the motion channel, so the live element and its hitboxes move together, and input lands where the screen paints. The frozen copy takes fresh ids where the live tree still uses them, so a surviving element and its copy never share GPUI element state. -Limits in this version: a name that only leaves paints nothing, and the -frozen copy takes no input. +Limits in this version: the frozen copy takes no input. diff --git a/examples/demo/navigation.tsx b/examples/demo/navigation.tsx index 5d5c4c1c..04351975 100644 --- a/examples/demo/navigation.tsx +++ b/examples/demo/navigation.tsx @@ -1,40 +1,63 @@ /// View transitions, shown as the push and pop of the iOS Settings app. /// -/// The two screens carry the same `viewTransitionName`, so one -/// `startViewTransition` call animates them as a pair. On a push, the new -/// screen slides in from the right over the old one, and the old one slides -/// 30% of its width to the left. On a pop, the same move runs backwards, and -/// the leaving screen stays on top while it slides out. +/// The header stays mounted the whole time, and only its content takes part +/// in the transition. The back button enters and leaves through a blur and +/// opacity pair. The title of each screen carries the name "nav-title", so +/// the old title slides and blurs out while the new one slides and blurs in. +/// The screens slide under the header as a pair, and a backdrop blur with an +/// eased mask blurs the rows progressively where they pass under it. import React, { useState } from "react" import { startViewTransition, useGpuix } from "@gpuix/react" import type { NativeRenderer, ViewTransitionOptions } from "@gpuix/react" import { Panel } from "./ui.js" +const HEADER_HEIGHT = 56 +/// How far past the bar the backdrop blur fades out. +const BLUR_TAIL = 28 + const PUSH: ViewTransitionOptions = { + duration: 0.35, + ease: "easeOut", groups: { screen: { - duration: 0.35, - ease: "easeOut", old: { translateX: ["0%", "-30%"] }, new: { translateX: ["100%", "0%"] }, }, + "nav-back": { new: { opacity: [0, 1], blur: [6, 0] } }, + "nav-title": { + old: { opacity: [1, 0], translateX: ["0%", "-40%"], blur: [0, 4] }, + new: { opacity: [0, 1], translateX: ["40%", "0%"], blur: [4, 0] }, + }, }, } const POP: ViewTransitionOptions = { + duration: 0.35, + ease: "easeOut", groups: { screen: { - duration: 0.35, - ease: "easeOut", old: { translateX: ["0%", "100%"], onTop: true }, new: { translateX: ["-30%", "0%"] }, }, + "nav-back": { old: { opacity: [1, 0], blur: [0, 6] } }, + "nav-title": { + old: { opacity: [1, 0], translateX: ["0%", "40%"], blur: [0, 4] }, + new: { opacity: [0, 1], translateX: ["-40%", "0%"], blur: [4, 0] }, + }, }, } -const GENERAL_ROWS = ["About", "Software Update", "Storage", "AppleCare", "AirDrop"] -const ROOT_ROWS = ["General", "Display", "Sound", "Focus", "Battery"] +const ROOT_ROWS = [ + "General", "Display", "Sound", "Focus", "Battery", + "Privacy", "Wallpaper", "Siri", "Wallet", "Accounts", + "App Store", "Game Center", "Developer", +] +const GENERAL_ROWS = [ + "About", "Software Update", "Storage", "AppleCare", "AirDrop", + "AirPlay", "Picture in Picture", "CarPlay", "Keyboard", "Fonts", + "Language", "Dictionary", "VPN", "Legal", +] function NavRow({ label, detail, onClick }: { label: string @@ -59,33 +82,81 @@ function NavRow({ label, detail, onClick }: { ) } -function TitleBar({ title, onBack }: { title: string; onBack?: () => void }) { +/// The header that never unmounts. The first layer is the progressive blur: +/// a backdrop blur whose eased mask fades it out past the bar, so the rows +/// blur where they pass under it. The title and the back button sit on top +/// of that layer, and each carries its own view transition name. +function Header({ screen, onBack }: { + screen: "root" | "general" + onBack: () => void +}) { + const title = screen === "root" ? "Settings" : "General" return ( -
- {onBack ? ( -
- {"< Settings"} + <> +
+
+
+ {title}
- ) : null} -
- {title} -
- {onBack ?
: null} -
+
+
+ {screen === "general" ? ( +
+ {"< Settings"} +
+ ) : null} +
+ ) } /// One screen of the stack. The name pairs it with the screen it replaces, /// and the key makes React mount a new element instead of an update in -/// place, the way a real navigation swaps components. +/// place, the way a real navigation swaps components. The top padding puts +/// the first row under the header, and the rows scroll under it. function Screen({ children }: { children: React.ReactNode }) { return (
{children}
@@ -105,11 +176,16 @@ function Phone({ renderer }: { renderer: NativeRenderer | null }) { return (
{screen === "root" ? ( - {ROOT_ROWS.map((label) => ( ) : ( - go("root", POP)} /> {GENERAL_ROWS.map((label) => ( ))} )} +
go("root", POP)} />
) } @@ -135,7 +211,7 @@ export function Navigation() { return ( diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index 37f2bcc3..816dcc44 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -15,6 +15,8 @@ pub(crate) struct MotionStyle { pub width: Option, pub height: Option, pub opacity: Option, + /// A `filter: blur()` sigma in pixels, on the element and its children. + pub blur: Option, pub top: Option, pub right: Option, pub bottom: Option, @@ -198,6 +200,7 @@ impl MotionStyle { .height .map(|to| self.height.unwrap_or(to).mix(to, progress)), opacity: value(self.opacity, target.opacity, progress), + blur: value(self.blur, target.blur, progress), top: value(self.top, target.top, progress), right: value(self.right, target.right, progress), bottom: value(self.bottom, target.bottom, progress), @@ -221,6 +224,9 @@ impl MotionStyle { if let Some(value) = self.opacity { style.opacity = Some(value.into()); } + if let Some(value) = self.blur { + style.filter = Some(format!("blur({value}px)")); + } if let Some(value) = self.top { style.top = Some(value.into()); } @@ -331,12 +337,13 @@ impl MotionFrame { } /// A frame a view transition composes for the arriving element of a pair. - /// It carries only the opacity of this animation frame. The transition - /// element applies the movement at paint. - pub(crate) fn view_transition_opacity(opacity: f64) -> Self { + /// It carries the opacity and the blur of this animation frame. The + /// transition element applies the movement at paint. + pub(crate) fn view_transition_frame(opacity: Option, blur: Option) -> Self { Self { style: MotionStyle { - opacity: Some(opacity), + opacity, + blur, ..MotionStyle::default() }, active: true, @@ -345,10 +352,19 @@ impl MotionFrame { } } - /// Fold a view-transition opacity into this frame. The transition owns the - /// element while it runs, so its opacity replaces the motion one. - pub(crate) fn with_view_transition_opacity(mut self, opacity: f64) -> Self { - self.style.opacity = Some(opacity); + /// Fold a view transition into this frame. The transition owns the + /// element while it runs, so its values replace the motion ones. + pub(crate) fn with_view_transition( + mut self, + opacity: Option, + blur: Option, + ) -> Self { + if opacity.is_some() { + self.style.opacity = opacity; + } + if blur.is_some() { + self.style.blur = blur; + } self.active = true; self } @@ -523,6 +539,7 @@ fn validate_style(style: &MotionStyle) -> Result<(), String> { ("width", style.width), ("height", style.height.map(|height| height.pixels)), ("opacity", style.opacity), + ("blur", style.blur), ("top", style.top), ("right", style.right), ("bottom", style.bottom), @@ -536,8 +553,9 @@ fn validate_style(style: &MotionStyle) -> Result<(), String> { if style.width.is_some_and(|value| value < 0.0) || style.height.is_some_and(|height| height.pixels < 0.0) || style.border_radius.is_some_and(|value| value < 0.0) + || style.blur.is_some_and(|value| value < 0.0) { - return Err("motion sizes and borderRadius must be non-negative".to_string()); + return Err("motion sizes, borderRadius and blur must be non-negative".to_string()); } if style .opacity @@ -687,6 +705,25 @@ mod tests { ); } + #[test] + fn blur_interpolates_and_folds_into_the_filter() { + let started = Instant::now(); + let spec = serde_json::json!({ + "initial": { "blur": 0.0 }, + "animate": { "blur": 8.0 }, + "transition": { "duration": 1.0, "ease": "linear" } + }); + let state = MotionState::new(&spec, started).unwrap(); + let frame = state.frame(started + Duration::from_millis(500)); + assert_eq!(frame.style.blur, Some(4.0)); + let mut style = StyleDesc::default(); + frame.style.apply_to(&mut style); + assert_eq!(style.filter.as_deref(), Some("blur(4px)")); + + let bad = serde_json::json!({ "animate": { "blur": -1.0 }, "transition": {} }); + assert!(MotionState::new(&bad, started).is_err()); + } + #[test] fn disabled_initial_state_starts_at_the_target() { let now = Instant::now(); diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index 089f61e5..04ee693b 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -2978,6 +2978,7 @@ impl GpuixView { highlights: &mut self.highlights, highlight_events: &mut highlight_events, vt: self.view_transition.as_ref(), + frozen: false, }; let child = build_element(expected_child_id, &mut build_ctx, window, cx); emit_highlight_events(&callback, &highlight_events); @@ -3211,7 +3212,7 @@ impl gpui::Render for GpuixView { .is_some_and(|element| element.custom_props.contains_key("highlight")) }); let mut highlight_events = Vec::new(); - let result = match tree.root_id { + let (result, exit_copies) = match tree.root_id { Some(root_id) => { let mut ctx = BuildCtx { tree: &tree, @@ -3230,10 +3231,15 @@ impl gpui::Render for GpuixView { highlights: &mut self.highlights, highlight_events: &mut highlight_events, vt: view_transition.as_ref(), + frozen: false, }; - build_element(root_id, &mut ctx, window, cx) + let root = build_element(root_id, &mut ctx, window, cx); + // Names captured without a successor paint as frozen copies + // over the tree. Built after the root, so they paint last. + let copies = view_transition::exit_copies(&mut ctx, window, cx); + (root, copies) } - None => gpui::Empty.into_any_element(), + None => (gpui::Empty.into_any_element(), Vec::new()), }; // A transition animates every frame until it comes to rest. if view_transition.is_some() { @@ -3258,6 +3264,7 @@ impl gpui::Render for GpuixView { .child(selection_frame_reset(self.selection.clone())) .child(crate::automation::bounds_frame_reset()) .child(result) + .children(exit_copies) .into_any_element() }; diff --git a/packages/native/src/renderer/frame.rs b/packages/native/src/renderer/frame.rs index 15abfaee..1e66ef6e 100644 --- a/packages/native/src/renderer/frame.rs +++ b/packages/native/src/renderer/frame.rs @@ -52,6 +52,11 @@ pub(super) struct BuildCtx<'a> { /// The running view transition, or `None`. Cleared inside the build of a /// frozen copy, so a name inside the copy never starts a nested one. pub vt: Option<&'a super::view_transition::VtState>, + /// Whether this build is the frozen copy of a view transition. A copy is + /// a still image, so it gets no scrollbar. The scrollbar defers its draw, + /// and a deferred draw from inside the copy's isolated layout would read + /// its layout ids against the window's tree and panic. + pub frozen: bool, } // ── Element builders ───────────────────────────────────────────────── @@ -97,8 +102,9 @@ pub(super) fn build_element( let style = element.style.as_deref(); // This frame of the view transition, when one runs and the element - // carries a name. The opacity of the arriving side folds into the motion - // channel here, and the movement applies at paint in the wrapper below. + // carries a name. The opacity and blur of the arriving side fold into the + // motion channel here, and the movement applies at paint in the wrapper + // below. let vt_frame = ctx.vt.and_then(|vt| { let name = style?.view_transition_name.as_deref()?; if name.is_empty() || name == "none" { @@ -106,12 +112,15 @@ pub(super) fn build_element( } vt.frame_for(name) }); - let motion = match vt_frame.as_ref().and_then(|frame| frame.new_opacity()) { - Some(opacity) => Some(match motion { - Some(frame) => frame.with_view_transition_opacity(opacity), - None => crate::motion::MotionFrame::view_transition_opacity(opacity), + let motion = match vt_frame + .as_ref() + .map(|frame| (frame.new_opacity(), frame.new_blur())) + { + Some((opacity, blur)) if opacity.is_some() || blur.is_some() => Some(match motion { + Some(frame) => frame.with_view_transition(opacity, blur), + None => crate::motion::MotionFrame::view_transition_frame(opacity, blur), }), - None => motion, + _ => motion, }; // Inheritable style resolves before the element's own style, because a @@ -499,9 +508,12 @@ pub(crate) fn build_div( el = el.track_scroll(handle); // The scrollbar. Classic bars reserve a gutter in the layout, - // which taffy takes as one width for both axes. + // which taffy takes as one width for both axes. A frozen view + // transition copy gets none: see `BuildCtx::frozen`. let mode = super::scrollbar::Mode::current(cx); - if let Some(spec) = super::scrollbar::Spec::from_style(style, mode) { + if let Some(spec) = + super::scrollbar::Spec::from_style(style, mode).filter(|_| !ctx.frozen) + { let state = ctx.scrollbars.entry(element.id).or_default().clone(); let reserved = spec.reserved(state.borrow().overflowed); let gutter = reserved.x.max(reserved.y); diff --git a/packages/native/src/renderer/view_transition.rs b/packages/native/src/renderer/view_transition.rs index b35a2ef2..0092991c 100644 --- a/packages/native/src/renderer/view_transition.rs +++ b/packages/native/src/renderer/view_transition.rs @@ -11,9 +11,14 @@ //! the same style channel that `motion` uses, and the frozen copy carries its //! opacity on a wrapper element. //! +//! A name that disappears without a successor becomes an exit copy: the +//! renderer paints its frozen copy over the whole tree at its captured +//! place, and the group's `old` side drives it. +//! //! Known limits, on purpose: -//! - A name that disappears without a successor paints nothing. Give both -//! screens one name to animate an exit as a pair. +//! - An exit copy paints over the tree, so a former ancestor's clip or +//! scroll no longer applies to it. Give both screens one name when the +//! exit must stay inside the element's own area. //! - When the named element survives the swap, its frozen copy takes fresh //! ids, so the copy paints without the old scroll offsets. //! - The frozen copy keeps its event listeners, but their elements are gone @@ -97,6 +102,8 @@ struct SideSpec { translate_x: Option<[VtLen; 2]>, translate_y: Option<[VtLen; 2]>, opacity: Option<[f64; 2]>, + /// A `filter: blur()` sigma in pixels, as a `[from, to]` pair. + blur: Option<[f64; 2]>, /// Paint this side over the other one. Only read on the old side. on_top: Option, } @@ -407,6 +414,20 @@ impl VtElementFrame { .map(|[from, to]| motion::mix(from, to, self.t)) } + /// The live element's blur sigma this frame, or `None` when it holds + /// still. + pub(crate) fn new_blur(&self) -> Option { + self.new + .blur + .map(|[from, to]| motion::mix(from, to, self.t).max(0.0)) + } + + fn old_blur(&self) -> Option { + self.old + .blur + .map(|[from, to]| motion::mix(from, to, self.t).max(0.0)) + } + fn offset(x: Option<[VtLen; 2]>, y: Option<[VtLen; 2]>, t: f64, extent: Size) -> Point { let resolve = |lens: Option<[VtLen; 2]>, extent: f32| { lens.map_or(0.0, |[from, to]| { @@ -453,42 +474,11 @@ pub(super) fn wrap( .and_then(|style| style.view_transition_name.as_deref()) .unwrap_or_default(); let vt = ctx.vt; - let old = vt.and_then(|vt| vt.capture(name)).map(|capture| { - // The frozen tree builds through the same walk as the live one. The - // nested context clears `vt`, so a name inside the copy never starts - // a transition of its own. - let mut frozen_ctx = BuildCtx { - tree: &capture.tree, - event_callback: ctx.event_callback, - focus_handles: ctx.focus_handles, - scroll_handles: &mut *ctx.scroll_handles, - custom_registry: &mut *ctx.custom_registry, - virtual_lists: &mut *ctx.virtual_lists, - motion_states: &mut *ctx.motion_states, - scrollbars: &mut *ctx.scrollbars, - now: ctx.now, - motion_active: &mut *ctx.motion_active, - selection: ctx.selection.clone(), - cascade: ctx.cascade.clone(), - highlight: None, - highlights: &mut *ctx.highlights, - highlight_events: &mut *ctx.highlight_events, - vt: None, - }; - let content = build_element(capture.root, &mut frozen_ctx, window, cx); - // The shell fixes the copy at its captured size and carries this - // frame's opacity down the whole copy. - let mut shell = gpui::div() - .w(capture.size.width) - .h(capture.size.height) - .overflow_hidden(); - shell.style().opacity = Some(frame.old_opacity() as f32); - OldCopy { - element: shell.child(content).into_any_element(), - layout: IsolatedLayout::new(), - origin: capture.origin + frame.old_offset(capture.size), - size: capture.size, - } + let old = vt.and_then(|vt| vt.capture(name)).map(|capture| OldCopy { + element: build_frozen(capture, &frame, ctx, window, cx), + layout: IsolatedLayout::new(), + origin: capture.origin + frame.old_offset(capture.size), + size: capture.size, }); VtGroup { child: built, @@ -498,6 +488,101 @@ pub(super) fn wrap( .into_any_element() } +/// Build the frozen copy of one capture, held at its captured size and faded +/// and blurred for this frame. +fn build_frozen( + capture: &VtCapture, + frame: &VtElementFrame, + ctx: &mut BuildCtx, + window: &mut Window, + cx: &mut gpui::Context, +) -> AnyElement { + use gpui::prelude::*; + + // The frozen tree builds through the same walk as the live one. The + // nested context clears `vt`, so a name inside the copy never starts + // a transition of its own. + let mut frozen_ctx = BuildCtx { + tree: &capture.tree, + event_callback: ctx.event_callback, + focus_handles: ctx.focus_handles, + scroll_handles: &mut *ctx.scroll_handles, + custom_registry: &mut *ctx.custom_registry, + virtual_lists: &mut *ctx.virtual_lists, + motion_states: &mut *ctx.motion_states, + scrollbars: &mut *ctx.scrollbars, + now: ctx.now, + motion_active: &mut *ctx.motion_active, + selection: ctx.selection.clone(), + cascade: ctx.cascade.clone(), + highlight: None, + highlights: &mut *ctx.highlights, + highlight_events: &mut *ctx.highlight_events, + vt: None, + frozen: true, + }; + let content = build_element(capture.root, &mut frozen_ctx, window, cx); + // The shell fixes the copy at its captured size and carries this + // frame's opacity down the whole copy. + let mut shell = gpui::div() + .w(capture.size.width) + .h(capture.size.height) + .overflow_hidden(); + shell.style().opacity = Some(frame.old_opacity() as f32); + if let Some(blur) = frame.old_blur() { + shell = shell.blur(px(blur as f32)); + } + shell.child(content).into_any_element() +} + +/// Build a frozen copy for every captured name that has no live element this +/// frame. The renderer appends these to the root wrapper, so they paint over +/// the tree at their captured place while the group's `old` side fades or +/// moves them out. +pub(super) fn exit_copies( + ctx: &mut BuildCtx, + window: &mut Window, + cx: &mut gpui::Context, +) -> Vec { + use gpui::prelude::*; + + let Some(vt) = ctx.vt else { + return Vec::new(); + }; + let live: HashSet<&str> = ctx + .tree + .elements + .values() + .filter_map(|element| element.style.as_deref()?.view_transition_name.as_deref()) + .collect(); + // Sorted, so two exit copies paint in the same order on every frame. + let mut names: Vec<&String> = vt + .captures + .keys() + .filter(|name| !live.contains(name.as_str())) + .collect(); + names.sort(); + let mut copies = Vec::new(); + for name in names { + let Some(capture) = vt.capture(name) else { + continue; + }; + let Some(frame) = vt.frame_for(name) else { + continue; + }; + copies.push( + ExitCopy { + element: build_frozen(capture, &frame, ctx, window, cx), + layout: IsolatedLayout::new(), + origin: capture.origin + frame.old_offset(capture.size), + size: capture.size, + } + .into_any_element(), + ); + } + copies +} + /// The frozen copy of one name, ready to paint at its captured place. struct OldCopy { element: AnyElement, @@ -611,3 +696,126 @@ impl IntoElement for VtGroup { self } } + +/// One exit-only frozen copy, painted over the tree at its captured place. +/// +/// The element asks for no layout space of its own. The copy lays out in its +/// own isolated tree at its captured size, so the live layout never sees it. +struct ExitCopy { + element: AnyElement, + layout: IsolatedLayout, + origin: Point, + size: Size, +} + +impl Element for ExitCopy { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, ()) { + ( + window.request_layout(gpui::Style::default(), None::, cx), + (), + ) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut (), + window: &mut Window, + cx: &mut App, + ) { + let element = &mut self.element; + let origin = self.origin; + let extent = self.size; + self.layout.enter(window, |window| { + element.layout_as_root( + size( + AvailableSpace::Definite(extent.width), + AvailableSpace::Definite(extent.height), + ), + window, + cx, + ); + element.prepaint_at(origin, window, cx); + }); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut (), + _prepaint: &mut (), + window: &mut Window, + cx: &mut App, + ) { + let element = &mut self.element; + self.layout.enter(window, |window| element.paint(window, cx)); + } +} + +impl IntoElement for ExitCopy { + type Element = Self; + + fn into_element(self) -> Self { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + /// A state with no captures, halted at `elapsed_ms` into the transition. + fn state_at(options: &str, elapsed_ms: u64) -> VtState { + let options = VtOptions::parse(options).unwrap(); + let started = Instant::now(); + VtState { + captures: HashMap::new(), + options, + started: Some(started), + frame_now: Some(started + Duration::from_millis(elapsed_ms)), + ids: HashSet::new(), + } + } + + #[test] + fn blur_mixes_on_both_sides() { + let state = state_at( + r#"{"groups":{"screen":{"duration":0.3,"ease":"linear","old":{"blur":[0,6]},"new":{"blur":[6,0],"opacity":[0,1]}}}}"#, + 150, + ); + let frame = state.frame_for("screen").unwrap(); + assert_eq!(frame.new_blur(), Some(3.0)); + assert_eq!(frame.old_blur(), Some(3.0)); + assert_eq!(frame.new_opacity(), Some(0.5)); + } + + #[test] + fn a_side_without_blur_holds_still() { + let state = state_at(r#"{"groups":{"screen":{"old":{"opacity":[1,0]}}}}"#, 150); + let frame = state.frame_for("screen").unwrap(); + assert_eq!(frame.new_blur(), None); + assert_eq!(frame.old_blur(), None); + } +} diff --git a/packages/native/src/style/resolve.rs b/packages/native/src/style/resolve.rs index de6971d2..8a550345 100644 --- a/packages/native/src/style/resolve.rs +++ b/packages/native/src/style/resolve.rs @@ -216,6 +216,9 @@ pub(crate) fn apply_motion( if let Some(opacity) = motion.opacity { el = el.opacity(opacity as f32); } + if let Some(blur) = motion.blur { + el = el.blur(gpui::px(blur as f32)); + } el } diff --git a/packages/react/src/__tests__/view-transitions.test.tsx b/packages/react/src/__tests__/view-transitions.test.tsx index 58d45c9e..30b04aa0 100644 --- a/packages/react/src/__tests__/view-transitions.test.tsx +++ b/packages/react/src/__tests__/view-transitions.test.tsx @@ -122,6 +122,32 @@ describeNative("view transitions", () => { expect(boundsOf(id)![1]).toBeCloseTo(0, 0) }) + it("paints an exit copy for a name that leaves without a successor", () => { + const { render, renderer } = root + renderer.clockPause() + render() + const oldId = screenId() + const baseX = boundsOf(oldId)![0] + + // The next tree has no element with the name. The frozen copy paints + // over the tree and the `old` side slides and blurs it out. + startViewTransition(renderer, () => render(
), { + groups: { + screen: { + duration: 0.3, + ease: "linear", + old: { translateX: ["0%", "100%"], opacity: [1, 0], blur: [0, 6] }, + }, + }, + }) + + expect(boundsOf(oldId)![0]).toBeCloseTo(baseX, 0) + renderer.clockFastForward(150) + expect(boundsOf(oldId)![0]).toBeCloseTo(baseX + 150, 0) + renderer.clockFastForward(400) + expect(boundsOf(oldId)).toBeNull() + }) + it("a fresh start replaces a running transition", () => { const { render, renderer } = root renderer.clockPause() diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index 06f70279..78ae7bd8 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -26,6 +26,8 @@ export interface MotionStyle { */ height?: number | "auto" opacity?: Numeric + /** A `filter: blur()` sigma in pixels, on the element and its children. */ + blur?: Numeric top?: Numeric right?: Numeric bottom?: Numeric diff --git a/packages/react/src/view-transitions.ts b/packages/react/src/view-transitions.ts index 03b80e39..a098f342 100644 --- a/packages/react/src/view-transitions.ts +++ b/packages/react/src/view-transitions.ts @@ -15,6 +15,8 @@ export interface ViewTransitionSide { translateX?: [ViewTransitionLength, ViewTransitionLength] translateY?: [ViewTransitionLength, ViewTransitionLength] opacity?: [number, number] + /** A `filter: blur()` sigma in pixels. */ + blur?: [number, number] /** Paint this side over the other one. Only read on `old`. */ onTop?: boolean } @@ -45,7 +47,9 @@ export interface ViewTransitionOptions { * Run `update` and animate every element that carries a * `viewTransitionName` from its place before the update to its place after * it. Give the leaving screen and the arriving screen the same name to - * animate a navigation as a pair. + * animate a navigation as a pair. A name that only leaves paints a frozen + * copy over the tree while its group's `old` side runs, without the clip + * of its former ancestors. * * On a renderer without the native methods, this runs `update` alone. */ From 8d74c5032f6ec2bab1e1ca0d98ec546114c2c0ce Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 17:49:05 +0200 Subject: [PATCH 3/4] feat(demo): morph the header title with blur only and drop the header border --- examples/demo/navigation.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/examples/demo/navigation.tsx b/examples/demo/navigation.tsx index 04351975..3c7b1891 100644 --- a/examples/demo/navigation.tsx +++ b/examples/demo/navigation.tsx @@ -3,9 +3,10 @@ /// The header stays mounted the whole time, and only its content takes part /// in the transition. The back button enters and leaves through a blur and /// opacity pair. The title of each screen carries the name "nav-title", so -/// the old title slides and blurs out while the new one slides and blurs in. -/// The screens slide under the header as a pair, and a backdrop blur with an -/// eased mask blurs the rows progressively where they pass under it. +/// the old text blurs and fades out in place while the new text sharpens in, +/// a text morph. The screens slide under the header as a pair, and a +/// backdrop blur with an eased mask blurs the rows progressively where they +/// pass under it. import React, { useState } from "react" import { startViewTransition, useGpuix } from "@gpuix/react" @@ -26,8 +27,8 @@ const PUSH: ViewTransitionOptions = { }, "nav-back": { new: { opacity: [0, 1], blur: [6, 0] } }, "nav-title": { - old: { opacity: [1, 0], translateX: ["0%", "-40%"], blur: [0, 4] }, - new: { opacity: [0, 1], translateX: ["40%", "0%"], blur: [4, 0] }, + old: { opacity: [1, 0], blur: [0, 4] }, + new: { opacity: [0, 1], blur: [4, 0] }, }, }, } @@ -42,8 +43,8 @@ const POP: ViewTransitionOptions = { }, "nav-back": { old: { opacity: [1, 0], blur: [0, 6] } }, "nav-title": { - old: { opacity: [1, 0], translateX: ["0%", "40%"], blur: [0, 4] }, - new: { opacity: [0, 1], translateX: ["-40%", "0%"], blur: [4, 0] }, + old: { opacity: [1, 0], blur: [0, 4] }, + new: { opacity: [0, 1], blur: [4, 0] }, }, }, } @@ -115,8 +116,6 @@ function Header({ screen, onBack }: { right: 0, height: HEADER_HEIGHT, justifyContent: "center", - borderBottomWidth: 1, - borderColor: "var(--color-line)", pointerEvents: "none", }} > From 1920ea89160a6fc2f486c729ecc7d20f4fa059c2 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 17:57:08 +0200 Subject: [PATCH 4/4] fix(view-transitions): let a blur halo paint past the group and spring the nav demo --- examples/demo/navigation.tsx | 14 ++++---- .../native/src/renderer/view_transition.rs | 35 +++++++++++++++---- zed | 2 +- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/examples/demo/navigation.tsx b/examples/demo/navigation.tsx index 3c7b1891..9ddf384c 100644 --- a/examples/demo/navigation.tsx +++ b/examples/demo/navigation.tsx @@ -16,10 +16,13 @@ import { Panel } from "./ui.js" const HEADER_HEIGHT = 56 /// How far past the bar the backdrop blur fades out. const BLUR_TAIL = 28 +/// A spring without bounce: fast out of the gate, a long soft landing, +/// and no overshoot, like a critically damped UIKit spring. +const SPRING: [number, number, number, number] = [0.36, 0.66, 0.04, 1] const PUSH: ViewTransitionOptions = { - duration: 0.35, - ease: "easeOut", + duration: 0.45, + ease: SPRING, groups: { screen: { old: { translateX: ["0%", "-30%"] }, @@ -34,8 +37,8 @@ const PUSH: ViewTransitionOptions = { } const POP: ViewTransitionOptions = { - duration: 0.35, - ease: "easeOut", + duration: 0.45, + ease: SPRING, groups: { screen: { old: { translateX: ["0%", "100%"], onTop: true }, @@ -101,9 +104,8 @@ function Header({ screen, onBack }: { left: 0, right: 0, height: HEADER_HEIGHT + BLUR_TAIL, - backdropFilter: "blur(16px) saturate(160%)", + backdropFilter: "blur(16px)", maskImage: "linear-gradient(to bottom, black 50%, ease-in-out, transparent)", - backgroundColor: "rgb(10 10 14 / 0.4)", pointerEvents: "none", }} /> diff --git a/packages/native/src/renderer/view_transition.rs b/packages/native/src/renderer/view_transition.rs index 0092991c..3ff151b4 100644 --- a/packages/native/src/renderer/view_transition.rs +++ b/packages/native/src/renderer/view_transition.rs @@ -428,6 +428,17 @@ impl VtElementFrame { .map(|[from, to]| motion::mix(from, to, self.t).max(0.0)) } + /// How far past the group's bounds this frame's blur reaches: three + /// sigmas of the widest blur among the two sides. The group's mask + /// grows by this, so the halo paints instead of clipping at the edge. + fn mask_inflation(&self) -> Pixels { + let sigma = self + .new_blur() + .unwrap_or(0.0) + .max(self.old_blur().unwrap_or(0.0)); + px((3.0 * sigma).ceil() as f32) + } + fn offset(x: Option<[VtLen; 2]>, y: Option<[VtLen; 2]>, t: f64, extent: Size) -> Point { let resolve = |lens: Option<[VtLen; 2]>, extent: f32| { lens.map_or(0.0, |[from, to]| { @@ -529,10 +540,18 @@ fn build_frozen( .h(capture.size.height) .overflow_hidden(); shell.style().opacity = Some(frame.old_opacity() as f32); - if let Some(blur) = frame.old_blur() { - shell = shell.blur(px(blur as f32)); + let shell = shell.child(content); + // The blur rides a wrapper that does not clip. On the shell itself, + // its `overflow: hidden` would clip the halo at the captured edge. + match frame.old_blur() { + Some(blur) => gpui::div() + .w(capture.size.width) + .h(capture.size.height) + .blur(px(blur as f32)) + .child(shell) + .into_any_element(), + None => shell.into_any_element(), } - shell.child(content).into_any_element() } /// Build a frozen copy for every captured name that has no live element this @@ -599,7 +618,9 @@ struct OldCopy { /// around a transition lays out exactly as it will at rest. Movement happens /// at paint: the child prepaints under an element offset, and the frozen copy /// prepaints at its captured bounds. Both paint inside the group's bounds as -/// a mask, so a slide stays inside the element's own area. +/// a mask, so a slide stays inside the element's own area. When a side blurs, +/// the mask grows by the blur's support, so the halo shows instead of +/// clipping at the edge. struct VtGroup { child: AnyElement, old: Option, @@ -638,7 +659,8 @@ impl Element for VtGroup { cx: &mut App, ) { let offset = self.frame.new_offset(bounds.size); - window.with_content_mask(Some(ContentMask { bounds }), |window| { + let mask = bounds.dilate(self.frame.mask_inflation()); + window.with_content_mask(Some(ContentMask { bounds: mask }), |window| { if let Some(old) = &mut self.old { let element = &mut old.element; let origin = old.origin; @@ -671,7 +693,8 @@ impl Element for VtGroup { window: &mut Window, cx: &mut App, ) { - window.with_content_mask(Some(ContentMask { bounds }), |window| { + let mask = bounds.dilate(self.frame.mask_inflation()); + window.with_content_mask(Some(ContentMask { bounds: mask }), |window| { let paint_old = |old: &mut Option, window: &mut Window, cx: &mut App| { if let Some(old) = old { let element = &mut old.element; diff --git a/zed b/zed index b50a99c3..a1db35c9 160000 --- a/zed +++ b/zed @@ -1 +1 @@ -Subproject commit b50a99c324d20def20d71c2d28f2f8ad0d3923ca +Subproject commit a1db35c962630dea77aabee1ced43dbca546feff