Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';

<EasePresence>
{visible ? (
<EaseView
key="toast"
animate={{ opacity: 1, translateY: 0 }}
exit={{ opacity: 0, translateY: 12 }}
transition={{ type: 'timing', duration: 220, easing: 'easeOut' }}
/>
) : null}
</EasePresence>
```

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.
Expand Down
9 changes: 9 additions & 0 deletions docs/docs/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |

## `<EasePresence>`

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 |
Expand Down
20 changes: 20 additions & 0 deletions docs/docs/usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<EasePresence>
{visible ? (
<EaseView
key="toast"
animate={{ opacity: 1, translateY: 0 }}
exit={{ opacity: 0, translateY: 12 }}
transition={{ type: 'timing', duration: 220, easing: 'easeOut' }}
/>
) : null}
</EasePresence>
```

- Child elements must have stable keys.
- Avoid `transition.loop` (`repeat`/`reverse`) on exiting views.

## Delay

```tsx
Expand Down
49 changes: 21 additions & 28 deletions example/src/demos/ExitDemo.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Section title="Exit Animation">
<View style={styles.exitContainer}>
{show && (
<EaseView
animate={{
opacity: exiting ? 0 : 1,
scale: exiting ? 0.8 : 1,
translateY: exiting ? 20 : 0,
}}
transition={{ type: 'timing', duration: 300, easing: 'easeIn' }}
onTransitionEnd={exiting ? handleTransitionEnd : undefined}
style={styles.box}
/>
)}
<EasePresence>
{show ? (
<EaseView
key="exit-box"
animate={{
opacity: 1,
scale: 1,
translateY: 0,
}}
exit={{
opacity: 0,
scale: 0.8,
translateY: 20,
}}
transition={{ type: 'timing', duration: 300, easing: 'easeIn' }}
style={styles.box}
/>
) : null}
</EasePresence>
</View>
<Button
label={show ? 'Remove' : 'Show Again'}
onPress={() => {
if (show) {
setExiting(true);
} else {
setShow(true);
}
}}
onPress={() => setShow((value: boolean) => !value)}
/>
</Section>
);
Expand Down
4 changes: 2 additions & 2 deletions skills/react-native-ease-refactor/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'` |
Expand Down Expand Up @@ -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)

Expand Down
200 changes: 200 additions & 0 deletions src/EasePresence.tsx
Original file line number Diff line number Diff line change
@@ -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<EaseViewProps>;
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<EaseViewProps>[] {
return Children.toArray(children).filter(
(child: ReactNode): child is ReactElement<EaseViewProps> =>
isValidElement<EaseViewProps>(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<PresenceItem[]>(() =>
childElements.map((element) => ({
key: normalizeKey(element.key),
element,
isExiting: false,
exitVersion: 0,
})),
);

useEffect(() => {
warnForMissingKeys(children);

setItems((prev: PresenceItem[]) => {
const incomingByKey = new Map<string, ReactElement<EaseViewProps>>();
const incomingOrder: string[] = [];

for (const element of childElements) {
const key = normalizeKey(element.key);
incomingByKey.set(key, element);
incomingOrder.push(key);
}

const prevByKey = new Map<string, PresenceItem>(
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
),
),
);
},
});
})}
</>
);
}
3 changes: 3 additions & 0 deletions src/EaseView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -241,6 +243,7 @@ export type EaseViewProps = ViewProps & {
export function EaseView({
animate,
initialAnimate,
exit: _exit,
transition,
onTransitionEnd,
useHardwareLayer = false,
Expand Down
Loading