스트로크 긋기 효과
여태까지 배운 개념으로 만들 수 있는 것이다.
새로울 거 하나 없다.
그냥 디자인과 형태가 다를 뿐이다.
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 path
에 stroke-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
을 썼었다.
transform
의 scale
로 크기를 조정하고 transform
에 translate
로 위치조정을 했었다.
<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”이라고 쓰여져있는 부분이다.
해당 부분은 스크롤 위치에 따라 크기가 변한다.
이 크기를 얘는 transfrom
의 scale
을 이용 안하고 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 범위에서 움직이는 것을 볼 수 있다.
이전에 로딩 이미지 만든 원리랑 거의 같다고 보면 된다.
여기서 알 수 있는 점.
애플에서 신제품을 출시할 때마다 같은 스타일의 인터렉션을 사용하고 있다.
단지 디자인이 조금 새로워지고 레이아웃이 바뀔뿐이지 기본적인 동작 자체는 유사하다는 것이다.
지금 우리도 새로 배운게 아니라 모양이 달라서 속성만 다른 것을 이용한 것 뿐이지 핵심적인 코드를 바꾼것이 전혀 없다.