개발자
류준열

모바일 사파리에서 키보드가 올라올때 생기는 가상영역 문제

모바일 사파리에서 키보드가 올라오면 화면 하단에 가상 영역이 생기고, 그 영역까지 스크롤되는 문제가 있었다.

  • 앱 안의 웹뷰에서 키보드 상단에 보이는 흰색 영역 (웹에서는 발생하지 않음)
  • 키보드가 올라온 뒤 실제 콘텐츠 높이보다 아래까지 스크롤 가능
  • 결과적으로 화면 하단에 의미 없는 빈 공간, 어색한 스크롤 경험

앱 안의 웹뷰에서 키보드 상단에 나타나는 흰 영역

위 부분은 웹에서는 나타나지 않는데 앱 안의 웹뷰에서는 키보드 상단에 흰색 영역이 있었는데 이는 웹브라우저에서는 보이지 않았다.

플러터 레벨에서 키보드가 올라올 때 레이아웃 조정 방식 때문에 생기는 이슈로 보이고 비슷한 사례가 스택오버플로우에도 작성되어 있다.

visual viewport와 layout viewport 비교하여 키보드 감지

먼저 '키보드가 올라온 상태'를 감지하여 스크롤을 강제로 위로 올리는 방식으로 접근했다.

  • layout viewport: 브라우저가 웹페이지를 그리는 뷰포트. 어떤 방식으로도 변경되지 않으며 사용자가 확대/축소를 해도 layout viewport는 동일하게 유지된다.
  • visual viewport: 사용자의 디바이스에 표시되는 화면의 영역, (ex: 키보드가 올라올 경우 visual viewport는 layout viewport보다 작아진다.)

처음에는 window.visualViewport와 layout viewport에 해당하는 window.innerHeight를 비교하여 visual viewport가 innerHeight보다 작은 경우를 키보드가 올라온 경우로 정의하고 스크롤을 다시 상단으로 올렸다.

if(window.visualViewport?.height < window.innerHeight){
  ...
	window.visualViewport.addEventListener('scroll', ()=>{
		window.scrollTo({
        top: 0,
        behavior: 'instant',
      })})
}

위 방식은 키보드 하단 영역 방지에는 어느 정도 효과가 있었지만 input이 초기 viewport 내에 있을 경우에만 유효한 해결방식이었다.

초기 viewport 밖에 있는 input에 포커스를 줄 때, 브라우저가 그 input이 보이도록 자연스럽게 스크롤 하려 하는데, 이 과정에서 scrollTo({ top: 0}) 이 실행되어 계속 되돌려 버리는 문제가 있었다.

이로 인해 폼 자체를 사용하기 힘들어졌다.

사파리 모바일에서의 부드러운 동작과 여백

이 문제를 해결하면서 사파리 모바일에서 키보드가 올라올때 하단 여백이 생기는 이유는 사파리 특유의 부드러움을 구현하기 위해 만든 여백이라는 추측을 했는데 스택오브플로우 대화에서도 사파리가 동적 화면 변경을 처리하는 방식에서 생기는 공간이라고 말하고 있었다.

이를 해결하기 위해서는 '스크롤 시에 키보드를 내리는 쪽이 낫다'는 조언이 많았다.

그래서 하단 여백을 억지로 없애려 하지 않고 스크롤이 시작되면 키보드를 숨겨서 문제 상황 자체를 없애는 방향으로 작업했다.

스크롤시에 키보드 숨기는 훅

인풋을 포커싱 하고 키보드가 올라왔을때 blur()를 호출해 키보드를 제거하는 useEffect를 만들었다.

 useEffect(() => {
    const container = formContentRef.current
    if (!container) return

    const handleScroll = () => {
      // 활성화된 input/textarea가 있으면 blur 처리
      const activeElement = document.activeElement
      if (activeElement instanceof HTMLInputElement) {
        activeElement.blur()
      }
    }

    container.addEventListener('scroll', handleScroll, { passive: true })

    return () => {
      container.removeEventListener('scroll', handleScroll)
    }
  }, [])

그런데 이는 인풋을 포커싱 하고 다른 영역을 터치하여 스크롤 하는 케이스가 고려되지 않아, input을 제외한 모든 영역에 대해서 스크롤시에 키보드를 제거했다.

  • 스크롤이 시작되면 blur()를 호출해 키보드를 내린다.
  • 인풋에서 시작한 터치는 그대로 둔다.
  • 인풋 외 실제 스크롤 의도가 느껴지면 blur를 호출한다. (실제 스크롤 의도: 최소 범위 이상의 스크롤 발생)

export default function useHideKeyboardOnScroll<T extends HTMLElement>(
  containerRef: MutableRefObject<T | null>,
) {
  useEffect(() => {
  	// iOS에서만 실행
    const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent)
    if (!isIOS) return
		
    const container = containerRef.current
    if (!container) return

    let touchStartY: number | null = null
    let touchStartTarget: EventTarget | null = null

    const blurActiveInput = () => {
      const activeElement = document.activeElement
      if (activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement) {
        activeElement.blur()
      }
    }

    // 스크롤 이벤트 핸들러 (스크롤 영역 내에서만 발생)
    const handleScroll = () => {
      blurActiveInput()
    }

    // 터치 이동 감지 (저장 버튼 등 스크롤 영역 밖에서도 작동)
    const handleTouchStart = (e: TouchEvent) => {
      touchStartY = e.touches[0]?.clientY ?? null
      touchStartTarget = e.target
    }

    const handleTouchMove = (e: TouchEvent) => {
      if (touchStartY === null || touchStartTarget === null) return

      // 터치가 input에서 시작되었으면 무시 (키보드 입력을 방해하지 않음)
      if (touchStartTarget instanceof HTMLInputElement) {
        return
      }

      const currentY = e.touches[0]?.clientY
      if (currentY === undefined) return

      // 일정 거리 이상 움직였을 때만 키보드 숨김 (의도하지 않은 작은 움직임 방지)
      const deltaY = Math.abs(touchStartY - currentY)
      if (deltaY > 10) {
        blurActiveInput()
        touchStartY = null // 한 번만 실행되도록
      }
    }

    const handleTouchEnd = () => {
      touchStartY = null
      touchStartTarget = null
    }

    container.addEventListener('scroll', handleScroll, { passive: true })
    window.addEventListener('touchstart', handleTouchStart, { passive: true })
    window.addEventListener('touchmove', handleTouchMove, { passive: true })
    window.addEventListener('touchend', handleTouchEnd, { passive: true })

    return () => {
      container.removeEventListener('scroll', handleScroll)
      window.removeEventListener('touchstart', handleTouchStart)
      window.removeEventListener('touchmove', handleTouchMove)
      window.removeEventListener('touchend', handleTouchEnd)
    }
  }, [containerRef])
}

결과

스크롤시 키보드를 제거하는 방식으로 키보드 하단영역 문제를 해결했다.

  • 인풋에 포커스를 줄 때는 기존처럼 키보드가 자연스럽게 올라온다.
  • 스크롤을 하면 키보드가 먼저 내려가고, 그 이후 평소처럼 스크롤이 동작한다.

즉, 가상 영역을 없애는 것이 아니라 키보드와 스크롤이 동시에 개입하는 상황을 피하는 쪽으로 우회했다.

참고자료