Skip to content

Commit

Permalink
✨ feat: added delay option to animation loop
Browse files Browse the repository at this point in the history
  • Loading branch information
e1en0r committed Oct 10, 2024
1 parent 54a2fbc commit 0497de7
Show file tree
Hide file tree
Showing 8 changed files with 147 additions and 31 deletions.
11 changes: 4 additions & 7 deletions src/components/LineLoader/LineLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { cx } from '@emotion/css';
import React, { useCallback, useRef } from 'react';
import { ThemeProps } from '../../types';
import { useThemeId } from '../../context/Theme';
import { useAnimationLoop } from '../../hooks/useAnimationLoop';
import { useAnimationLoop, UseAnimationLoopProps } from '../../hooks/useAnimationLoop';
import { useTranslations } from '../../hooks/useTranslations';
import styles from './styles/LineLoader.module.css';

Expand All @@ -15,14 +15,11 @@ export const lineLoaderTranslations: LineLoaderTranslations = {
};

export type LineLoaderProps = React.HTMLAttributes<HTMLDivElement> &
ThemeProps & {
ThemeProps &
Omit<UseAnimationLoopProps, 'animate' | 'delay' | 'manual'> & {
className?: string;
duration?: number;
/** Use a fixed position for the line loader */
fixed?: boolean;
loops?: number;
onFinish?: () => void;
onLoop?: (args: { loop: number }) => void;
percent?: number;
position?: 'top' | 'bottom';
style?: React.CSSProperties;
translations?: Partial<LineLoaderTranslations>;
Expand Down
4 changes: 3 additions & 1 deletion src/components/Panels/SidePanel/SidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type SidePanelProps = Pick<
UsePanelCollapserProps,
'onCloseFinish' | 'onCloseStart' | 'onOpenFinish' | 'onOpenStart' | 'open'
> &
Partial<Pick<UsePanelCollapserProps, 'easing' | 'unit' | 'transition'>> &
Partial<Pick<UsePanelCollapserProps, 'delay' | 'easing' | 'unit' | 'transition'>> &
React.HTMLAttributes<HTMLDivElement> & {
children: React.ReactChild | React.ReactFragment;
className?: string;
Expand Down Expand Up @@ -40,6 +40,7 @@ export const SidePanel = React.forwardRef<HTMLDivElement, SidePanelProps>(
{
children,
className,
delay,
duration = 300,
easing,
fixed = false,
Expand All @@ -60,6 +61,7 @@ export const SidePanel = React.forwardRef<HTMLDivElement, SidePanelProps>(
const ref = useRef<HTMLDivElement>(null);

usePanelCollapser({
delay,
duration,
easing: easing || easeInOutCubic,
position,
Expand Down
5 changes: 5 additions & 0 deletions src/components/Panels/SidePanel/stories/SidePanel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export default {
},
},

delay: {
table: {
category: 'Animation',
},
},
duration: {
table: {
category: 'Animation',
Expand Down
4 changes: 3 additions & 1 deletion src/components/Panels/StackPanel/StackPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type StackPanelProps = Pick<
UsePanelCollapserProps,
'onCloseFinish' | 'onCloseStart' | 'onOpenFinish' | 'onOpenStart' | 'open'
> &
Partial<Pick<UsePanelCollapserProps, 'easing' | 'unit' | 'transition'>> &
Partial<Pick<UsePanelCollapserProps, 'delay' | 'easing' | 'unit' | 'transition'>> &
React.HTMLAttributes<HTMLDivElement> & {
children: React.ReactChild | React.ReactFragment;
className?: string;
Expand Down Expand Up @@ -40,6 +40,7 @@ export const StackPanel = React.forwardRef<HTMLDivElement, StackPanelProps>(
{
children,
className,
delay,
duration = 300,
easing,
fixed = false,
Expand All @@ -60,6 +61,7 @@ export const StackPanel = React.forwardRef<HTMLDivElement, StackPanelProps>(
const ref = useRef<HTMLDivElement>(null);

usePanelCollapser({
delay,
duration,
easing: easing || easeInOutCubic,
height,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export default {
},
},

delay: {
table: {
category: 'Animation',
},
},
duration: {
table: {
category: 'Animation',
Expand Down
90 changes: 90 additions & 0 deletions src/components/Panels/stories/Panels.stories.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,51 @@ export const subPanelStyle = {
</Story>
</Canvas>

### Side panel: Delayed

<Canvas>
<Story name="Side panel: Delayed">
<PanelsWrapper isOpen>
{({ isOpen, panelState, setPanelState, toggleOpen }) => (
<React.Fragment>
<PanelContainer orientation="vertical" style={panelContainerStyle}>
<IconButton
color="white"
onClick={() => toggleOpen()}
shape="circle"
size="small"
style={{ position: 'absolute', bottom: 8, left: 8, zIndex: 10 }}
type="button"
weight="solid"
>
<RightPanelIcon size={14} />
</IconButton>
<MainPanel style={mainPanelStyle}>
Main panel
<br />
Side panel state: {panelState}
</MainPanel>
<SidePanel
delay={500}
onCloseFinish={() => setPanelState('closed')}
onCloseStart={() => setPanelState('closing')}
onOpenFinish={() => setPanelState('open')}
onOpenStart={() => setPanelState('opening')}
open={isOpen}
position="right"
style={subPanelStyle}
transition="squashable"
width={360}
>
Side panel
</SidePanel>
</PanelContainer>
</React.Fragment>
)}
</PanelsWrapper>
</Story>
</Canvas>

### Side panel: Fixed

<Canvas>
Expand Down Expand Up @@ -356,6 +401,51 @@ export const subPanelStyle = {
</Story>
</Canvas>

### Stack panel: Delayed

<Canvas>
<Story name="Stack panel: Delayed">
<PanelsWrapper isOpen>
{({ isOpen, panelState, setPanelState, toggleOpen }) => (
<React.Fragment>
<PanelContainer reverse orientation="horizontal" style={panelContainerStyle}>
<IconButton
color="white"
onClick={() => toggleOpen()}
shape="circle"
size="small"
style={{ position: 'absolute', bottom: 8, left: 8, zIndex: 10 }}
type="button"
weight="solid"
>
<RightPanelIcon size={14} />
</IconButton>
<MainPanel style={mainPanelStyle}>
Main panel
<br />
Stack panel state: {panelState}
</MainPanel>
<StackPanel
delay={500}
height={40}
onCloseFinish={() => setPanelState('closed')}
onCloseStart={() => setPanelState('closing')}
onOpenFinish={() => setPanelState('open')}
onOpenStart={() => setPanelState('opening')}
open={isOpen}
position="top"
style={subPanelStyle}
transition="squashable"
>
Stack panel
</StackPanel>
</PanelContainer>
</React.Fragment>
)}
</PanelsWrapper>
</Story>
</Canvas>

### Stack panel: Fixed

<Canvas>
Expand Down
55 changes: 33 additions & 22 deletions src/hooks/useAnimationLoop.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { produce } from 'immer';
import { useCallback, useEffect, useRef, useReducer } from 'react';
import { useSafeTimeout } from './useSafeTimeout';

enum ACTIONS {
SET_CUSTOM = 'SET_CUSTOM',
Expand Down Expand Up @@ -27,6 +28,8 @@ type Action =
export type UseAnimationLoopProps = {
/** The callback that receives the updated animation percentage */
animate: (percent: number, options: State['options']) => void;
/** The delay before animation starts */
delay?: number;
/** The duration of the animation before it's considered complete, or falsy for infinite */
duration?: number;
/** The number of loops to run the animation for, or falsy for infinite */
Expand All @@ -46,6 +49,11 @@ export type UseAnimationLoopResponse = {

const initialState = {} as State;

/**
* The runtime starts at -1 because technically it's possible
* for the first set duration action to set the runtime to 0
* which would stop the loop before it started.
*/
const createReducer =
() =>
(state: State, action: Action): State => {
Expand All @@ -55,7 +63,7 @@ const createReducer =
finished: false,
loop: 0,
percent: 0,
runtime: 0,
runtime: -1,
start: action.start,
options: action.options,
};
Expand Down Expand Up @@ -95,6 +103,7 @@ const createReducer =
*/
export const useAnimationLoop = ({
animate,
delay,
duration,
loops,
manual = false,
Expand All @@ -106,18 +115,22 @@ export const useAnimationLoop = ({
const previousState = useRef<State>({} as State);
const previousUseAnimationLoopResponse = useRef<UseAnimationLoopResponse>({} as UseAnimationLoopResponse);
const [state, dispatch] = useReducer(createReducer(), initialState);
const { setSafeTimeout } = useSafeTimeout();

const hasStateFinishedChanged = state.finished !== previousState.current.finished;
const hasStateLoopChanged = state.loop !== previousState.current.loop;
const hasStatePercentChanged = state.percent !== previousState.current.percent;

previousState.current = produce(previousState.current, draftState => {
draftState.finished = state.finished;
draftState.loop = state.loop;
draftState.percent = state.percent;
});

// updates the loop number, completion percentage and runtime with each tick
const tick = useCallback(
(timestamp: number, restart?: boolean, options?: State['options']): void => {
if (!state.start || restart) {
previousState.current.start = state.start;
previousState.current.options = state.options;

return dispatch({
type: ACTIONS.SET_START,
start: timestamp,
Expand All @@ -135,34 +148,36 @@ export const useAnimationLoop = ({
const loop = duration ? Math.floor(runtime / duration) : 1;
const percent = calculatePercent(runtime, loops, duration);

previousState.current.loop = state.loop;
previousState.current.percent = state.percent;
previousState.current.runtime = state.runtime;

return dispatch({
type: ACTIONS.SET_DURATION,
loop,
percent,
runtime,
});
},
[duration, loops, state.loop, state.options, state.percent, state.runtime, state.start],
[duration, loops, state.start],
);

// part of the returned value used to manually start the animation
const start = useCallback(
(options: Pick<State, 'options'>): void => {
if (duration === 0) {
animate(100, options);
onFinish && onFinish();
const run = () => {
animate(100, options);
onFinish && onFinish();
};
delay ? setSafeTimeout(run, delay) : run();
} else {
requestId.current =
typeof window !== 'undefined'
? window.requestAnimationFrame(timestamp => tick(timestamp, true, options))
: undefined;
const run = () => {
requestId.current =
typeof window !== 'undefined'
? window.requestAnimationFrame(timestamp => tick(timestamp, true, options))
: undefined;
};
delay ? setSafeTimeout(run, delay) : run();
}
},
[animate, duration, onFinish, tick],
[animate, delay, duration, onFinish, setSafeTimeout, tick],
);

// part of the returned value used to manually stop the animation
Expand Down Expand Up @@ -195,25 +210,21 @@ export const useAnimationLoop = ({
// flags the state as finished if the number of loops exceeds the maximum loops
useEffect((): void => {
if (loops && state.loop >= loops) {
previousState.current.finished = state.finished;

dispatch({
type: ACTIONS.SET_FINISHED,
});
}
}, [loops, state.finished, state.loop]);
}, [loops, state.loop]);

// if a percent value was passed then update the percentage state
useEffect((): void => {
if (percent && percent !== 100) {
previousState.current.percent = state.percent;

dispatch({
type: ACTIONS.SET_CUSTOM,
percent,
});
}
}, [percent, state.percent]);
}, [percent]);

// if the finished flag was set then stop running and call animate()
useEffect((): void => {
Expand Down
4 changes: 4 additions & 0 deletions src/hooks/usePanelCollapser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useAnimationLoop } from './useAnimationLoop';
export type CollapseTransition = 'squashable' | 'shiftable';

export type UsePanelCollapserProps = {
/** The delay before starting the animation */
delay?: number;
disableHiding?: boolean;
/** The duration of the animation (no duration results in an immediate change) */
duration: number;
Expand Down Expand Up @@ -132,6 +134,7 @@ const getProperties = ({ position, transition, width, height, useMax }: GetPrope
* value so that it shifts out out of the visible area.
*/
export const usePanelCollapser = ({
delay,
disableHiding = false,
duration,
easing,
Expand Down Expand Up @@ -190,6 +193,7 @@ export const usePanelCollapser = ({
// no duration for the first run so the panel doesn't animate in or out on load
const { start, stop } = useAnimationLoop({
animate,
delay,
duration: firstRun ? 0 : duration,
loops: 1,
manual: true,
Expand Down

0 comments on commit 0497de7

Please sign in to comment.