LHJ

I'm a FE developer.

스트로크 긋기 효과

05 Oct 2020 » js_apple_interaction

스트로크 긋기 효과

여태까지 배운 개념으로 만들 수 있는 것이다.
새로울 거 하나 없다.
그냥 디자인과 형태가 다를 뿐이다.

stroke effect

<span class="sticky-elem ribbon-path">
    <svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 450">
        <path class="st1" d="M709,41.5c-194,38-387,122-455,159c-64.13,34.89-73.4,42.42,20,26c82.5-14.5,126.34-33.68,185-38 c47.5-3.5,69.22,7.98-11,39c-75,29-251,98-459,169"></path>
    </svg>
</span>

index.html 파일에서 위 부분이 빨간색으로 칠해지는 스트로크 부분이다.
svg에서 path 태그로 스트로크를 그은건데, 우리 스트로크 써봤지?
로딩 만들 때

로딩 동그란거 만들 때 stroke-dasharray, stroke-dashoffset 사용했었다.
그 개념 그대로 이용한 거다.

위의 path는 애플 사이트에서 그대로 갖고온 것이다.
똑같은 모양으로 만들기 위해서.

SVG 이미지나 스트로크를 직접 만들고 싶다면 어도비 일러스트레이터 같은 SVG 제작이 가능한 그래픽툴을 사용하시면 됩니다.
물론 코드로 한땀한땀 만드셔도 됩니다.

<img class="sticky-elem pencil" src="images/pencil.png">

그리고 위 이미지가 연필 이미지이다.
얘도 마찬가지로 sticky-elem 클래스명을 사용했다.

.ribbon-path {
	left: 50%;
	top: 50%;
	min-width: 850px;
	transform: translate(-50%, -50%);
}
.ribbon-path path {
	fill: none;
	stroke: #FF0044;
	stroke-width: 62;
	stroke-linecap: round;
	stroke-linejoin: round;
	stroke-dasharray: 1401;
	stroke-dashoffset: 1401;
}

css 파일에서 눈여겨볼 부분은 윗 부분이다.
.ribbon-path pathstroke-dasharray, stroke-dashoffset을 미리 설정해두고, stroke-dashoffset 이 값을 스크롤 비율만큼 조정해서 그려지는 효과를 만들어내는 것이다.

(() => {

	let yOffset = 0; // window.pageYOffset 대신 쓸 변수
	let prevScrollHeight = 0; // 현재 스크롤 위치(yOffset)보다 이전에 위치한 스크롤 섹션들의 스크롤 높이값의 합
	let currentScene = 0; // 현재 활성화된(눈 앞에 보고있는) 씬(scroll-section)
	let enterNewScene = false; // 새로운 scene이 시작된 순간 true

	const sceneInfo = [
		{
			// 0
			type: 'sticky',
			heightNum: 3, // 브라우저 높이의 5배로 scrollHeight 세팅
			scrollHeight: 0,
			objs: {
				container: document.querySelector('#scroll-section-0'),
				messageA: document.querySelector('#scroll-section-0 .main-message.a'),
				messageB: document.querySelector('#scroll-section-0 .main-message.b'),
				pencilLogo: document.querySelector('#scroll-section-0 .pencil-logo'),
				pencil: document.querySelector('#scroll-section-0 .pencil'),
				ribbonPath: document.querySelector('.ribbon-path path')
			},
			values: {
				messageA_opacity_in: [0, 1, { start: 0.1, end: 0.2 }],
				messageB_opacity_in: [0, 1, { start: 0.4, end: 0.5 }],
				messageA_translateY_in: [20, 0, { start: 0.1, end: 0.2 }],
				messageA_opacity_out: [1, 0, { start: 0.3, end: 0.4 }],
				messageB_opacity_out: [1, 0, { start: 0.6, end: 0.7 }],
				messageA_translateY_out: [0, -20, { start: 0.3, end: 0.4 }],
				pencilLogo_width_in: [1000, 200, { start: 0.1, end: 0.4 }],
				pencilLogo_width_out: [200, 50, { start: 0.4, end: 0.8 }],
				pencilLogo_translateX_in: [-10, -20, { start: 0.2, end: 0.4 }],
				pencilLogo_translateX_out: [-20, -50, { start: 0.4, end: 0.8 }],
				pencilLogo_opacity_out: [1, 0, { start: 0.8, end: 0.9 }],
				pencil_right: [-10, 70, { start: 0.3, end: 0.8 }],
				pencil_bottom: [-80, 100, { start: 0.3, end: 0.8 }],
				pencil_rotate: [-120, -200, { start: 0.3, end: 0.8 }],
				path_dashoffset_in: [1401, 0, { start: 0.2, end: 0.4 }],
				path_dashoffset_out: [0, -1401, { start: 0.6, end: 0.8 }]
			}
		}
	];

	function setLayout() {
		// 각 스크롤 섹션의 높이 세팅
		for (let i = 0; i < sceneInfo.length; i++) {
			if (sceneInfo[i].type === 'sticky') {
				sceneInfo[i].scrollHeight = sceneInfo[i].heightNum * window.innerHeight;
			} else if (sceneInfo[i].type === 'normal')  {
                sceneInfo[i].scrollHeight = sceneInfo[i].objs.container.offsetHeight;
			}
            sceneInfo[i].objs.container.style.height = `${sceneInfo[i].scrollHeight}px`;
		}

		yOffset = window.pageYOffset;

		let totalScrollHeight = 0;
		for (let i = 0; i < sceneInfo.length; i++) {
			totalScrollHeight += sceneInfo[i].scrollHeight;
			if (totalScrollHeight >= yOffset) {
				currentScene = i;
				break;
			}
		}
		document.body.setAttribute('id', `show-scene-${currentScene}`);
	}

	function calcValues(values, currentYOffset) {
		let rv;
		// 현재 씬(스크롤섹션)에서 스크롤된 범위를 비율로 구하기
		const scrollHeight = sceneInfo[currentScene].scrollHeight;
		const scrollRatio = currentYOffset / scrollHeight;

		if (values.length === 3) {
			// start ~ end 사이에 애니메이션 실행
			const partScrollStart = values[2].start * scrollHeight;
			const partScrollEnd = values[2].end * scrollHeight;
			const partScrollHeight = partScrollEnd - partScrollStart;

			if (currentYOffset >= partScrollStart && currentYOffset <= partScrollEnd) {
				rv = (currentYOffset - partScrollStart) / partScrollHeight * (values[1] - values[0]) + values[0];
			} else if (currentYOffset < partScrollStart) {
				rv = values[0];
			} else if (currentYOffset > partScrollEnd) {
				rv = values[1];
			}
		} else {
			rv = scrollRatio * (values[1] - values[0]) + values[0];
		}

		return rv;
	}

	function playAnimation() {
		const objs = sceneInfo[currentScene].objs;
		const values = sceneInfo[currentScene].values;
		const currentYOffset = yOffset - prevScrollHeight;
		const scrollHeight = sceneInfo[currentScene].scrollHeight;
		const scrollRatio = currentYOffset / scrollHeight;

		switch (currentScene) {
			case 0:
				if (scrollRatio <= 0.25) {
					// in
					objs.messageA.style.opacity = calcValues(values.messageA_opacity_in, currentYOffset);
					objs.messageA.style.transform = `translate3d(0, ${calcValues(values.messageA_translateY_in, currentYOffset)}%, 0)`;
				} else {
					// out
					objs.messageA.style.opacity = calcValues(values.messageA_opacity_out, currentYOffset);
					objs.messageA.style.transform = `translate3d(0, ${calcValues(values.messageA_translateY_out, currentYOffset)}%, 0)`;
				}

				if (scrollRatio <= 0.55) {
					// in
					objs.messageB.style.opacity = calcValues(values.messageB_opacity_in, currentYOffset);
				} else {
					// out
					objs.messageB.style.opacity = calcValues(values.messageB_opacity_out, currentYOffset);
				}

				// 크기가 커져도 깨지지 않는 SVG의 장점을 살리기 위해 transform scale 대신 width를 조정
				if (scrollRatio <= 0.4) {
					objs.pencilLogo.style.width = `${calcValues(values.pencilLogo_width_in, currentYOffset)}vw`;
					objs.pencilLogo.style.transform = `translate(${calcValues(values.pencilLogo_translateX_in, currentYOffset)}%, -50%)`;
				} else {
					objs.pencilLogo.style.width = `${calcValues(values.pencilLogo_width_out, currentYOffset)}vw`;
					objs.pencilLogo.style.transform = `translate(${calcValues(values.pencilLogo_translateX_out, currentYOffset)}%, -50%)`;
				}

				// 빨간 리본 패스(줄 긋기)
				if (scrollRatio <= 0.5) {
					objs.ribbonPath.style.strokeDashoffset = calcValues(values.path_dashoffset_in, currentYOffset);
				} else {
					objs.ribbonPath.style.strokeDashoffset = calcValues(values.path_dashoffset_out, currentYOffset);
				}

				objs.pencilLogo.style.opacity = calcValues(values.pencilLogo_opacity_out, currentYOffset);
				objs.pencil.style.right = `${calcValues(values.pencil_right, currentYOffset)}%`;
				objs.pencil.style.bottom = `${calcValues(values.pencil_bottom, currentYOffset)}%`;
				objs.pencil.style.transform = `rotate(${calcValues(values.pencil_rotate, currentYOffset)}deg)`;

				break;
		}
	}

	function scrollLoop() {
		enterNewScene = false;
		prevScrollHeight = 0;

		for (let i = 0; i < currentScene; i++) {
			prevScrollHeight += sceneInfo[i].scrollHeight;
		}

		if (yOffset < prevScrollHeight + sceneInfo[currentScene].scrollHeight) {
			document.body.classList.remove('scroll-effect-end');
		}

		if (yOffset > prevScrollHeight + sceneInfo[currentScene].scrollHeight) {
			enterNewScene = true;
			if (currentScene === sceneInfo.length - 1) {
				document.body.classList.add('scroll-effect-end');
			}
			if (currentScene < sceneInfo.length - 1) {
				currentScene++;
			}
			document.body.setAttribute('id', `show-scene-${currentScene}`);
		}

		if (yOffset < prevScrollHeight) {
			enterNewScene = true;
			// 브라우저 바운스 효과로 인해 마이너스가 되는 것을 방지(모바일)
			if (currentScene === 0) return;
			currentScene--;
			document.body.setAttribute('id', `show-scene-${currentScene}`);
		}

		if (enterNewScene) return;

		playAnimation();
	}

	window.addEventListener('load', () => {
        document.body.classList.remove('before-load');
		setLayout();

        window.addEventListener('scroll', () => {
            yOffset = window.pageYOffset;
            scrollLoop();
  		});

  		window.addEventListener('resize', () => {
  			if (window.innerWidth > 900) {
  				setLayout();
			}
  		});

  		window.addEventListener('orientationchange', () => {
  			setTimeout(setLayout, 500);
		});
		  
		document.querySelector('.loading').addEventListener('transitionend', (e) => {
			document.body.removeChild(e.currentTarget);
		});

	});

})();

여기서 체크해야될 부분은 이 부분이다.

function playAnimation() {
    const objs = sceneInfo[currentScene].objs;
    const values = sceneInfo[currentScene].values;
    const currentYOffset = yOffset - prevScrollHeight;
    const scrollHeight = sceneInfo[currentScene].scrollHeight;
    const scrollRatio = currentYOffset / scrollHeight;

    switch (currentScene) {
        case 0:
            // 크기가 커져도 깨지지 않는 SVG의 장점을 살리기 위해 transform scale 대신 width를 조정
            if (scrollRatio <= 0.4) {
                objs.pencilLogo.style.width = `${calcValues(values.pencilLogo_width_in, currentYOffset)}vw`;
                objs.pencilLogo.style.transform = `translate(${calcValues(values.pencilLogo_translateX_in, currentYOffset)}%, -50%)`;
            } else {
                objs.pencilLogo.style.width = `${calcValues(values.pencilLogo_width_out, currentYOffset)}vw`;
                objs.pencilLogo.style.transform = `translate(${calcValues(values.pencilLogo_translateX_out, currentYOffset)}%, -50%)`;
            }

            break;
    }
}

앞에 예제에선 크기와 위치를 조정할 때 다 transform을 썼었다.
transformscale로 크기를 조정하고 transformtranslate로 위치조정을 했었다.

<object class="sticky-elem pencil-logo" data="images/pencil-logo.svg" type="image/svg+xml"></object>

svg는 아래와 같이 인라인 방식으로 코드 자체를 넣을 수도 있고

<span class="sticky-elem ribbon-path">
    <svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 450">
        <path class="st1" d="M709,41.5c-194,38-387,122-455,159c-64.13,34.89-73.4,42.42,20,26c82.5-14.5,126.34-33.68,185-38 c47.5-3.5,69.22,7.98-11,39c-75,29-251,98-459,169"></path>
    </svg>
</span>
<object class="sticky-elem pencil-logo" data="images/pencil-logo.svg" type="image/svg+xml"></object>

이렇게 object 태그를 이용해서 data 안에 svg 파일 경로를 넣어서 이용할 수도 있다.
이미지랑 똑같이 img 태그로도 svg 이미지를 사용할 수 있는데, 이 방법은 css 스타일링 하기도 어렵고 그래서 잘 안쓴다. object로 쓰는게 더 좋다. (만약 이 svg를 CSS나 JS로 컨트롤 하시려면)

여튼 위의 object로 불러온 svg는 페이지에서 “사과랑 Pencil”이라고 쓰여져있는 부분이다.
해당 부분은 스크롤 위치에 따라 크기가 변한다.
이 크기를 얘는 transfromscale을 이용 안하고 width를 직접 조정해줬다.

function playAnimation() {
    const objs = sceneInfo[currentScene].objs;
    const values = sceneInfo[currentScene].values;
    const currentYOffset = yOffset - prevScrollHeight;
    const scrollHeight = sceneInfo[currentScene].scrollHeight;
    const scrollRatio = currentYOffset / scrollHeight;

    switch (currentScene) {
        case 0:
            // 크기가 커져도 깨지지 않는 SVG의 장점을 살리기 위해 transform scale 대신 width를 조정
            if (scrollRatio <= 0.4) {
                objs.pencilLogo.style.width = `${calcValues(values.pencilLogo_width_in, currentYOffset)}vw`;
                objs.pencilLogo.style.transform = `translate(${calcValues(values.pencilLogo_translateX_in, currentYOffset)}%, -50%)`;
            } else {
                objs.pencilLogo.style.width = `${calcValues(values.pencilLogo_width_out, currentYOffset)}vw`;
                objs.pencilLogo.style.transform = `translate(${calcValues(values.pencilLogo_translateX_out, currentYOffset)}%, -50%)`;
            }

            break;
    }
}

이런식으로 말이다.
이유가 뭘까?

svg는 백터잖아?
백터라는 것은 크기를 아무리 많이 키워도 안 깨진다.
path 계산에 의해 그려지는 그림이기 때문에.
근데 이런 백터를 width 같은 크기 자체로 조정을 안하고 scale로 뻥튀기를 하게되면 그 장점을 다 못살리게된다.
뿌옇게 깨지게된다.
그래서 이 경우는 svg라서 width로 크기를 조정해준 것이다.

function playAnimation() {
    const objs = sceneInfo[currentScene].objs;
    const values = sceneInfo[currentScene].values;
    const currentYOffset = yOffset - prevScrollHeight;
    const scrollHeight = sceneInfo[currentScene].scrollHeight;
    const scrollRatio = currentYOffset / scrollHeight;

    switch (currentScene) {
        case 0:
            // 빨간 리본 패스(줄 긋기)
            if (scrollRatio <= 0.5) {
                objs.ribbonPath.style.strokeDashoffset = calcValues(values.path_dashoffset_in, currentYOffset);
            } else {
                objs.ribbonPath.style.strokeDashoffset = calcValues(values.path_dashoffset_out, currentYOffset);
            }

            objs.pencilLogo.style.opacity = calcValues(values.pencilLogo_opacity_out, currentYOffset);
            objs.pencil.style.right = `${calcValues(values.pencil_right, currentYOffset)}%`;
            objs.pencil.style.bottom = `${calcValues(values.pencil_bottom, currentYOffset)}%`;
            objs.pencil.style.transform = `rotate(${calcValues(values.pencil_rotate, currentYOffset)}deg)`;

            break;
    }
}

그리고 이 부분, 빨간 줄 긋는 효과 부분이다.
objs.ribbonPath.style.strokeDashoffset 이렇게 strokeDashoffset을 조정하는 것을 볼 수 있다.
똑같이 calcValues 함수로 우리 하던거 그대로 계속 반복해서 한 것이다.
단지 svg이고 strokeDashoffset을 이용해서 그려지는 효과를 냈다는 것 뿐.

그리고 그런 값들을 활용해서 밑에 보면 rotate로 연필을 돌린다던지 하는 것이다.

스크롤하면 stroke-dashoffset-1401 ~ 1401 범위에서 움직이는 것을 볼 수 있다.
이전에 로딩 이미지 만든 원리랑 거의 같다고 보면 된다.


여기서 알 수 있는 점.

애플에서 신제품을 출시할 때마다 같은 스타일의 인터렉션을 사용하고 있다.
단지 디자인이 조금 새로워지고 레이아웃이 바뀔뿐이지 기본적인 동작 자체는 유사하다는 것이다.
지금 우리도 새로 배운게 아니라 모양이 달라서 속성만 다른 것을 이용한 것 뿐이지 핵심적인 코드를 바꾼것이 전혀 없다.