diff --git a/dev/html/public/examples/avatar-profile.html b/dev/html/public/examples/avatar-profile.html index d7a2ac3bfd..5a3d1608c2 100644 --- a/dev/html/public/examples/avatar-profile.html +++ b/dev/html/public/examples/avatar-profile.html @@ -199,7 +199,9 @@

Maya Powell

animateView(() => { buildProfile() avatar.style.visibility = "hidden" - }, opts).add(avatar, ".profile") + }, opts) + .add(avatar, ".profile") + .crop(true) } const close = () => { @@ -210,7 +212,9 @@

Maya Powell

document.querySelector(".profile")?.remove() document.querySelector(".profile-backdrop")?.remove() avatar.style.visibility = "visible" - }, opts).add(".profile", avatar) + }, opts) + .add(".profile", avatar) + .crop(true) } avatar.addEventListener("click", openProfile) diff --git a/dev/html/public/playwright/view/view-crop-timing.html b/dev/html/public/playwright/view/view-crop-timing.html new file mode 100644 index 0000000000..69724c8433 --- /dev/null +++ b/dev/html/public/playwright/view/view-crop-timing.html @@ -0,0 +1,87 @@ + + + + + + +
+ + + + + diff --git a/packages/motion-dom/src/animation/waapi/utils/px-values.ts b/packages/motion-dom/src/animation/waapi/utils/px-values.ts index b41cc2f065..c594764908 100644 --- a/packages/motion-dom/src/animation/waapi/utils/px-values.ts +++ b/packages/motion-dom/src/animation/waapi/utils/px-values.ts @@ -1,3 +1,5 @@ +import { cornerRadiusProps } from "../../../utils/border-radius" + export const pxValues = new Set([ // Border props "borderWidth", @@ -6,10 +8,7 @@ export const pxValues = new Set([ "borderBottomWidth", "borderLeftWidth", "borderRadius", - "borderTopLeftRadius", - "borderTopRightRadius", - "borderBottomRightRadius", - "borderBottomLeftRadius", + ...cornerRadiusProps, // Positioning props "width", "maxWidth", diff --git a/packages/motion-dom/src/projection/animation/mix-values.ts b/packages/motion-dom/src/projection/animation/mix-values.ts index eeadc29536..11f550d059 100644 --- a/packages/motion-dom/src/projection/animation/mix-values.ts +++ b/packages/motion-dom/src/projection/animation/mix-values.ts @@ -8,14 +8,9 @@ import { noop, } from "motion-utils" import type { ResolvedValues } from "../../node/types" +import { cornerRadiusProps } from "../../utils/border-radius" -const borderLabels = [ - "borderTopLeftRadius", - "borderTopRightRadius", - "borderBottomLeftRadius", - "borderBottomRightRadius", -] -const numBorders = borderLabels.length +const numBorders = cornerRadiusProps.length const asNumber = (value: AnyResolvedKeyframe) => typeof value === "string" ? parseFloat(value) : value @@ -54,7 +49,7 @@ export function mixValues( * Mix border radius */ for (let i = 0; i < numBorders; i++) { - const borderLabel = borderLabels[i] + const borderLabel = cornerRadiusProps[i] let followRadius = getRadius(follow, borderLabel) let leadRadius = getRadius(lead, borderLabel) diff --git a/packages/motion-dom/src/projection/styles/scale-correction.ts b/packages/motion-dom/src/projection/styles/scale-correction.ts index 030445024b..812554a30c 100644 --- a/packages/motion-dom/src/projection/styles/scale-correction.ts +++ b/packages/motion-dom/src/projection/styles/scale-correction.ts @@ -1,4 +1,5 @@ import { isCSSVariableName } from "../../animation/utils/is-css-variable" +import { cornerRadiusProps } from "../../utils/border-radius" import { correctBorderRadius } from "./scale-border-radius" import { correctBoxShadow } from "./scale-box-shadow" import type { ScaleCorrectorMap } from "./types" @@ -6,12 +7,7 @@ import type { ScaleCorrectorMap } from "./types" export const scaleCorrectors: ScaleCorrectorMap = { borderRadius: { ...correctBorderRadius, - applyTo: [ - "borderTopLeftRadius", - "borderTopRightRadius", - "borderBottomLeftRadius", - "borderBottomRightRadius", - ], + applyTo: [...cornerRadiusProps], }, borderTopLeftRadius: correctBorderRadius, borderTopRightRadius: correctBorderRadius, diff --git a/packages/motion-dom/src/utils/border-radius.ts b/packages/motion-dom/src/utils/border-radius.ts new file mode 100644 index 0000000000..b4713ffa1e --- /dev/null +++ b/packages/motion-dom/src/utils/border-radius.ts @@ -0,0 +1,12 @@ +/** + * The four corner-radius longhands. Shared so the projection mixer, scale + * corrector, WAAPI px-value set and view-transition crop pass don't each carry + * their own copy. Order is irrelevant - every consumer mixes/corrects/animates + * each corner independently. + */ +export const cornerRadiusProps = [ + "borderTopLeftRadius", + "borderTopRightRadius", + "borderBottomRightRadius", + "borderBottomLeftRadius", +] as const diff --git a/packages/motion-dom/src/view/start.ts b/packages/motion-dom/src/view/start.ts index 983538ba51..b00abeb8bb 100644 --- a/packages/motion-dom/src/view/start.ts +++ b/packages/motion-dom/src/view/start.ts @@ -6,6 +6,7 @@ import { AnimationPlaybackControls, DOMKeyframesDefinition } from "../animation/ import { getValueTransition } from "../animation/utils/get-value-transition" import { mapEasingToNativeEasing } from "../animation/waapi/easing/map-easing" import { applyGeneratorOptions } from "../animation/waapi/utils/apply-generator" +import { cornerRadiusProps } from "../utils/border-radius" import { ElementOrSelector, resolveElements } from "../utils/resolve-elements" import type { ViewTransitionBuilder } from "./index" import { ViewTransitionTarget, ViewTransitionTargetDefinition } from "./types" @@ -21,6 +22,16 @@ import { hasTarget } from "./utils/has-target" const definitionNames = ["layout", "enter", "exit", "new", "old"] as const +type CornerRadii = Record<(typeof cornerRadiusProps)[number], string> + +/** + * Whether a computed border-radius is square (every component zero). Splitting + * on whitespace handles two-value/elliptical radii like "0px 20px" - a leading + * `parseFloat` alone would misread the non-zero vertical radius as square. + */ +const isSquareRadius = (radius: string) => + radius.split(" ").every((value) => parseFloat(value) === 0) + /** * The `ViewTransitionTarget` buckets driving each generated layer type, in * priority order - the inverse of `chooseLayerType`. The new view is driven by @@ -284,24 +295,63 @@ export function startViewAnimation( } /** - * Each layer's box size per snapshot, so `finalizeCrop` can tell whether a - * morph's aspect ratio changed (the only case worth cropping). + * Resolve a layer's group (`layout`) timing to plain WAAPI values: native + * ms `delay`/`duration` and a baked `ease`. The single source of group + * timing, shared by the generated-group retiming and the crop corner-radius + * pass so the rounded clip animates on exactly the box's timing. It returns + * no generator `type` (the WAAPI-only `NativeAnimation` rejects a string + * type) nor `repeat`/`times` (which the group's `updateTiming` ignores), so + * none of them can leak into the radius animation and desync it. */ - const cropBox = new Map< + const resolveGroupTiming = (name: string) => { + const [index, total] = staggerPosition(name, "group") + const transition = resolveLayerTransition( + layerTargets.get(name), + "group", + "layout", + index === -1 ? 0 : index, + total + ) + transition.duration &&= secondsToMilliseconds(transition.duration) + const { delay = 0, duration, ease } = applyGeneratorOptions(transition) + return { delay: secondsToMilliseconds(delay), duration, ease } + } + + /** + * Each layer's measured box + corner radii per snapshot. The box lets + * `finalizeCrop` tell whether a morph's aspect ratio changed (the only case + * worth cropping); the radii let a cropped morph's group clip animate each + * corner from the old element's radius to the new element's, keeping it + * rounded where `overflow: clip` would otherwise square the corners. + * + * We never flatten the source for capture (a snapshot is a paint of the live + * DOM, so squaring an element just for its capture would flash one real + * square frame). For an aspect-changing morph `object-fit: cover` crops each + * snapshot's own baked corners off-screen mid-morph, so the animated clip is + * the only visible corner; a near-same-aspect forced crop (`.crop(true)`) + * can't hide the outgoing snapshot's silhouette, but the endpoints coincide. + */ + const cropMeasurements = new Map< string, { - old?: { width: number; height: number } - new?: { width: number; height: number } + old?: { width: number; height: number; radii: CornerRadii } + new?: { width: number; height: number; radii: CornerRadii } } >() const measureLayers = (phase: "old" | "new") => nameRegistry.forEach((name, element) => { - const rect = (element as HTMLElement).getBoundingClientRect?.() + const el = element as HTMLElement + const rect = el.getBoundingClientRect?.() if (rect && rect.height) { - const entry = cropBox.get(name) ?? {} - entry[phase] = { width: rect.width, height: rect.height } - cropBox.set(name, entry) + const style = getComputedStyle(el) + const radii = {} as CornerRadii + for (const corner of cornerRadiusProps) { + radii[corner] = style[corner] + } + const entry = cropMeasurements.get(name) ?? {} + entry[phase] = { width: rect.width, height: rect.height, radii } + cropMeasurements.set(name, entry) } }) @@ -332,7 +382,7 @@ export function startViewAnimation( * never "changed". */ const aspectChanged = (name: string) => { - const box = cropBox.get(name) + const box = cropMeasurements.get(name) if (!box?.old || !box?.new || !box.old.height || !box.new.height) { return false } @@ -703,64 +753,125 @@ export function startViewAnimation( (name.type === "old" || name.type === "new") && !!stagger?.old && !!stagger?.new - const timingType = - name.type.startsWith("group") || isMorphCrossfade - ? "group" - : name.type - - const [index, total] = staggerPosition( - name.layer, - timingType - ) - const transitionName = - timingType === "group" ? "layout" : "" - let animationTransition = resolveLayerTransition( - targetDefinition, - timingType, - transitionName, - index === -1 ? 0 : index, - total - ) - /** - * The crossfade should resolve at the spring's *perceptual* - * (visual) duration - the geometry can keep bouncing, but the - * opacity shouldn't drag through the settle. So capture - * `visualDuration` before `applyGeneratorOptions` replaces it - * with the full overshoot duration, and use it for the fade. - */ - const visualDuration = animationTransition.visualDuration + let timing: { + delay: number + duration?: number + easing: string + } + if (name.type.startsWith("group")) { + // group + group-children follow the resolved group + // timing - the single source shared with the crop + // corner-radius pass below. + const { delay, duration, ease } = resolveGroupTiming( + name.layer + ) + timing = { + delay, + duration, + easing: mapEasingToNativeEasing( + ease, + duration! + ) as string, + } + } else { + const timingType = isMorphCrossfade ? "group" : name.type + const [index, total] = staggerPosition( + name.layer, + timingType + ) + const transitionName = + timingType === "group" ? "layout" : "" + let animationTransition = resolveLayerTransition( + targetDefinition, + timingType, + transitionName, + index === -1 ? 0 : index, + total + ) - animationTransition.duration &&= secondsToMilliseconds( - animationTransition.duration - ) + /** + * The crossfade should resolve at the spring's + * *perceptual* (visual) duration - the geometry can keep + * bouncing, but the opacity shouldn't drag through the + * settle. So capture `visualDuration` before + * `applyGeneratorOptions` replaces it with the full + * overshoot duration, and use it for the fade. + */ + const visualDuration = animationTransition.visualDuration - animationTransition = - applyGeneratorOptions(animationTransition) - - const duration = - isMorphCrossfade && visualDuration !== undefined - ? secondsToMilliseconds(visualDuration) - : animationTransition.duration - - const easing = isMorphCrossfade - ? "linear" - : (mapEasingToNativeEasing( - animationTransition.ease, - animationTransition.duration! - ) as string) - - effect.updateTiming({ - delay: secondsToMilliseconds( - animationTransition.delay ?? 0 - ), - duration, - easing, - }) + animationTransition.duration &&= secondsToMilliseconds( + animationTransition.duration + ) + + animationTransition = + applyGeneratorOptions(animationTransition) + + timing = { + delay: secondsToMilliseconds( + animationTransition.delay ?? 0 + ), + duration: + isMorphCrossfade && visualDuration !== undefined + ? secondsToMilliseconds(visualDuration) + : animationTransition.duration, + easing: isMorphCrossfade + ? "linear" + : (mapEasingToNativeEasing( + animationTransition.ease, + animationTransition.duration! + ) as string), + } + } + + effect.updateTiming(timing) animations.push(new NativeAnimationWrapper(animation)) } + /** + * Round each cropped layer's clip. Its `::view-transition-group` + * has `overflow: clip`, which would otherwise square the corners + * mid-morph; animate each corner from the old element's radius to + * the new element's so the crop stays rounded. Timed as the group + * (`layout`) so the radius tracks the morphing box. + */ + cropMeasurements.forEach((entry, name) => { + if (!croppedNames.has(name)) return + + // Reuse the group's resolved timing - native ms delay/ + // duration + a baked ease, with no generator `type` or + // repeat/times to leak into (or throw inside) NativeAnimation. + const { delay, duration, ease } = resolveGroupTiming(name) + + for (const corner of cornerRadiusProps) { + // `||` (not `??`) so an empty measurement falls back to + // the other snapshot rather than an invalid keyframe. + const from = + entry.old?.radii[corner] || + entry.new?.radii[corner] || + "0px" + const to = + entry.new?.radii[corner] || + entry.old?.radii[corner] || + "0px" + // Nothing to round if the corner is square at both ends. + if (isSquareRadius(from) && isSquareRadius(to)) continue + + animations.push( + new NativeAnimation({ + element: document.documentElement, + name: corner, + pseudoElement: `::view-transition-group(${name})`, + keyframes: [from, to], + delay, + duration, + ease, + }) + ) + } + }) + resolve(new GroupAnimation(animations)) }) .catch(() => diff --git a/tests/view/view-targets.spec.ts b/tests/view/view-targets.spec.ts index 3743a99adf..dfc0d8f2d0 100644 --- a/tests/view/view-targets.spec.ts +++ b/tests/view/view-targets.spec.ts @@ -148,6 +148,9 @@ test.describe("animateView() target resolution", () => { expect(result.css).toMatch( /::view-transition-old\(box\)[^{]*\{[^}]*object-fit:\s*cover/ ) + // The cropped morph rounds its square clip by animating the group's + // border-radius (8px -> 28px) - without it the corners square mid-morph. + expect(result.radiusAnimated).toBe(true) }) test(".crop(false) opts out of the default crop", async ({ page }) => { @@ -157,6 +160,25 @@ test.describe("animateView() target resolution", () => { expect(result.error).toBeNull() expect(result.css).not.toMatch(/object-fit/) + // No crop -> no overflow: clip -> no group-radius animation needed. + expect(result.radiusAnimated).toBe(false) + }) + + test("repeat/type don't leak into the cropped-clip radius animation", async ({ + page, + }) => { + await page.goto("view/view-crop-timing.html") + const result = await readResult(page) + test.skip(!result.supported, "No startViewTransition support") + + // A string `type` must not throw inside the WAAPI-only NativeAnimation + // and drop every animation. + expect(result.error).toBeNull() + // The radius reuses the group's resolved timing, which carries no + // `repeat`, so it runs once - in sync with the box geometry (also once). + // A leaking `repeat: 1` would make the radius run twice and desync. + expect(result.radiusIterations).toBe(1) + expect(result.groupIterations).toBe(1) }) test(".exit({ opacity: 0 }) animates from an inferred 1, not instantly", async ({