컴공과컴맹효묘의블로그

[React.ts] FullPageScroll 전체 화면씩 스크롤하기 본문

개발/짧

[React.ts] FullPageScroll 전체 화면씩 스크롤하기

효묘 2023. 6. 18. 20:08
반응형

Full page scroll?

풀 페이지 스크롤이란, 스크롤을 살짝만 하면 전체 페이지가 스크롤되는 트랜젝션입니다. 이 트랜젝션은 ppt같은 느낌을 주고, 기업 소개 페이지같은 랜딩 페이지에 어울립니다.

직접 스크롤을 window listener와 prevent default로기능을 없애고, 간단한 계산으로만 full-page scroll을 구현했습니다.

FullPageScroll.tsx 전체 코드

import { PropsWithChildren, useEffect, useRef, useState } from "react";
import { Dots } from "./Dots";

type PFullPageScroll = {
	onPageChange?: (page: number) => void;
	onLoad?: (limit: number) => void;
} & PropsWithChildren;

export const FullPageScroll: React.FC<PFullPageScroll> = ({
	children,
	onLoad = () => {},
	onPageChange = () => {},
}) => {
	const outerDivRef = useRef<HTMLDivElement>(null);
	const currentPage = useRef<number>(0);
	const canScroll = useRef<boolean>(true);
	const oldTouchY = useRef<number>(0);
	const [_, refresh] = useState<number>(0);

	const scrollDown = () => {
		const pageHeight = outerDivRef.current?.children.item(0)?.clientHeight; // 화면 세로 길이 100vh
		if (outerDivRef.current && pageHeight) {
			outerDivRef.current.scrollTo({
				top: pageHeight * (currentPage.current + 1),
				left: 0,
				behavior: "smooth",
			});
			canScroll.current = false;
			setTimeout(() => {
				canScroll.current = true;
			}, 500);
			if (outerDivRef.current.childElementCount - 1 > currentPage.current)
				currentPage.current++;
		}
		console.log(currentPage.current);
		onPageChange(currentPage.current);
		refresh((v) => v + 1);
	};

	const scrollUp = () => {
		const pageHeight = outerDivRef.current?.children.item(0)?.clientHeight; // 화면 세로 길이 100vh
		if (outerDivRef.current && pageHeight) {
			outerDivRef.current.scrollTo({
				top: pageHeight * (currentPage.current - 1),
				left: 0,
				behavior: "smooth",
			});
			canScroll.current = false;
			setTimeout(() => {
				canScroll.current = true;
			}, 500);
			if (currentPage.current > 0) currentPage.current--;
		}
		console.log(currentPage.current);
		onPageChange(currentPage.current);
		refresh((v) => v + 1);
	};

	const wheelHandler = (e: WheelEvent) => {
		e.preventDefault();
		if (!canScroll.current) return;
		const { deltaY } = e; // +is down -is up
		console.log("scroll to", outerDivRef.current?.scrollHeight);
		if (deltaY > 0 && outerDivRef.current) {
			scrollDown();
		} else if (deltaY < 0 && outerDivRef.current) {
			scrollUp();
		}
	}; // wheel Handler

	const scrollHandler = (e: Event) => {
		e.preventDefault();
	};

	const onTouchDown = (e: TouchEvent) => {
		oldTouchY.current = e.changedTouches.item(0)?.clientY || 0;
	};

	const onTouchUp = (e: TouchEvent) => {
		const currentTouchY = e.changedTouches.item(0)?.clientY || 0;
		const isScrollDown: boolean =
			oldTouchY.current - currentTouchY > 0 ? true : false;

		if (isScrollDown) {
			scrollDown();
		} else {
			scrollUp();
		}
	};

	useEffect(() => {
		const outer = outerDivRef.current;
		if (!outer) return;
		onLoad(outerDivRef.current.childElementCount);
		refresh((v) => v + 1);
		outer.addEventListener("wheel", wheelHandler);
		outer.addEventListener("scroll", scrollHandler);
		outer.addEventListener("touchmove", scrollHandler);
		outer.addEventListener("touchstart", onTouchDown);
		outer.addEventListener("touchend", onTouchUp);
		return () => {
			outer.removeEventListener("wheel", wheelHandler);
			outer.removeEventListener("scroll", scrollHandler);
			outer.removeEventListener("touchmove", scrollHandler);
			outer.removeEventListener("touchstart", onTouchDown);
			outer.removeEventListener("touchend", onTouchUp);
		};
	}, []);
	const movePageTo= (index: number) => {
		const num = currentPage.current;
		if (index > num) for (let i = 0; i < index - num; i++) scrollDown();
		else if (index < num) for (let i = 0; i < num - index; i++) scrollUp();
	};

	return (
		<>
			<div
				ref={outerDivRef}
				style={{ height: "100vh", width: "100%", overflowY: "hidden" }}
			>
				{children}
			</div>
			<Dots
				limit={outerDivRef.current?.childElementCount || 0}
				currentIndex={currentPage.current}
				onDotClick={movePageTo}
			/>
		</>
	);
};

FullPageScroll은 outer와 inner div로 나뉩니다.

outer는 전체 화면에 꽉 차는 크기이고, 항상 화면에 보여지는 div입니다.

inner는 전체 화면에 꽉 차는 크기이지만, 화면에 보이지 않을 수도 있습니다.

inner는 여러 개 있을 수 있고, outer 바로 안에 존재합니다.

여기서 outer는 FullPageScroll 자체입니다.

<FullPageScroll> // outer
	<div className="inner"/>
	<div className="ineer"/>
</FullPageScroll>

outer와 inner 모두 스타일을 height: "100vh"로 맞춰줍니다. 또한 outer는 overflow-y: 'hidden'으로 해줍니다.

스크롤 중 스크롤을 막기 위해서 사용하는 여러 가지 방법이 있겠지만, 가장 간단하고 단순하게 자바스크립트의 setTimeOut함수로 스크롤을 잠시 중단하는 방법을 사용했습니다. 이렇게 하면 코드도 복잡해지지 않고 간단하면서 효과적인 기능을 구현했습니다.

함수들

FullPageScroll은 다음과 같은 상태와 함수로 이루어져있습니다.

const outerDivRef = useRef<HTMLDivElement>(null);
const currentpage = useRef<number>(0);
const canScroll = useRef<boolean>(true);
const oldTouchY = useRef<number>(0);
const [_, refresh] = useState<number>(0);

// 다음 페이지로
const void scrollDown();

// 이전 페이지로
const void scrollUp();

// 마우스 휠 감지
const void wheelHandler(e: WheelHandler);

// 스크롤 기능 비활성화 함수
const void scrollHandler(e: Event);

// 터치로 스크롤 했을 때 페이지 이동 가능하게 하는 함수
const void onTouchDown(e: TouchEvent);
const void onTouchUp(e: TouchEvent);

// Dot을 눌렀을 때 해당 페이지로 이동하는 함수.
const void movePageTo(index: number);

scrollDown, scrollUp 함수를 보면, 한 번 스크롤할 때마다 다음과 같은 offset이 이동됩니다.

const pageHeight = outerDivRef.current?.children.item(0)?.clientHeight; 

해당 코드를 이용한 이유는, FullPageScroll의 child의 높이가 100vh라는 것을 가정했고, window.innerHeight같은 함수를 사용하면 모바일에서 주소 창 때문에 제대로 작동하지 않는 경우가 있습니다. (meta 태그 해도 안 됨) 따라서 위 함수로 직접 child를 계산해줍니다.

Dot.tsx

Dot 컴포넌트는 어려운 기능은 없고, 어느 페이지에서나 Dot을 누를 수있게 style로 위치를 고정시켜놓았습니다.

import styles from "./Dots.module.scss";

type PDot = {
	index: number;
	currentIndex: number;
	onClick: (index: number) => void;
};
const Dot: React.FC<PDot> = ({ index, currentIndex, onClick }) => {
	const selected = index === currentIndex;
	return (
		<div
			style={{
				width: 15,
				height: 15,
				border: "1px solid" + (selected ? " white" : " rgba(0, 0, 0, 0)"),
				borderRadius: 9999,
				margin: "10px 0",
				display: "flex",
				justifyContent: "center",
				alignItems: "center",
			}}
			onClick={() => onClick(index)}
		>
			<div
				style={{
					position: "relative",
					width: 11,
					height: 11,
					borderRadius: 9999,
					backgroundColor: "white",
					cursor: "pointer",
				}}
			></div>
		</div>
	);
};

type TDots = {
	limit: number;
	currentIndex: number;
	onDotClick: (index: number) => void;
};

export const Dots: React.FC<TDots> = ({ limit, currentIndex, onDotClick }) => {
	return (
		<div style={{ position: "fixed", top: 0, left: 100, height: "100%" }}>
			<div
				style={{
					position: "fixed",
					top: 65,
					left: 100 + 8,
					height: "100%",
					width: 1,
					backgroundColor: "white",
				}}
			></div>
			<div
				style={{
					position: "fixed",
					display: "flex",
					flexDirection: "column",
					height: "100%",
					alignItems: "center",
					justifyContent: "center",
				}}
			>
				{Array(limit)
					.fill("")
					.map((_, index) => (
						<Dot
							index={index}
							currentIndex={currentIndex}
							onClick={onDotClick}
						></Dot>
					))}
			</div>
		</div>
	);
};

다음은 App.tsx 코드 입니다.

App.tsx

import styles from "./App.module.scss";
import { MainLayout } from "./layout/MainLayout";
import { FullPageScroll } from "./customComponent/FullPageScroll";

function App() {
	return (
		<MainLayout>
			<FullPageScroll>
				<div className={`${styles.bg} ${styles.section}`}></div>
				<div className={`${styles.bg} ${styles.section}`}></div>
				<div className={`${styles.bg} ${styles.section}`}></div>
				<div className={`${styles.bg} ${styles.section}`}></div>
				<div className={`${styles.bg} ${styles.section}`}></div>
			</FullPageScroll>
		</MainLayout>
	);
}

export default App;

App.module.scss

.bg {
  position: relative;
  top: 0;
  height: 100vh;
  width: 100%;
  background-repeat: no-repeat;
  background-size: cover;
  background-position: center;
}

.outer {
  height: 100vh;
  width: 100%;
  overflow-y: auto;
}

.section {
  height: 100vh;
  width: 100%;
  
  &:nth-child(1) {
    background-color: rgb(230, 74, 74);
  }
  &:nth-child(2) {
    background-color: rgb(255, 208, 53);
  }
  &:nth-child(3) {
    background-color: rgb(77, 218, 157);
  }
  &:nth-child(4) {
    background-color: rgb(83, 232, 255);
  }
  &:nth-child(5) {
    height: 50vh;
    background-color: rgb(126, 83, 255);
  }
}

*참고: https://codingbroker.tistory.com/128*

반응형
Comments