Skip to content
Closed
31 changes: 31 additions & 0 deletions .changeset/view-transitions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@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`, `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. 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: the frozen copy takes no input.
48 changes: 47 additions & 1 deletion examples/demo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,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 { Selectors } from "./demo/selectors"
import { Variables } from "./demo/variables"
Expand Down Expand Up @@ -51,6 +52,7 @@ const PANELS = [
["selectors", <Selectors />],
["motion", <Motion />],
["scrollbars", <Scrollbars />],
["navigation", <Navigation />],
] as const

describeNative("demo panels", () => {
Expand Down Expand Up @@ -322,6 +324,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(
<div
style={{
...BASE,
...PALETTES.midnight,
width: "100%",
height: "100%",
padding: 16,
backgroundColor: "var(--color-bg)",
}}
>
<Navigation />
</div>
)
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
Expand All @@ -331,7 +377,7 @@ describeNative("the whole application", () => {
test.render(<App />)
expect(test.renderer.getPaintedText()).toContain("GPUIX")

for (const title of ["Lengths", "Variables", "Inheritance", "className", "Selectors", "Motion", "Scrollbars", "Performance", "Colours"]) {
for (const title of ["Lengths", "Variables", "Inheritance", "className", "Selectors", "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)
Expand Down
2 changes: 2 additions & 0 deletions examples/demo/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { Selectors } from "./selectors.js"
Expand Down Expand Up @@ -80,6 +81,7 @@ const SECTIONS = [
{ id: "selectors", title: "Selectors", render: () => <Selectors /> },
{ id: "motion", title: "Motion", render: () => <Motion /> },
{ id: "scrollbars", title: "Scrollbars", render: () => <Scrollbars /> },
{ id: "navigation", title: "Navigation", render: () => <Navigation /> },
] as const

type SectionId = (typeof SECTIONS)[number]["id"] | "perf"
Expand Down
220 changes: 220 additions & 0 deletions examples/demo/navigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/// View transitions, shown as the push and pop of the iOS Settings app.
///
/// 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 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"
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
/// 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.45,
ease: SPRING,
groups: {
screen: {
old: { translateX: ["0%", "-30%"] },
new: { translateX: ["100%", "0%"] },
},
"nav-back": { new: { opacity: [0, 1], blur: [6, 0] } },
"nav-title": {
old: { opacity: [1, 0], blur: [0, 4] },
new: { opacity: [0, 1], blur: [4, 0] },
},
},
}

const POP: ViewTransitionOptions = {
duration: 0.45,
ease: SPRING,
groups: {
screen: {
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], blur: [0, 4] },
new: { opacity: [0, 1], blur: [4, 0] },
},
},
}

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
detail?: string
onClick?: () => void
}) {
return (
<div
testId={`nav-row-${label}`}
className={["row items-center px-4 py-3", onClick ? "pointer hover:bg-raised" : ""].join(" ")}
style={{
flexShrink: 0,
justifyContent: "space-between",
borderBottomWidth: 1,
borderColor: "var(--color-line)",
}}
onClick={onClick}
>
<text className="text-sm text-fg">{label}</text>
<text className="text-sm text-faint">{detail ?? (onClick ? ">" : "")}</text>
</div>
)
}

/// 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 (
<>
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: HEADER_HEIGHT + BLUR_TAIL,
backdropFilter: "blur(16px)",
maskImage: "linear-gradient(to bottom, black 50%, ease-in-out, transparent)",
pointerEvents: "none",
}}
/>
<div
className="row items-center"
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: HEADER_HEIGHT,
justifyContent: "center",
pointerEvents: "none",
}}
>
<div key={screen} testId={`nav-title-${screen}`} style={{ viewTransitionName: "nav-title" }}>
<text className="text-sm font-semibold text-fg">{title}</text>
</div>
</div>
<div
className="row items-center px-2"
style={{ position: "absolute", top: 0, left: 0, height: HEADER_HEIGHT }}
>
{screen === "general" ? (
<div
testId="nav-back"
className="row pointer select-none px-1"
style={{ viewTransitionName: "nav-back" }}
onClick={onBack}
>
<text className="text-sm" style={{ color: "var(--color-brand)" }}>{"< Settings"}</text>
</div>
) : null}
</div>
</>
)
}

/// 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. The top padding puts
/// the first row under the header, and the rows scroll under it.
function Screen({ children }: { children: React.ReactNode }) {
return (
<div
className="col w-full h-full"
style={{
viewTransitionName: "screen",
backgroundColor: "var(--color-panel)",
overflowY: "scroll",
paddingTop: HEADER_HEIGHT,
}}
>
{children}
</div>
)
}

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 (
<div
className="col rounded border"
style={{
width: 320,
height: 440,
flexShrink: 0,
overflow: "hidden",
position: "relative",
}}
>
{screen === "root" ? (
<Screen key="root">
{ROOT_ROWS.map((label) => (
<NavRow
key={label}
label={label}
onClick={label === "General" ? () => go("general", PUSH) : undefined}
/>
))}
</Screen>
) : (
<Screen key="general">
{GENERAL_ROWS.map((label) => (
<NavRow key={label} label={label} detail="" />
))}
</Screen>
)}
<Header screen={screen} onBack={() => go("root", POP)} />
</div>
)
}

export function Navigation() {
const { renderer } = useGpuix()
return (
<Panel
title="View transitions"
note="Click General to push its screen. The screens slide as a pair under a header that never unmounts. The back button enters through a blur and opacity pair and leaves the same way, and the title morphs between Settings and General. The strip under the header is a backdrop blur with an eased mask, so the rows blur progressively as they scroll under it."
>
<Phone renderer={renderer ?? null} />
</Panel>
)
}
21 changes: 21 additions & 0 deletions packages/native/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -337,6 +348,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. For a `<virtual-list>` the
Expand Down
Loading
Loading