본문 바로가기

Etc

모바일 웹에서 키보드에 가려지는 input 스크롤 이슈 해결기 (iOS & Android)

728x90

모바일 웹을 개발하다 보면 생각보다 자주 마주치는 문제가 있다.
바로 input포커스를 주었을 키보드가 올라오면서 입력창이나 유효성 메시지가 가려지는 현상이다.


처음엔 scrollIntoView() 줄이면 간단히 해결될 알았지만,
기기마다 다르게 동작하는 브라우저 환경, iOSAndroid뷰포트 처리 차이,
내부 스크롤/외부 스크롤 문제 예상치 못한 상황들이 연이어 발생했다.

 

글은 과정에서 겪은 시행착오와 최종적으로 어떻게 해결했는지를
실제 코드와 함께, 하나씩 천천히 분석한 기록이다.

 


 

1. 문제 상황

1) 유효성 체크 문구가 잘린 상태로 노출됨

2) 전체 문구가 보이도록 input 포커스 시 자동 스크롤되도록 수정

3) 자동으로 올라오는 키보드로 인해 Input 자체가 가려져서 노출됨 

 

 

2. 해결방법

1) 단순 scrollIntoView()

useEffect(() => {
  if (isFocused) {
    inputLineElement.current?.scrollIntoView()
  }
}, [isFocused])

 

처음 해당 오류가 발생 했을 땐,

해당 input을 ref로 잡은 다음, 포커스 되었을 때 scrollintoView()를 줘서 자동으로 스크롤되도록 수정했다.

 

그러나 자동 스크롤은 되지만, 포커스 시 자동으로 올라오는 키보드로 인해 input 자체가 가려지게 된다.

또한, 포커스만으로는 화면이 가려지는지 직접적으로 체크하기가 어려웠다. 

 

 

* scrollintoView()

element.scrollIntoView();
element.scrollIntoView(alignToTop); // Boolean parameter
element.scrollIntoView(scrollIntoViewOptions); // Object 

ex) 
element.scrollIntoView(true); // 조상 요소의 보이는 영역 상단에 정렬
element.scrollIntoView(false); // 조상 요소의 보이는 영역 하단에 정렬
element.scrollIntoView({ block: "end" });
element.scrollIntoView({ behavior: "smooth", block: "end", inline: "nearest" });

 

scrollintoView가 호출 된 요소가 사용자에게 표시되도록 요소의 상위 컨테이너를 스크롤한다.

매개변수로는 boolean or object가 들어간다. 

 

 

 

2) viewport 를 기반으로 한 스크롤 계산

useEffect(() => {
  if (!isFocused || !window.visualViewport) return

  const inputY = inputLineElement.current!.getBoundingClientRect()
  const viewportHeight = window.visualViewport.height

  if (inputY.bottom > viewportHeight) {
    const currentScrollY = window.visualViewport.pageTop
    const elementTop = inputY.top + currentScrollY
    const targetScrollY = elementTop - viewportHeight * 0.3

    window.scrollTo({
      top: targetScrollY,
      behavior: 'smooth',
    })
  }
}, [isFocused])

 

두 번째 방법으로 쓴 게 수동으로 위치를 계산하는 것이다. 

 

 

if (!isFocused || !window.visualViewport) return

 

우선 포커스되지 않았거나 visualViewport를 지원하지 않으면 그대로 return 시켰다.

 

 

const inputY = inputLineElement.current!.getBoundingClientRect()
const viewportHeight = window.visualViewport.height

 

그 후, getBoundingClientRect 로 Input의 현재 위치를 가져오고

visualViewport 로 화면의 height를 가져왔다.

 

처음에는 innerHeight를 통해 높이를 가져오려고 했었다.

그러나 ios와 aos의 높이 측정 방식이 다르다는 것을 알게 되었다.

 

IOS는 innerHeight 로 높이를 측정할 때, 키패드 유무와 상관없이 항상 화면 전체의 높이 값을 가진다.

반면, AOS는 innterHeight 로 높이를 측정할 때, 키패드를 제외한 영역을 측정한다.

 

IOS는 키패드 유무와 상관없이 전체가 측정되기 때문에 키패드 유무를 판단하는 데 사용할 수 없다고 생각해서

visualViewport를 사용하게 되었다.

 

 

* getBoundingClientRect

const rect = myInput.getBoundingClientRect()

// input 요소의 뷰포트 기준 위쪽 거리
// 만일 해당 값이 100이라면, 해당 요소가 화면 상단에서 100px 아래에 있다는 뜻 
console.log(rect.top)

 

 

해당 요소가 브라우저 뷰포트 내에서 어떤 위치에 있는지 알려주는 메서드이다.

반환값은 DOMRect 객체이며 top, bottom, left, right, width, height 정보를 담고 있다. 

 

 

뷰포트 = 사용자가 실제로 보고 있는 영역

 

 

* visualViewport

console.log(window.visualViewport.height)

 

현재 사용자가 실제로 보고 있는 화면의 영역에 대한 정보를 담고 있는 객체이다.

height, pageTop 등 정보를 담고 있다. 

 

 

if (inputY.bottom > viewportHeight) {
    const currentScrollY = window.visualViewport.pageTop
    const elementTop = inputY.top + currentScrollY
    const targetScrollY = elementTop - viewportHeight * 0.3

    window.scrollTo({
      top: targetScrollY,
      behavior: 'smooth',
    })
  }

 

input의 bottom 값이 viewport height보다 크다면, input이 보이지 않는 곳에 있다는 뜻이므로

scrollTo를 이용해 자동으로 스크롤이 이동되도록 만들었다.

 

그러나 위 방법에서도 문제가 발생했다. 

 

① 포커스 되지 않은 input을 기준으로 스크롤됨

② 스크롤 위치가 이상함

 

useEffect를 통해서 포커스 됐을 때 무조건 실행되도록 해서 만든 게 이유인 거 같았다.

그래서 위 문제를 방지하기 위해 "input이 가려졌을 때"만 함수가 실행되도록 수정했다.

 

 

 

3) 조건부 scrollIntoView

const handleResize = () => {
  const inputPosition = inputLineElement.current!.getBoundingClientRect()
  if (visualViewport?.height! < inputPosition.bottom) {
    inputLineElement.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
  }
}

useEffect(() => {
  window.visualViewport?.addEventListener('resize', handleResize)
  return () => {
    window.visualViewport?.removeEventListener('resize', handleResize)
  }
}, [])

 

이번엔 useEffect를 통해 visualViewport.resize 이벤트가 일어났을 때에만 함수가 실행되도록 했다.

 

window.visualViewport는 브라우저에서 실제로 보이는 시각적 화면 영역에 대한 정보를 담은 객체이다.

키보드가 올라오면 시각적 영역이 줄어들 테니, 해당 영역이 resize 하면 이벤트가 실행되도록 만들었다. 

그리고 scrollIntoView의 옵션을 통해 input의 가운데로 스크롤되도록 수정했다. 

 

이렇게 수정하니 아까 발생한 문제들은 다 해결되었지만, 버벅거림이 발생하기 시작했다..

아마 resize가 너무 자주 호출돼서 발생한 오류인 거 같았다.

 

 

 

4) debounce

const handleResize = debounce(() => {
  const inputPosition = inputLineElement.current!.getBoundingClientRect()
  if (visualViewport?.height! < inputPosition.bottom) {
    inputLineElement.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
  }
}, 300)

 

debounce를 이용해 한 번만 호출되도록 수정해 줬다.

버벅거림이 수정되었다.

 

드디어 끝난 줄 알았으나,, 아래 인풋 입력하고 위 인풋에 포커스 하면,

스크롤이 아래 인풋에 맞게 올라가는 오류가 발생했다.

 

 

* debounce

 

짧은 시간 동안 반복해서 호출되는 함수들을 한 번만 실행하게 만드는 기술.

마지막 호출 이후 일정 시간 동안 추가 호출이 없으면, 그때 딱 한 번만 실행되도록 한다.

 

 

 

5) 최종 코드

const handleResize = debounce(() => {
	// 포커스 되어 있는 input만 적용
	if (document.activeEelement !== inputLineElement.current) {
		return
	}
	
	const inputPosition = inputLineElement.current!.getBoundingClientRect()
  if (visualViewport?.height! < inputPosition.bottom!) {
            inputLineElement.current?.scrollIntoView({behavior: 'smooth', block: 'center'})
        }
    }

}, 300)

 

 

포커스 되어 있는 input에서만 동작하도록 document.activeElement 를 추가했다.

현재 활성화되어 있는 요소가 inputElement와 동일할 경우에만 스크롤이 진행되도록 수정했다.

 

 

 

* document.activeElement

 

현재 활성화 상태인 요소를 반환하는 속성

 

 


 

모바일 웹에서 input 포커스 자동 스크롤 처리는 생각보다 복잡한 문제였다.
처음엔 scrollIntoView() 줄로 끝날 알았지만,
기기별 차이, 키보드 등장 타이밍, 뷰포트 처리 방식, 스크롤 컨테이너 다양한 예외 상황을 직접 마주해야 했다.

 

모바일 웹은 거의 처음이라 엄청 어려웠는데,

이렇게 한 번 정리했으니까 다음번에 같은 오류가 나면 쉽게 풀 수 있겠지..!!

 

 

728x90