Skip to content

Commit

Permalink
ドラッグによるスクロールが可能なDivを実装
Browse files Browse the repository at this point in the history
  • Loading branch information
erutobusiness committed Apr 26, 2024
1 parent 29fdbe8 commit 44a6233
Show file tree
Hide file tree
Showing 14 changed files with 819 additions and 367 deletions.
472 changes: 110 additions & 362 deletions src/components/div/DivScrollable.tsx

Large diffs are not rendered by default.

406 changes: 406 additions & 0 deletions src/components/div/DivScrollbar.tsx

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions src/hooks/useEventKeydown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { type RefObject, useEffect } from 'react';

const KEYDOWN_DURATION = 300;
const KEYDOWN_IGNORE_TAGNAMES = ['INPUT', 'TEXTAREA'];
let keydownTimestamp = 0;

interface Trigger {
key: string;
callback: () => void;
}

export const useEventKeydown = (
triggers: Trigger[],
ref?: RefObject<HTMLElement>,
) => {
//
const handleKeydown = (e: KeyboardEvent) => {
if (e.isComposing) return; // 変換中ならキャンセル
const tagName = (e.target as HTMLElement).tagName; // 入力対象のタグ名を取得
if (KEYDOWN_IGNORE_TAGNAMES.includes(tagName)) return; // タグ名が無視リストにあるならキャンセル
if (triggers.map(t => t.key).includes(e.key)) e.preventDefault(); // デフォルトの動作を無効化
const sub = e.timeStamp - keydownTimestamp; // 時間間隔を取得
if (sub < KEYDOWN_DURATION) return; // 間隔が一定以下ならキャンセル
keydownTimestamp = e.timeStamp; // 最終実行時間を更新
// 入力に応じたコールバックを実行
for (const trigger of triggers) {
if (trigger.key === e.key) {
trigger.callback();
}
}
};
//
useEffect(() => {
if (ref?.current) {
ref.current.addEventListener('keydown', handleKeydown, true);
} else {
window.addEventListener('keydown', handleKeydown, true);
}
return () => {
if (ref?.current) {
ref.current.removeEventListener('keydown', handleKeydown, true);
} else {
window.removeEventListener('keydown', handleKeydown, true);
}
};
}, [ref, handleKeydown]);
};
36 changes: 36 additions & 0 deletions src/hooks/useEventWheel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { type RefObject, useEffect } from 'react';

const WHEEL_DURATION = 300;
let wheelTimeoutid: number | null = null;

export const useEventWheel = (
ref: RefObject<HTMLElement>,
callbackWheel: () => void,
callbackEnd: () => void,
) => {
//
const handleWheel = (e: WheelEvent) => {
// 要素がないならキャンセル
if (!ref.current) return;
// 実行中の処理を実行
callbackWheel();
// デフォルトのスクロールを無効化
e.preventDefault();
//
ref.current.scrollLeft += e.deltaX;
ref.current.scrollTop += e.deltaY;
// タイムアウトを設定済みならクリア
if (wheelTimeoutid) clearTimeout(wheelTimeoutid);
// タイムアウトを設定
wheelTimeoutid = setTimeout(callbackEnd, WHEEL_DURATION);
};
//
useEffect(() => {
ref.current?.addEventListener('wheel', handleWheel, {
passive: false,
});
return () => {
ref.current?.removeEventListener('wheel', handleWheel);
};
}, [ref, handleWheel]);
};
3 changes: 3 additions & 0 deletions src/pages/scroll/Scroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { H1 } from '../../components/heading/H1';
import { useScrollSmooth } from '../../hooks/useScrollSmooth';
import { ScrollCss } from './ScrollCss';
import { ScrollDiv } from './ScrollDiv';
import { Ultimate } from './Ultimate';

const styles = stylex.create({
wrap: {
Expand Down Expand Up @@ -57,6 +58,8 @@ const Component: FC = () => {
<DivCustom styles={styles.wrap}>
{/* タイトル */}
<H1 propsStyles={styles.h1}>Scroll</H1>
{/* Ultimate */}
<Ultimate />
{/* Divスクロール */}
<ScrollDiv />
{/* CSSスクロール */}
Expand Down
10 changes: 5 additions & 5 deletions src/pages/scroll/ScrollDiv.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type FC, memo, useCallback, useRef } from 'react';
import type { RefHandle } from '../../@types/scrollable';
import { ButtonVite } from '../../components/button/ButtonVite';
import { DivCustom } from '../../components/div/DivCustom';
import { DivScrollable } from '../../components/div/DivScrollable';
import { DivScrollbar } from '../../components/div/DivScrollbar';
import { H2 } from '../../components/heading/H2';
import { useScrollSmooth } from '../../hooks/useScrollSmooth';
import { stylesCommon } from './styles';
Expand Down Expand Up @@ -73,17 +73,17 @@ const Component: FC = () => {
<DivCustom styles={styles.wrapper}>
<H2 propsStyles={stylesCommon.h2}>With JS</H2>
{/* 通常スクロール */}
<DivScrollable
<DivScrollbar
ref={refNormal}
id='normal'
stylesChild={styles.child}
>
<ButtonVite styles={styles.button} onClick={scrollDivNormal}>
Normal Scroll
</ButtonVite>
</DivScrollable>
</DivScrollbar>
{/* スムーススクロール */}
<DivScrollable
<DivScrollbar
ref={refSmooth}
id='smooth'
stylesChild={styles.child}
Expand All @@ -92,7 +92,7 @@ const Component: FC = () => {
<ButtonVite styles={styles.button} onClick={scrollDivSmooth}>
Smooth Scroll
</ButtonVite>
</DivScrollable>
</DivScrollbar>
</DivCustom>
);
};
Expand Down
62 changes: 62 additions & 0 deletions src/pages/scroll/Ultimate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import stylex from '@stylexjs/stylex';
import { type FC, memo } from 'react';
import { DivCustom } from '../../components/div/DivCustom';
import { DivScrollable } from '../../components/div/DivScrollable';
import { H2 } from '../../components/heading/H2';
import { stylesCommon } from './styles';

const styles = stylex.create({
wrapper: {
width: '100dvw',
height: '100dvh',
position: 'relative',
},
parent: {
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
width: '80dvw',
height: '80dvh',
},
scroll: {
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateRows: 'repeat(3, 1fr)',
'::-webkit-scrollbar': {
display: 'none',
},
},
child: {
width: '80dvw',
height: '80dvh',
background: 'radial-gradient(#ff000033, #0000ff33)',
},
});

const Component: FC = () => {
return (
<DivCustom styles={styles.wrapper}>
<H2 propsStyles={stylesCommon.h2}>Ultimate</H2>
{/* */}
<DivScrollable
isSnap
isAnimate
stylesParent={styles.parent}
stylesScroll={styles.scroll}
>
<div {...stylex.props(styles.child)}>1</div>
<div {...stylex.props(styles.child)}>2</div>
<div {...stylex.props(styles.child)}>3</div>
<div {...stylex.props(styles.child)}>4</div>
<div {...stylex.props(styles.child)}>5</div>
<div {...stylex.props(styles.child)}>6</div>
<div {...stylex.props(styles.child)}>7</div>
<div {...stylex.props(styles.child)}>8</div>
<div {...stylex.props(styles.child)}>9</div>
</DivScrollable>
</DivCustom>
);
};

export const Ultimate = memo(Component);
4 changes: 4 additions & 0 deletions src/utils/easeInOutQuad.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type Type = (t: number) => number;
export const easeInOutQuad: Type = t => {
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
};
7 changes: 7 additions & 0 deletions src/utils/getChildLengthes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const getChildWidthes = (elem: HTMLDivElement) => {
const children = Array.from(elem.children);
return {
childWidthes: children.map(child => child.clientWidth),
childHeights: children.map(child => child.clientHeight),
};
};
13 changes: 13 additions & 0 deletions src/utils/getChildOffsets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Coordinate } from '../@types';

type Type = (elem: HTMLDivElement) => Coordinate[];

export const getChildOffsets: Type = (elem: HTMLDivElement) => {
const rectParent = elem.getBoundingClientRect();
const children = Array.from(elem.children);
const rectChildren = children.map(c => c.getBoundingClientRect());
return rectChildren.map(c => ({
x: c.left - rectParent.left + elem.scrollLeft,
y: c.top - rectParent.top + +elem.scrollTop,
}));
};
15 changes: 15 additions & 0 deletions src/utils/getClosestCoordinate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Coordinate } from '../@types';

export const getClosestCoordinate = (
target: Coordinate,
samples: Coordinate[],
) =>
samples.reduce((acc, curr) => {
const currDistance = Math.sqrt(
(curr.x - target.x) ** 2 + (curr.y - target.y) ** 2,
);
const accDistance = Math.sqrt(
(acc.x - target.x) ** 2 + (acc.y - target.y) ** 2,
);
return currDistance < accDistance ? curr : acc;
}, samples[0]);
49 changes: 49 additions & 0 deletions src/utils/scrollAnimate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Coordinate } from '../@types';
import { easeInOutQuad } from './easeInOutQuad';

const SCROLL_ANIMATE_DURATION = 250;

type Callback = () => void;

export const scrollAnimate = () => {
// アニメーションのフレームID
let animationFrameId: number | null = null;

const cancelAnimateScroll = (callback?: Callback) => {
if (animationFrameId === null) return;
cancelAnimationFrame(animationFrameId);
animationFrameId === null;
callback?.();
};

const animateScroll = (
elem: HTMLElement,
target: Coordinate,
callback?: Callback,
) => {
const start = { x: elem.scrollLeft, y: elem.scrollTop };
const distance = { x: target.x - start.x, y: target.y - start.y };
if (distance.x === 0 && distance.y === 0) return callback?.();
// タイムスタンプを定義
let t: number | null = null;
// アニメーションを定義
const step = (timestamp: number) => {
if (!t) t = timestamp;
const progress = timestamp - t;
const percentage = Math.min(progress / SCROLL_ANIMATE_DURATION, 1);
const easePercentage = easeInOutQuad(percentage);
elem.scrollLeft = start.x + distance.x * easePercentage;
elem.scrollTop = start.y + distance.y * easePercentage;
// 進捗率で分岐
if (progress < SCROLL_ANIMATE_DURATION) {
animationFrameId = window.requestAnimationFrame(step);
} else {
cancelAnimateScroll(callback);
}
};
// アニメーションを実行
window.requestAnimationFrame(step);
};

return { cancelAnimateScroll, animateScroll };
};
40 changes: 40 additions & 0 deletions src/utils/snapPointer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { PointerEvent } from 'react';
import type { Coordinate } from '../@types';
import { getChildOffsets } from './getChildOffsets';
import { getClosestCoordinate } from './getClosestCoordinate';
import { scrollAnimate } from './scrollAnimate';

export const snapPointer = (
e: PointerEvent<HTMLDivElement>,
dragScroll: Coordinate,
dragTimestamp: number,
isAnimate: boolean | undefined,
) => {
//
const target = e.target as HTMLDivElement;
//
const { scrollLeft, scrollTop, clientWidth, clientHeight } = target;
const subTimestamp = e.timeStamp - dragTimestamp;
const subCoordinate: Coordinate = {
x: scrollLeft - dragScroll.x,
y: scrollTop - dragScroll.y,
};
const adjuster: Coordinate = {
x: ((subCoordinate.x / subTimestamp) * clientWidth) / 5000,
y: ((subCoordinate.y / subTimestamp) * clientHeight) / 5000,
};
const adjusted: Coordinate = {
x: scrollLeft + adjuster.x,
y: scrollTop + adjuster.y,
};
const offsets: Coordinate[] = getChildOffsets(target);
const closest = getClosestCoordinate(adjusted, offsets);
// フラグによりアニメーションか瞬間移動か分岐
if (isAnimate) {
const { animateScroll } = scrollAnimate();
animateScroll(target, closest);
} else {
target.scrollLeft = closest.x;
target.scrollTop = closest.y;
}
};
22 changes: 22 additions & 0 deletions src/utils/snapWheel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Coordinate } from '../@types';
import { getChildOffsets } from './getChildOffsets';
import { getClosestCoordinate } from './getClosestCoordinate';
import { scrollAnimate } from './scrollAnimate';

export const snapWheel = (
target: HTMLDivElement,
isAnimate: boolean | undefined,
) => {
//
const { scrollLeft: x, scrollTop: y } = target;
const offsets: Coordinate[] = getChildOffsets(target);
const closest = getClosestCoordinate({ x, y }, offsets);
// フラグによりアニメーションか瞬間移動か分岐
if (isAnimate) {
const { animateScroll } = scrollAnimate();
animateScroll(target, closest);
} else {
target.scrollLeft = closest.x;
target.scrollTop = closest.y;
}
};

0 comments on commit 44a6233

Please sign in to comment.