Skip to content

Commit

Permalink
Merge pull request #4 from iamyoki/feature
Browse files Browse the repository at this point in the history
Feature add useSwitchTransition hook and several optimization
  • Loading branch information
iamyoki authored Dec 22, 2021
2 parents f1af576 + c59c662 commit 56847d9
Show file tree
Hide file tree
Showing 9 changed files with 276 additions and 7 deletions.
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

0 comments on commit 56847d9

Please sign in to comment.