diff --git a/README.md b/README.md index 0a3ead2..2d8b2ea 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ function FadeCard({ visible, children }) { `EaseView` works like a regular `View` — it accepts children, styles, and all standard view props. When values in `animate` change, it smoothly transitions to the new values using native platform animations. +For unmount/exit animations, wrap keyed children in `EasePresence` and provide `exit` on `EaseView`. + ## Why ### Goals @@ -423,6 +425,30 @@ Use `initialAnimate` to set starting values. On mount, the view starts at `initi Without `initialAnimate`, the view renders at the `animate` values immediately with no animation on mount. +### Exit Animations + +Use `EasePresence` + `exit` to animate before unmount. `EasePresence` keeps removed keyed children mounted until `onTransitionEnd({ finished: true })`. + +```tsx +import { EasePresence, EaseView } from 'react-native-ease'; + + + {visible ? ( + + ) : null} + +``` + +Notes: + +- Child elements must have stable keys. +- Avoid `transition.loop` on exiting views (`repeat`/`reverse` never complete). + ### Delay Use `delay` to postpone the start of an animation. This is useful for staggering enter animations across multiple elements. diff --git a/docs/docs/api-reference.mdx b/docs/docs/api-reference.mdx index 9711d6f..e383a9b 100644 --- a/docs/docs/api-reference.mdx +++ b/docs/docs/api-reference.mdx @@ -11,6 +11,7 @@ A `View` that animates property changes using native platform APIs. | ------------------ | ---------------------------- | ----------- | | `animate` | `AnimateProps` | Target values for animated properties | | `initialAnimate` | `AnimateProps` | Starting values for enter animations | +| `exit` | `AnimateProps` | Target values for unmount animations (used by `EasePresence`) | | `transition` | `Transition` | Single config or per-property map | | `onTransitionEnd` | `(event) => void` | Called when all animations complete with `{ finished: boolean }` | | `transformOrigin` | `{ x?: number; y?: number }` | Pivot point for scale/rotation as 0–1 fractions | @@ -21,6 +22,14 @@ A `View` that animates property changes using native platform APIs. | `children` | `ReactNode` | Child elements | | `...rest` | `ViewProps` | All other standard View props | +## `` + +Wrap keyed children to run `exit` animations before unmount. + +| Prop | Type | Description | +| ---------- | ----------- | ----------- | +| `children` | `ReactNode` | Keyed `EaseView` children to retain while their exit animations run | + ## `AnimateProps` | Property | Type | Default | Description | diff --git a/docs/docs/usage.mdx b/docs/docs/usage.mdx index 1d18fbe..db8aeee 100644 --- a/docs/docs/usage.mdx +++ b/docs/docs/usage.mdx @@ -225,6 +225,26 @@ Loop requires `initialAnimate` to define the starting value. Spring animations d /> ``` +## Exit animations + +Use `EasePresence` + `exit` to animate before unmount. + +```tsx + + {visible ? ( + + ) : null} + +``` + +- Child elements must have stable keys. +- Avoid `transition.loop` (`repeat`/`reverse`) on exiting views. + ## Delay ```tsx diff --git a/example/src/demos/ExitDemo.tsx b/example/src/demos/ExitDemo.tsx index 1cb19c1..e94552b 100644 --- a/example/src/demos/ExitDemo.tsx +++ b/example/src/demos/ExitDemo.tsx @@ -1,46 +1,39 @@ import { useState } from 'react'; import { View, StyleSheet } from 'react-native'; -import { EaseView, type TransitionEndEvent } from 'react-native-ease'; +import { EasePresence, EaseView } from 'react-native-ease'; import { Section } from '../components/Section'; import { Button } from '../components/Button'; export function ExitDemo() { const [show, setShow] = useState(true); - const [exiting, setExiting] = useState(false); - - const handleTransitionEnd = ({ finished }: TransitionEndEvent) => { - if (finished) { - setShow(false); - setExiting(false); - } - }; return (
- {show && ( - - )} + + {show ? ( + + ) : null} +
); diff --git a/skills/react-native-ease-refactor/SKILL.md b/skills/react-native-ease-refactor/SKILL.md index 7fc3170..129ee87 100644 --- a/skills/react-native-ease-refactor/SKILL.md +++ b/skills/react-native-ease-refactor/SKILL.md @@ -87,7 +87,7 @@ Use this table to convert Reanimated/Animated patterns to EaseView: | `entering={SlideInLeft}` / `SlideInRight` | `initialAnimate={{ translateX: ±value }}` + `animate={{ translateX: 0 }}` | | `entering={SlideInUp}` / `SlideInDown` | `initialAnimate={{ translateY: ±value }}` + `animate={{ translateY: 0 }}` | | `entering={ZoomIn}` | `initialAnimate={{ scale: 0 }}` + `animate={{ scale: 1 }}` | -| `exiting={FadeOut}` / other exit animations | State-driven exit: boolean state + `onTransitionEnd` to unmount (flag as "requires state changes" in report) | +| `exiting={FadeOut}` / other exit animations | `EasePresence` + `exit={{ ... }}` on `EaseView` with a stable `key` | | `withRepeat(withTiming(...), -1, false)` | `transition={{ type: 'timing', ..., loop: 'repeat' }}` + `initialAnimate` for start value | | `withRepeat(withTiming(...), -1, true)` | `transition={{ type: 'timing', ..., loop: 'reverse' }}` + `initialAnimate` for start value | | `Easing.linear` | `easing: 'linear'` | @@ -219,7 +219,7 @@ Format: **Current:** Brief description of what the animation does and which API it uses **Proposed:** What the EaseView equivalent looks like (include exact transition values with mapped defaults) **Changes:** What will be added/removed/modified -**Note:** (only if applicable) "Requires state changes for exit animation" or other caveats +**Note:** (only if applicable) "Needs `EasePresence` wrapper + stable keys for exit animation" or other caveats ### Not Migratable (will be skipped) diff --git a/src/EasePresence.tsx b/src/EasePresence.tsx new file mode 100644 index 0000000..6fdb314 --- /dev/null +++ b/src/EasePresence.tsx @@ -0,0 +1,200 @@ +import { + Children, + cloneElement, + isValidElement, + type Key, + type ReactElement, + type ReactNode, + useEffect, + useMemo, + useState, +} from 'react'; +import type { AnimateProps, Transition, TransitionEndEvent } from './types'; +import type { EaseViewProps } from './EaseView'; + +type PresenceItem = { + key: string; + element: ReactElement; + isExiting: boolean; + exitVersion: number; +}; + +export type EasePresenceProps = { + children: ReactNode; +}; + +function normalizeKey(key: Key | null): string { + return key == null ? '__ease_presence_no_key__' : String(key); +} + +function hasLoopingTransition(transition?: Transition): boolean { + if (!transition) return false; + + const isLoopingTiming = (value: Transition | undefined) => + value != null && + 'type' in value && + value.type === 'timing' && + (value.loop === 'repeat' || value.loop === 'reverse'); + + if (isLoopingTiming(transition)) { + return true; + } + + if (!('type' in transition)) { + return ( + isLoopingTiming(transition.default) || + isLoopingTiming(transition.transform) || + isLoopingTiming(transition.opacity) || + isLoopingTiming(transition.borderRadius) || + isLoopingTiming(transition.backgroundColor) || + isLoopingTiming(transition.border) || + isLoopingTiming(transition.shadow) + ); + } + + return false; +} + +function toEaseElements(children: ReactNode): ReactElement[] { + return Children.toArray(children).filter( + (child: ReactNode): child is ReactElement => + isValidElement(child), + ); +} + +function warnForMissingKeys(children: ReactNode): void { + if (!__DEV__) return; + + Children.forEach(children, (child: ReactNode) => { + if (isValidElement(child) && child.key == null) { + console.warn( + 'react-native-ease: EasePresence requires stable keys on children to run exit animations.', + ); + } + }); +} + +export function EasePresence({ children }: EasePresenceProps) { + const childElements = useMemo(() => toEaseElements(children), [children]); + + const [items, setItems] = useState(() => + childElements.map((element) => ({ + key: normalizeKey(element.key), + element, + isExiting: false, + exitVersion: 0, + })), + ); + + useEffect(() => { + warnForMissingKeys(children); + + setItems((prev: PresenceItem[]) => { + const incomingByKey = new Map>(); + const incomingOrder: string[] = []; + + for (const element of childElements) { + const key = normalizeKey(element.key); + incomingByKey.set(key, element); + incomingOrder.push(key); + } + + const prevByKey = new Map( + prev.map((item: PresenceItem) => [item.key, item]), + ); + const next: PresenceItem[] = []; + + for (const key of incomingOrder) { + const nextElement = incomingByKey.get(key)!; + const existing = prevByKey.get(key); + if (existing) { + next.push({ + key, + element: nextElement, + isExiting: false, + exitVersion: existing.exitVersion, + }); + } else { + next.push({ + key, + element: nextElement, + isExiting: false, + exitVersion: 0, + }); + } + } + + for (const oldItem of prev) { + if (incomingByKey.has(oldItem.key)) { + continue; + } + + if (oldItem.isExiting) { + next.push(oldItem); + continue; + } + + const exit = oldItem.element.props.exit; + if (!exit) { + continue; + } + + if (hasLoopingTransition(oldItem.element.props.transition)) { + if (__DEV__) { + console.warn( + 'react-native-ease: EasePresence ignores exit animation when transition.loop is repeat/reverse. Remove loop on exiting views.', + ); + } + continue; + } + + next.push({ + ...oldItem, + isExiting: true, + exitVersion: oldItem.exitVersion + 1, + }); + } + + return next; + }); + }, [childElements, children]); + + return ( + <> + {items.map((item: PresenceItem) => { + if (!item.isExiting) { + return item.element; + } + + const exitAnimate: AnimateProps | undefined = item.element.props.exit; + if (!exitAnimate) { + return item.element; + } + + const originalOnTransitionEnd = item.element.props.onTransitionEnd; + const exitingKey = item.key; + const exitingVersion = item.exitVersion; + + return cloneElement(item.element, { + animate: exitAnimate, + onTransitionEnd: (event: TransitionEndEvent) => { + originalOnTransitionEnd?.(event); + if (!event.finished) { + return; + } + setItems((current: PresenceItem[]) => + current.filter( + (candidate: PresenceItem) => + !( + candidate.key === exitingKey && + candidate.isExiting && + candidate.exitVersion === exitingVersion + ), + ), + ); + }, + }); + })} + + ); +} diff --git a/src/EaseView.tsx b/src/EaseView.tsx index aadc03f..4d49f12 100644 --- a/src/EaseView.tsx +++ b/src/EaseView.tsx @@ -199,6 +199,8 @@ export type EaseViewProps = ViewProps & { animate?: AnimateProps; /** Starting values for enter animations. Animates to `animate` on mount. */ initialAnimate?: AnimateProps; + /** Target values for exit animations. Used by `EasePresence` before unmount. */ + exit?: AnimateProps; /** Animation configuration (timing or spring). */ transition?: Transition; /** Called when all animations complete. Reports whether they finished naturally or were interrupted. */ @@ -241,6 +243,7 @@ export type EaseViewProps = ViewProps & { export function EaseView({ animate, initialAnimate, + exit: _exit, transition, onTransitionEnd, useHardwareLayer = false, diff --git a/src/EaseView.web.tsx b/src/EaseView.web.tsx index 20963df..9617e96 100644 --- a/src/EaseView.web.tsx +++ b/src/EaseView.web.tsx @@ -118,6 +118,8 @@ const SPRING_FALLBACK_EASING = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'; export type EaseViewProps = { animate?: AnimateProps; initialAnimate?: AnimateProps; + /** Target values for exit animations. Used by `EasePresence` before unmount. */ + exit?: AnimateProps; transition?: Transition; onTransitionEnd?: (event: TransitionEndEvent) => void; /** No-op on web. */ diff --git a/src/__tests__/EasePresence.test.tsx b/src/__tests__/EasePresence.test.tsx new file mode 100644 index 0000000..a857e9d --- /dev/null +++ b/src/__tests__/EasePresence.test.tsx @@ -0,0 +1,105 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { EasePresence } from '../EasePresence'; +import { EaseView } from '../EaseView'; + +describe('EasePresence', () => { + it('keeps removed child mounted while exit runs, then removes on finish', () => { + const { rerender } = render( + + + , + ); + + rerender({null}); + + const exiting = screen.getByTestId('card'); + expect(exiting).toBeTruthy(); + expect(exiting.props.animateOpacity).toBe(0); + + fireEvent(exiting, 'onTransitionEnd', { nativeEvent: { finished: true } }); + expect(screen.queryByTestId('card')).toBeNull(); + }); + + it('removes immediately when child has no exit prop', () => { + const { rerender } = render( + + + , + ); + + rerender({null}); + expect(screen.queryByTestId('card')).toBeNull(); + }); + + it('forwards onTransitionEnd during exit', () => { + const onTransitionEnd = jest.fn(); + const { rerender } = render( + + + , + ); + + rerender({null}); + + fireEvent(screen.getByTestId('card'), 'onTransitionEnd', { + nativeEvent: { finished: true }, + }); + + expect(onTransitionEnd).toHaveBeenCalledWith({ finished: true }); + }); + + it('does not remove exiting item when transition end is interrupted', () => { + const { rerender } = render( + + + , + ); + + rerender({null}); + + fireEvent(screen.getByTestId('card'), 'onTransitionEnd', { + nativeEvent: { finished: false }, + }); + + expect(screen.queryByTestId('card')).toBeTruthy(); + }); + + it('warns for missing keys', () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + render( + + + , + ); + + expect(spy).toHaveBeenCalledWith( + expect.stringContaining('EasePresence requires stable keys on children'), + ); + + spy.mockRestore(); + }); +}); diff --git a/src/__tests__/EaseView.test.tsx b/src/__tests__/EaseView.test.tsx index 073ec68..613c141 100644 --- a/src/__tests__/EaseView.test.tsx +++ b/src/__tests__/EaseView.test.tsx @@ -124,6 +124,19 @@ describe('EaseView', () => { expect(props.initialAnimateScaleX).toBe(1); expect(props.initialAnimateScaleY).toBe(1); }); + + it('does not forward exit prop to native component', () => { + render( + , + ); + const props = getNativeProps(); + expect(props.exit).toBeUndefined(); + expect(props.animateOpacity).toBe(1); + }); }); describe('transition defaults', () => { diff --git a/src/index.tsx b/src/index.tsx index 612c5da..80802b0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,7 @@ export { EaseView } from './EaseView'; export type { EaseViewProps } from './EaseView'; +export { EasePresence } from './EasePresence'; +export type { EasePresenceProps } from './EasePresence'; export type { AnimateProps, CubicBezier,