사용자 경험 향상을 위한 textarea 스크롤 위치 감지하기

부루베릐·2023년 11월 8일
0

TIL

목록 보기
13/20

우리 회사 서비스에서 계약서를 읽고 동의하는 기능을 구현하려 한다. 사용자가 계약서를 모두 숙지하면 "동의" 버튼을 클릭해서 계약에 동의를 하는 매커니즘인데, 핵심은 어떻게 하면 사용자가 계약서를 다 읽었는지를 파악할 수 있는지였다.

은행이나 보험 등 다른 회사의 서비스들을 참고하였을 때 가장 직관적이고 간단한 방법은, 계약서를 맨 아래까지 스크롤하였을 때 동의 버튼이 활성화되는 것이다. 이를 통해 사용자가 계약서를 다 읽고 숙지했다는 것을 보장할 수 있다. 따라서 사용자의 textarea 스크롤 위치를 감지하는 방법을 알아보려 한다.


Vue에서 DOM 요소를 가져오는 방법

스크롤을 다루기 위해선 결국 textarea DOM 요소를 Vue 코드에 가져와야 한다는 뜻이다. 이를 위해서 Vue에서는 여러 방법들을 사용할 수 있는데, 머릿속으로는 알고 있었지만 정리할 생각은 하지 못했었으므로 이번 기회에 간단하게 살펴보려 한다.

document.getElementByClassName을 사용해서 가져옴

  • Vue 컴포넌트 외부에서 DOM 요소를 선택하는 방법.
  • Vue의 주요 강점 중 하나는 컴포넌트 단위로 독립성을 유지하고 DOM 조작을 추상화하는 것이므로 Vue의 철학에 어긋날 수 있음.
<template>
  <textarea id="textarea" />
</template>

<script lang="ts">
export default {
	setup() {
		const el = ref<HTMLTextareaElement | null>(null)
		onMounted(() => {
			el.value = document.getElementById('textarea')
		})
	}
}
</script>

vue의 ref 속성을 통해 가져옴

  • Vue 컴포넌트 내부에서 DOM 요소를 관리할 수 있도록 요소를 추상화하여 참조하는 방식
  • Vue의 데이터 바인딩이나 반응성을 직접 DOM 요소와 연계해서 사용해야 하는 경우 유용하다. 예를 들어 컴포넌트가 mounted 되자마자 focus를 해야 하는 상황 등등.
<template>
  <textarea ref="el" />
</template>

<script lang="ts">
export default {
	setup() {
		const el = ref<HTMLTextareaElement | null>(null)
		onMounted(() => {
			el.value?.focus()
		})

		return {
			el
		}
	}
}
</script>

scroll 이벤트 핸들러에서 event.target 속성을 통해 가져옴

  • 스크롤 이벤트가 발생할 때 이를 핸들링하는 과정에서 DOM 요소에 접근하는 방식
  • 스크롤 이벤트를 핸들링하는 로직을 직접 처리해야 할 때 유용하다.
<template>
  <textarea @scroll="handleScroll" />
</template>

<script lang="ts">
export default {
	setup() {
		const handleScroll = (e: Event) => {
			const el = e.target as HTMLTextareaElement
		}

		return {
			handleScroll
		}
	}
}
</script>

결론

DOM 요소에 접근하는 위의 3가지 방법 중 가장 Vue스러운 방법은 두 번째, Vue ref를 사용하는 방식일 것이다. 하지만 ref 방식은 이벤트 핸들링과 상관 없이 Vue 컴포넌트 내에서 해당 DOM 요소를 직접 다루어야 하거나, vue의 데이터 바인딩과 DOM 요소를 서로 연계해야 하는 상황에 적합할 것으로 보인다.

지금 우리는 스크롤 이벤트가 발생했을 때 얼만큼 스크롤이 일어났는지를 알고 싶다. 그 말은 스크롤 이벤트 핸들링 과정에서 DOM 요소에 접근해야 한다는 말이다. 그렇다면 굳이 Vue ref를 사용하지 않더라도 scroll 이벤트 핸들러의 event.target을 통해서 DOM 요소에 접근하는 방식이 더 자연스러워 보인다. 따라서 세 번째 방식을 적용하기로 하자.


스크롤 관련 style

element.scrollTop

요소의 콘텐츠가 수직으로 스크롤되어 내려온 픽셀 수를 이야기한다. 요소의 상단에서부터 스크롤 화면 맨 상단까지의 거리가 scrollTop 속성이다. 만약 요소가 루트 html 요소라면 scrollTop 속성의 값은 scrollY 값과 동일하다.

element.scrollHeight

스크롤 가능한 요소의 전체 높이를 이야기한다. 스크롤 화면에 보이는 부분과 화면 바깥에 보이지 않는 부분까지 합산한 총 높이이다.

element.clientHeight

padding까지 포함한, 해당 요소의 컨텐츠가 차지하는 높이. border 너비나 스크롤 너비는 포함하지 않는다.

element.offsetHeight

해당 요소 자체가 화면에서 차지하는 높이. border 너비와 스크롤 너비까지 포함한 높이를 의미한다.


맨 밑까지 스크롤 되었는지 감지하기

요소를 스크롤했을 때, 요소 맨 상단에서부터 스크롤하여 내려온 거리와 스크롤 화면의 높이의 합이 요소의 전체 높이와 같거나 큰지를 검사하면 된다.

  • 요소의 전체 높이는 element.scrollHeight
  • 요소 상단에서부터 스크롤 화면 상단까지의 거리는 element.scrollTop
  • 스크롤 화면의 높이는 element.clientHeight
    • offsetHeight는 전체 화면에서 해당 요소가 차지하는 높이이다. 하지만 우리가 알고 싶은 것은 요소 안의 스크롤 화면, 즉 요소의 컨텐츠가 차지하는 높이만을 알면 되므로 clientHeight가 적합하다.

따라서 scrollHeight <= scrollTop + clientHeight이 참이면 사용자가 해당 문서를 끝까지 읽었다는 것을 보장할 수 있다. 만약 끝까지 스크롤을 내렸다는 것을 확인했다면 버튼을 활성화시키도록 하자!

<template>
	<div>
	  <textarea @scroll="handleScroll" />
		<button type="button" :disabled="!isButtonAvailable">
			계약서 동의
		</button>
	</div>
</template>

<script lang="ts">
export default {
	setup() {
		const isButtonAvailable = ref(false)

		const handleScroll = (e: Event) => {
			const el = e.target as HTMLTextareaElement
			const isScrolledToBottom 
       		  = el.scrollHeight <= el.scrollTop + el.clientHeight

			if (isScrolledToBottom) {
				console.log('끝까지 스크롤해서 내용을 확인했습니다.')
				isButtonAvailable.value = true
			}
		}

		return {
			handleScroll
		}
	}
}
</script>


추가) 본문이 짧아 스크롤이 필요없는 경우

작업을 진행하다 보니, scroll 이벤트 핸들러를 사용할 수 없는 한 가지 예외 케이스가 있었다! 본문의 길이가 짧아서 스크롤을 할 수 없는 경우가 있는데, 이런 상황에서는 스크롤 이벤트가 감지되지 못하므로 스크롤 이벤트 핸들러를 통해 본문을 읽었는지를 검사할 수 없다. 따라서 이런 경우에는 맨 처음 페이지가 렌더링되었을 때 “읽음” 플래그를 true로 변경하는 추가 작업이 필요하다.

스크롤 이벤트가 발생되지 않으므로 textarea 요소를 가지고 오는 다른 방법을 선택해야 한다. 위에서 살펴본 대로 getElementByIdgetElementsByClassName과 같은 순수한 자바스크립트를 사용하는 것보다 vue에서 제공하는 ref를 적용하여 textarea 요소에 접근하자.

DOM 요소에 대한 작업은 DOM이 마운트된 이후부터 가능하니 다음과 같이 vue의 mounted 훅에서 작업을 진행한다. scrollEl의 초기값을 null로 지정한 이유도 이와 같다. DOM 트리가 생성되고 마운트되기 전에는 해당 요소에 접근할 수 없기 때문이다.

<template>
	<div>
    <textarea 
			ref="scrollEl" 
			@scroll="handleScroll" 
		/>
		<button type="button" :disabled="!isButtonAvailable">
			계약서 동의
		</button>
	</div>
</template>

<script lang="ts">
export default {
	setup() {
		const isButtonAvailable = ref(false)

	  const scrollEl = ref<HTMLTextAreaElement | null>(null)
	  onMounted(() => {
	    if (scrollEl.value === null) return
	
	    const isScrollNeeded =
	      scrollEl.value.scrollHeight === scrollEl.value.clientHeight
	
	    if (isScrollNeeded) isButtonAvailable.value = true
	  })

		const handleScroll = (e: Event) => {
			//...
		}
	
		return {
			handleScroll
		}
	}
}
</script>

본문이 짧아 스크롤이 필요 없는 상태라면 scrollHeightclientHeight이 동일한 값을 가질 것이므로, 이를 사용해서 해당 textarea의 버튼을 초기에 활성화시킬지 여부를 결정하면 된다.


추가) 페이지의 확대/축소 비율에 따라 scrollTop이 달라지는 이슈

scrollTop 속성의 값이 웹페이지 화면의 확대/축소 비율에 따라 달라지는 이슈가 있었다. 밑은 scrollTop 속성의 출력값을 웹페이지 화면의 확대 축소 비율에 따라 정리한 것이다.

// 125% 
el.scrollHeight 1068 el.scrollTop 669.5999755859375 el.clientHeight 398

// 110% 
el.scrollHeight 1068 el.scrollTop 670 el.clientHeight 398

// 100% 
el.scrollHeight 1068 el.scrollTop 670 el.clientHeight 398

// 90%
el.scrollHeight 1068 el.scrollTop 668.888916015625 el.clientHeight 399

// 80%
el.scrollHeight 1068 el.scrollTop 669.375 el.clientHeight 399

// 70%
el.scrollHeight 1068 el.scrollTop 669.3333129882812 el.clientHeight 399

그러다 보니 화면 비율을 100%으로 했을 때는 스크롤을 맨 밑까지 했을 때를 제대로 감지했지만 90%나 120%일 때는 끝까지 내려도 이를 캐치하지 못했다. 그 이유를 정확하게는 파악하지 못했으나, clientHeight 역시 약간이지만 변화하는 것을 보면 화면 비율의 변화가 요소 크기에 미세하게 영향을 주는 것으로 보인다.

실제로 MDN에서는 다음과 같이 화면의 크기가 확대 혹은 축소되었을 때 scrollTop 속성값이 소숫점으로 나올 가능성이 있다고 이야기한다.

scrollTop 속성을 대체할 수 있는 다른 방법이 있다면 그 방법을 사용하는 것이 좋을 것 같으나, 다음과 같이 값들의 오차를 조금만 보정해주어도 효과가 있을 것 같다.

const isScrolledToBottom 
  = el.scrollHeight - (el.scrollTop + el.clientHeight) <= 1

scrollHeightscrollTop + clientHeight의 차가 1 픽셀을 넘지 않을 경우 끝까지 스크롤이 되었다고 판단하는 것이다. 판단 조건이 너무 빡빡하다고 생각이 들면 조금 넉넉하게 두어도 괜찮을 것으로 보인다.

혹시나 계산값이 음수로 나올 가능성까지 고려하여 절댓값을 매겨 주자.

const isScrolledToBottom 
  = Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) <= 1

지금은 이렇게 예외처리를 통해 스크롤을 검사하지만, 화면 크기 변화와 같은 외부적인 요인에 따라 값이 변하는 속성 외에 다른 방법이 있을지 다시 한 번 더 확인해볼 필요가 있다.

0개의 댓글