본문 바로가기

개발/React

useTouchNavigation 개발기

반응형

 

일단 너무 오랜만에 글을 올리게 되어 상당히 어색하지만, 계속해서 개발을 열심히 하고있고 개인적으로 노션에만 아카이빙하던 것들을 앞으로는 다시 공개적으로 아카이빙하면서 부족한점을 채워가야겠다는 스탠스로 방향을 정했습니다. (_ _)

 

만들게 된 계기

 

현재 현업에서는 속도감있는 서비스제공을 위해 메인 앱 내에서 웹뷰로 일부 피처 서비스를 제공하고있고, 이러한 웹뷰에서는 본앱과 유사한 경험을 주는 유저에게 제공하는 것이 주요한 목적 중 하나였습니다.

따라서 유저가 많은 컨텐츠를 접하고 이를 유연하게 이동하기 위해 우측 고정 네비게이션 바가 필요했고, 이러한 요구사항을 구현함에 있어서 custom hook으로 headless하게 기능을 제공할 수 있겠다는 생각에 작업하게 되었습니다.

 

기능적 한계

 

  1. 현 버전에서는 특정 상황에서만 사용이 가능합니다. (수직스크롤을 진행하는 상황에서 요소마다 앵커링을 걸어주어야할때만 사용이 가능합니다.)
  2. 앵커링이 걸려야하는 요소와 네비게이션에 나열되는 요소는 하드코딩으로 그려지지 않고 동적으로 그려지는 것을 기준으로 생각하였고, 두 요소의 개수가 동일할때 사용 가능합니다. (일반적인 상황에서는 동일할 것으로 보입니다.)
  3. 웹뷰 환경에서 터치 이벤트를 사용하는 상황을 가정하여 유저의 사용성을 고려하였습니다. (이후 상세설명 예정)

 

코드

import { useEffect, useCallback, useRef, useState } from 'react'

/**
 * @params navigationTargetDataList - navigate할 대상이 담긴 데이터를 넣어줍니다.
 * @params elementHeight - navigation에 존재하는 anchor 엘리먼트의 높이를 넣어줍니다.
 * @params elementSpace - navigation에 존재하는 anchor 엘리먼트 사이의 간격을 넣어줍니다.
 *
 * @returns scrollLockStatus - scrollLock 상태를 반환
 * @returns moveTargetRef - navigation을 누를 시 타겟으로 이동합니다.
 * @returns anchorListRef - 앵커링이 걸리는 대상 리스트 Ref
 * @returns navigationRef - navigation 역할을 하는 대상 Ref
 * @returns navigationFirstElementRef - navigation의 첫번째 element
 */

export default function useTouchNavigation({
  navigationTargetDataList,
  elementHeight,
  elementSpace,
}: {
  navigationTargetDataList?: any[]
  elementHeight: number
  elementSpace: number
}) {
  const [isScrollLock, setIsScrollLock] = useState(false)

  const navigationRef = useRef<HTMLDivElement>(null)
  const navigationFirstElementRef = useRef<HTMLDivElement>(null)

  const anchorListRef = useRef<HTMLDivElement[]>([])
  const anchorPositionList = useRef<number[]>([])

  const setRefOriginPosition = useCallback(() => {
    const anchorList = anchorListRef.current
    if (!anchorList) return

    window.scroll(0, 0)
    anchorPositionList.current = anchorList.map((anchor) => {
      return anchor.offsetTop
    })
  }, [anchorListRef])

  const moveTargetRef = (index: number) => {
    const targetOffsetTop = anchorPositionList.current[index]
    window.scrollTo(0, targetOffsetTop)
  }

  const handleTouchStart = useCallback(() => {
    setIsScrollLock(true)
  }, [])

  const handleTouchMove = useCallback(
    (event: TouchEvent) => {
      const firstRef = navigationFirstElementRef?.current?.getBoundingClientRect().top

      if (!firstRef || !navigationTargetDataList) return

      const BREAK_POINT = navigationTargetDataList.map((_, index) => {
        return firstRef + (elementHeight + elementSpace) * index
      })

      const TOUCHED_Y_POSITION = event.changedTouches[0].clientY

      const isUnderBreakPointRange = TOUCHED_Y_POSITION < BREAK_POINT[0]
      
      if (isUnderBreakPointRange) return

      const targetBreakPoint = BREAK_POINT.findIndex((point) => point > TOUCHED_Y_POSITION)
      const isOverBreakPointRange = targetBreakPoint <= 0

      if (targetBreakPoint === 1) {
        window.scrollTo(0, 0)
        return
      }

      if (isOverBreakPointRange) {
        window.scrollTo(0, anchorPositionList.current[BREAK_POINT.length - 1])
        return
      }

      window.scrollTo(0, anchorPositionList.current[targetBreakPoint - 1])
    },
    [navigationTargetDataList]
  )

  const handleTouchEnd = useCallback(() => {
    setIsScrollLock(false)
  }, [])

  useEffect(() => {
    setRefOriginPosition()
  }, [navigationTargetDataList])

  useEffect(() => {
    if (!navigationRef.current) return

    const navigation = navigationRef.current

    navigation.addEventListener('touchstart', handleTouchStart)
    navigation.addEventListener('touchmove', handleTouchMove)
    navigation.addEventListener('touchend', handleTouchEnd)

    return () => {
      navigation.removeEventListener('touchstart', handleTouchStart)
      navigation.removeEventListener('touchmove', handleTouchMove)
      navigation.removeEventListener('touchend', handleTouchEnd)
    }
  }, [])

  return {
    scrollLockStatus: isScrollLock,
    moveTargetRef,
    anchorListRef,
    navigationRef,
    navigationFirstElementRef,
  }
}

 

필요한 요소 및 반환값 설명

 

인자로 받아오는 값은 3가지입니다.

  1. navigationTargetDataList - navigate할 대상이 담긴 데이터를 넣어줍니다.
  2. elementHeight - navigation에 존재하는 anchor 엘리먼트의 높이를 넣어줍니다.
  3. elementSpace - navigation에 존재하는 anchor 엘리먼트 사이의 간격을 넣어줍니다.

 

여기서 2,3번이 왜 필요한지에 대해서는 이후 실제 코드를 설명할 때 같이 언급하겠습니다.

 

반환해주는 값은 5가지입니다.

  1. scrollLockStatus - scrollLock 상태를 반환
  2. moveTargetRef - navigation을 누를 시 타겟으로 이동합니다.
  3. anchorListRef - 앵커링이 걸리는 대상 리스트 Ref
  4. navigationRef - navigation 역할을 하는 대상 Ref
  5. navigationFirstElementRef - navigation의 첫번째 element

 

위의 반환값에 대해서 하나씩 설명해보자면,

 

첫번째 scrollLockStatus는 touchStart시에 true, touchEnd 시에 false를 반환하는 값입니다.

 

이 값이 필요한 목적으로는 처음 언급한 모바일 유저의 사용성을 고려한 점에서 기인하는데, 일반적으로 모바일 유저가 터치를 통한 스크롤 이벤트를 발생시킬때에는 이에 대한 이벤트버블링으로 화면까지 같이 내려가게됩니다. 우리가 구현하고자 하는 것은 네비게이션바에서 일어나는 스크롤을 진행할 때에는 스크롤에 대한 이벤트가 전부 네비게이션바를 통해 일어나는것을 원하기때문에, 현재 버전에서는 사용처에서 이 상태값을 활용하여 직접 scrollLock을 on/off하는것을 권장합니다.

이에 대한 처리 역시 해당 훅에서 하는것을 주요 develop 사항 중 한가지로 두고있습니다.

 

두번째 moveTargetRef는 네비게이션 바 내 해당 요소에 매핑된 앵커로 스크롤이 이동할 수 있도록 하는 이벤트입니다.

 

3~4번째 반환값은 특별하지 않기때문에 설명은 생략하고, 5번째 반환값은 이후 코드라인을 하나씩 살펴보며 왜 필요한지 말씀드리겠습니다.

 

 

코드 라인 살펴보기

 

주요하게 살펴볼 부분은 두가지 정도로 보입니다.

 

1. 앵커링이 걸릴 위치의 리스트를 구합니다.

 

const setRefOriginPosition = useCallback(() => {
	const anchorList = anchorListRef.current
	if (!anchorList) return

	window.scroll(0, 0)
	anchorPositionList.current = anchorList.map((anchor) => {
		return anchor.offsetTop
	})
}, [anchorListRef])

useEffect(() => {
	setRefOriginPosition()
}, [navigationTargetDataList])

 

 

인자로 받아온 Datalist가 업데이트된다면 해당 함수를 다시한번 실행하도록 useEffect를 활용합니다.

Return값으로 반환해준 anchorList가 연결이 안되었다면 함수를 실행하지 않도록하고, 앵커링이 걸려야하는 Element의 리스트들을 받아서, 해당 값들의 offsetTop값을 구해 저장해둡니다.

이를 통해 추후 원하는 Element의 요소로 이동할 수 있도록 합니다.

 

이때 window.scroll(0, 0) 을 해주는 이유는 스크롤이 내려가있는 상태에서 새로고침을 하는 경우에는 브라우저의 scroll restoration으로 인해 스크롤이 유지가 되고, 특정 UI적 요소인 sticky속성이 들어가있는 anchor의 경우에는 초기화된 position이 아닌 스크롤링되어있는 position으로 기억되기때문에, 값을 계산하기 전에 최상단으로 스크롤해주는 로직을 통해 정상적으로 position을 기억할 수 있도록 초기화해줍니다.

 

여기서 앵커링이 걸릴 position리스트가 필요한 이유는 아래에서 바로 설명하겠습니다.

 

 

2. TouchMove 이벤트 발생 시 해당하는 Element에 매핑된 요소로 스크롤을 이동시켜줍니다.

 

 

const handleTouchMove = useCallback(
    (event: TouchEvent) => {
      const firstRef = navigationFirstElementRef?.current?.getBoundingClientRect().top

      if (!firstRef || !navigationTargetDataList) return

      const BREAK_POINT = navigationTargetDataList.map((_, index) => {
        return firstRef + (elementHeight + elementSpace) * index
      })

      const TOUCHED_Y_POSITION = event.changedTouches[0].clientY

      const isUnderBreakPointRange = TOUCHED_Y_POSITION < BREAK_POINT[0]
      
      if (isUnderBreakPointRange) return

      const targetBreakPoint = BREAK_POINT.findIndex((point) => point > TOUCHED_Y_POSITION)

      const isOverBreakPointRange = targetBreakPoint <= 0

      if (targetBreakPoint === 1) {
        window.scrollTo(0, 0)
        return
      }

      if (isOverBreakPointRange) {
        window.scrollTo(0, anchorPositionList.current[BREAK_POINT.length - 1])
        return
      }

      window.scrollTo(0, anchorPositionList.current[targetBreakPoint - 1])
    },
    [navigationTargetDataList]
  )

 

여기서는 코드라인을 하나씩 살펴보겠습니다.

 

const firstRef = navigationFirstElementRef?.current?.getBoundingClientRect().top

 

먼저 위에서 추후 언급하겠다고 했던 네비게이션바의 첫번째 Element가 왜 필요한지 짚어보고 가겠습니다.

 

기본적으로 touchmove 이벤트로부터 받아오는 event의 target은 최초 touch가 Start된 엘리먼트에서 변하지 않습니다.

ex) Navigation Bar에 첫번째 Element 'A'에서 'B' 로 이동 시, 실제 e.target은 'A' 에서 변하지 않습니다.

 

따라서 터치 후 스크롤링하는동안 ㄱ → ㄴ 으로의 이동을 감지하기 위해 선택한 방법은 아래와 같습니다.

 

  1. 첫번째 Element를 Ref로 기억
  2. 해당 Element의 position을 기반으로 인자로 받아온 Navigation Bar 내의 개별 Element HeightElement별 간격을 활용하여 Element별 간격을 계산합니다. ( 인자로 받아온 데이터의 수만큼 순회합니다. )
  3. touch event에서 받아온 event객체에 있는 changedTouches를 활용하여 현재 이동한 위치에서의 client Y값을 받아옵니다.
  4. 받아온 client Y값과 첫번째 Element Ref로부터 계산한 Navigation Bar 내의 Element 위치들을 비교하여 해당 Element가 가지는 영역 내에서는 해당 Element에 매칭된 Anchor로 focus를 이동해줍니다.

 

추가적으로 위의 방법을 선택한데에는 한가지 이유가 더 있습니다.

event객체의 changedTouches를 활용하여 현재 이동한 위치의 Position을 구하고, 이를 document.elementFromPoint 라는 옵션을 활용하면 해당하는 position에 존재하는 Element를 구할 수 있지만, 문제는 실제 유저의 행동패턴에 있었습니다.

 

유저 입장에서 우측 네비게이션 바를 실제로 핸들링한다면, 정직하게 위에서 아래로 해당 네비게이션 바를 내리지 않습니다. 한손으로 휴대폰을 가지고 사용한다면 보통은 아래 이미지의 화살표 방향처럼 아치형 혹은 대각선을 그리며 스와이프 하게될것입니다.

그렇다면 위의 document.elementFromPoint 로부터 Element를 받아온다면, 스와이프 시에는 우측 네비게이션 바에 존재하는 Element가 아닌 컨텐츠 영역에 있는 Element가 타겟으로 잡힐것입니다.

 

따라서 위에서 말씀드린 바와 같이 미리 계산된 offsetTop값을 바탕으로 touch move 이벤트가 시작된다면 기존에 기억한 Navigation Element 별 position과 touch move event에서 반환해주는 client Y값을 비교하여 앵커링을 걸어주게 되었습니다.

 

 

const BREAK_POINT = navigationTargetDataList.map((_, index) => {
	return firstRef + (elementHeight + elementSpace) * index
})

const TOUCHED_Y_POSITION = event.changedTouches[0].clientY

 

이후 인자로 받아온 DataList로부터 네비게이션 바 내에서 해당 엘리먼트가 차지할 영역을 계산해줍니다.

기본적으로 요소가 가지고있는 Height와 요소 사이의 간격을 고려하여 'A' Element의 Y값에서 'B' Element Y값으로 손가락이 움직이기 전까지는 'A' Element가 포커스될 수 있도록 각 BreakPoint를 구합니다.

 

TouchMove 이벤트가 일어나는 순간에 position은 TouchEvent의 changedTouches값으로부터 받아옵니다.

 

 

const isUnderBreakPointRange = TOUCHED_Y_POSITION < BREAK_POINT[0]

if (isUnderBreakPointRange) return

 

그 다음 네비게이션 바의 Range보다 낮은 값, 즉 touch move이벤트가 진행중이면서 네비게이션바보다 더 위의 영역이 터치되는 이벤트가 발생한다면, move이벤트의 순서에 따라 이미 이전 영역에서 index 0인 앵커로 이동해있을것이기때문에 이후에는 유효하지 않은 이벤트로 간주하여 return합니다.

 

 

const targetBreakPoint = BREAK_POINT.findIndex((point) => point > TOUCHED_Y_POSITION)

const isOverBreakPointRange = targetBreakPoint <= 0

if (targetBreakPoint === 1) {
	window.scrollTo(0, 0)
	return
}

if (isOverBreakPointRange) {
	window.scrollTo(0, anchorPositionList.current[BREAK_POINT.length - 1])
	return
}

window.scrollTo(0, anchorPositionList.current[targetBreakPoint - 1])

 

이제 예외케이스에 대한 처리는 완료되었기때문에 현재 터치된 영역이 어느 BreakPoint에 속하는지를 구하고, 터치된 영역이 BreakPoint를 넘어선 즉, 네비게이션바의 영역보다 아래의 영역에서 touch move 이벤트가 일어난다면, targetBreakPoint는 -1이 나올것이기때문에, 이때는 BreakPoint의 마지막 앵커 위치로 이동시켜주고, 이외의 케이스에서는 알맞게 스크롤을 이동시켜줍니다.

 

 

실행 영상

 

 

 

 

결론

작업하면서 웹으로 제공하는 지면임에도 불구하고 App Like한 경험을 위해 실제 모바일 디바이스를 사용하는 유저의 행동 패턴에 맞게 이벤트를 설계하는 것이 가장 큰 고려사항이었고, 일반적으로 웹에서 제공하는 방식보다는 다른 방향으로 고려할게 더 많았던 점에서 배울게 많았던 프로젝트였습니다.

현재는 한정적인 케이스에 대해서만 커버가 가능하기때문에 조금 아쉬운점은 있지만, 계속해서 기능 확장과 테스트코드를 통해 더 안전한 로직으로 디벨롭하는 것이 목표입니다.

 

 

(전반적으로 계속해서 글을 다듬고 보완중입니다. 추가적으로 이미지를 통한 자세한 설명도 추가할 예정입니다. 부족한 점이 있더라도 양해 부탁드립니다. (_ _) )

반응형