Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature add useSwitchTransition hook and several optimization #4

Merged
merged 3 commits into from
Dec 22, 2021
Merged
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
2 changes: 1 addition & 1 deletion src/Transition.tsx → src/Transition/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Stage, useTransition} from './useTransition';
import {Stage, useTransition} from '../useTransition';

type TransitionProps = {
state: boolean;
Expand Down
28 changes: 28 additions & 0 deletions src/helpers/setAnimationFrameTimeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export type Canceller = {
id?: number;
};

export function setAnimationFrameTimeout(
callback: Function,
timeout: number = 0
) {
const startTime = performance.now();
const canceller: Canceller = {};

function call() {
canceller.id = requestAnimationFrame((now) => {
if (now - startTime > timeout) {
callback();
} else {
call();
}
});
}

call();
return canceller;
}

export function clearAnimationFrameTimeout(canceller: Canceller) {
if (canceller.id) cancelAnimationFrame(canceller.id);
}
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export {useTransition} from './useTransition';
export {Transition} from './Transition';
export * from './useTransition';
export * from './useSwitchTransition';
export * from './Transition';
37 changes: 37 additions & 0 deletions src/useSwitchTransition/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {Fragment, useRef, useState} from 'react';
import {Stage} from '../useTransition';
import {ListItem, Mode} from './types';
import {useDefaultMode} from './useDefaultMode';
import {useInOutMode} from './useInOutMode';
import {useOutInMode} from './useOutInMode';

type RenderCallback<S> = (state: S, stage: Stage) => React.ReactNode;

export function useSwitchTransition<S>(state: S, timeout: number, mode?: Mode) {
const keyRef = useRef(0);
const firstDefaultItem: ListItem<S> = {
state,
key: keyRef.current,
stage: 'enter',
};
const [list, setList] = useState([firstDefaultItem]);

// for default mode only
useDefaultMode({state, timeout, keyRef, mode, list, setList});

// for out-in mode only
useOutInMode({state, timeout, keyRef, mode, list, setList});

// for in-out mode only
useInOutMode({state, timeout, keyRef, mode, list, setList});

function transition(renderCallback: RenderCallback<S>) {
return list.map((item) => (
<Fragment key={item.key}>
{renderCallback(item.state, item.stage)}
</Fragment>
));
}

return transition;
}
18 changes: 18 additions & 0 deletions src/useSwitchTransition/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {Stage} from '../useTransition';

export type Mode = 'default' | 'out-in' | 'in-out';

export type ListItem<S> = {
state: S;
key: number;
stage: Stage;
};

export type ModeHookParam<S = any> = {
state: S;
timeout: number;
mode?: Mode;
keyRef: React.MutableRefObject<number>;
list: ListItem<S>[];
setList: React.Dispatch<React.SetStateAction<ListItem<S>[]>>;
};
50 changes: 50 additions & 0 deletions src/useSwitchTransition/useDefaultMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {useEffect} from 'react';
import {ListItem, ModeHookParam} from './types';

export function useDefaultMode<S>({
state,
timeout,
mode,
keyRef,
list,
setList,
}: ModeHookParam<S>) {
useEffect(() => {
// skip unmatched mode 🚫
if (mode !== undefined && mode !== 'default') return;

// skip fist mount and any unchanged effect 🚫
const [lastItem] = list.slice(-1);
if (lastItem.state === state) return;

// 0 update key
const prevKey = keyRef.current; // save prev key
keyRef.current++; // update to last item key
const curKey = keyRef.current; // save cur key (for async gets)

// 1 add new item immediately with stage 'from'
setList((prev) => prev.concat({state, key: curKey, stage: 'from'}));

// 1.1 change this item immediately with stage 'enter'
const isCurItem = (item: ListItem<S>) => item.key === curKey;
setTimeout(() => {
setList((prev) =>
prev.map((item) => (isCurItem(item) ? {...item, stage: 'enter'} : item))
);
});

// 1.2 leave prev item immediately with stage 'leave'
const shouldItemLeave = (item: ListItem<S>) => item.key === prevKey;
setList((prev) =>
prev.map((item) =>
shouldItemLeave(item) ? {...item, stage: 'leave'} : item
)
);

// 2 unmount leaved item after timeout
const shouldMountItem = (item: ListItem<S>) => item.key !== prevKey;
setTimeout(() => {
setList((prev) => prev.filter(shouldMountItem));
}, timeout);
}, [keyRef, list, mode, setList, state, timeout]);
}
77 changes: 77 additions & 0 deletions src/useSwitchTransition/useInOutMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {useEffect, useRef} from 'react';
import {
Canceller,
clearAnimationFrameTimeout,
setAnimationFrameTimeout,
} from '../helpers/setAnimationFrameTimeout';
import {ModeHookParam} from './types';

export function useInOutMode<S>({
state,
timeout,
mode,
keyRef,
list,
setList,
}: ModeHookParam<S>) {
const timerRef = useRef<Canceller>({});
const timerRef2 = useRef<Canceller>({});

useEffect(() => {
// skip unmatched mode 🚫
if (mode !== 'in-out') return;

const [lastItem, secondLastItem] = list.reverse();

// if state has changed && stage is enter (add new item)
if (lastItem.state !== state && lastItem.stage === 'enter') {
// 1 add new item with stage 'from'
keyRef.current++;
setList((prev) =>
prev.slice(-1).concat({state, key: keyRef.current, stage: 'from'})
);
}

// if state hasn't changed && stage is from (enter that new item)
if (lastItem.state === state && lastItem.stage === 'from') {
// 2 set that new item's stage to 'enter' immediately
setAnimationFrameTimeout(() => {
setList([secondLastItem, {...lastItem, stage: 'enter'}]);
});
}

// if state hasn't changed
// && stage is enter
// && second last item exist
// && second last item enter
// (leave second last item)
if (
lastItem.state === state &&
lastItem.stage === 'enter' &&
secondLastItem &&
secondLastItem?.stage === 'enter'
) {
// 3 leave second last item after new item enter animation ends
clearAnimationFrameTimeout(timerRef.current);
timerRef.current = setAnimationFrameTimeout(() => {
setList([{...secondLastItem, stage: 'leave'}, lastItem]);
}, timeout);
}

// if second last item exist
// && second last item is enter
// (unmount second last item)
if (secondLastItem && secondLastItem.stage === 'leave') {
// 4 unmount second last item after it's leave animation ends
clearAnimationFrameTimeout(timerRef2.current);
timerRef2.current = setAnimationFrameTimeout(() => {
setList([lastItem]);
}, timeout);
}

return () => {
clearAnimationFrameTimeout(timerRef.current);
clearAnimationFrameTimeout(timerRef2.current);
};
}, [keyRef, list, mode, setList, state, timeout]);
}
51 changes: 51 additions & 0 deletions src/useSwitchTransition/useOutInMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {useEffect, useRef} from 'react';
import {
Canceller,
clearAnimationFrameTimeout,
setAnimationFrameTimeout,
} from '../helpers/setAnimationFrameTimeout';
import {ModeHookParam} from './types';

export function useOutInMode<S>({
state,
timeout,
mode,
keyRef,
list,
setList,
}: ModeHookParam<S>) {
const timerRef = useRef<Canceller>({});

useEffect(() => {
// skip unmatched mode 🚫
if (mode !== 'out-in') return;

const [lastItem] = list.slice(-1);

// if state has changed && stage is enter (trigger prev last item to leave)
if (lastItem.state !== state && lastItem.stage === 'enter') {
// 1 leave prev last item
setList([{...lastItem, stage: 'leave'}]);
}

// if state has changed && stage is leave (add new item after prev last item leave ani ends)
if (lastItem.state !== state && lastItem.stage === 'leave') {
// 2 add new item after prev last item leave animation ends
clearAnimationFrameTimeout(timerRef.current);
timerRef.current = setAnimationFrameTimeout(() => {
keyRef.current++;
setList([{state, key: keyRef.current, stage: 'from'}]);
}, timeout);
}

// if state hasn't change && stage is from
if (lastItem.state === state && lastItem.stage === 'from') {
// 3 change that new item's stage to 'enter' immediately
setAnimationFrameTimeout(() => {
setList((prev) => [{...prev[0], stage: 'enter'}]);
});
}

return () => clearAnimationFrameTimeout(timerRef.current);
}, [keyRef, list, mode, setList, state, timeout]);
}
15 changes: 11 additions & 4 deletions src/useTransition.ts → src/useTransition/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import {useEffect, useRef, useState} from 'react';
import {
Canceller,
clearAnimationFrameTimeout,
setAnimationFrameTimeout,
} from '../helpers/setAnimationFrameTimeout';

export type Stage = 'from' | 'enter' | 'leave';

Expand All @@ -7,27 +12,29 @@ export function useTransition(state: boolean, timeout: number) {
const [stage, setStage] = useState<Stage>(state ? 'enter' : 'from');

// the timer for should mount
const timer = useRef<number>();
const timer = useRef<Canceller>({});
const [shouldMount, setShouldMount] = useState(state);

useEffect(
function handleStateChange() {
clearTimeout(timer.current);
clearAnimationFrameTimeout(timer.current);

// when true - trans from to enter
// when false - trans enter to leave, unmount after timeout
if (state === true) {
setStage('from');
setShouldMount(true);
setTimeout(() => {
setAnimationFrameTimeout(() => {
setStage('enter');
});
} else {
setStage('leave');
timer.current = setTimeout(() => {
timer.current = setAnimationFrameTimeout(() => {
setShouldMount(false);
}, timeout);
}

return () => clearAnimationFrameTimeout(timer.current);
},
[state, timeout]
);
Expand Down