
스킨스쿨 일기장 어플이 2023년 9월에 출시되었어요.
Web App 으로 개발된 앱의 특성상 사용자의 편의를 위해 배포 전 많은 UX 고민과 QA 가 있었는데요.
그중 AOS, iOS 의 모바일 키보드 환경을 대응하기 위해 겪었던 경험과 실패 사례와 해결한 방법을 공유해보려고 해요.
Android 기기와 iOS 기기에서 input 태그를 누르면 키보드에 가려지는 현상이 있어요.
이런 경우 사용자가 직접 입력란으로 스크롤하고 키패드를 터치해야 하는 사용자에게 불필요한 액션이 추가가 되는 좋지 않은 경험을 제공할 수 있어요.
예시 이미지들을 볼까요?
[Android, iOS 15.7, iOS 16.6] 순서에요.

두 OS의 키보드 구조를 이해하기 위해 채널톡 - iOS15 대응기(feat. 크로스 브라우징) 를 참고했어요.
채널톡 문서를 통해 Android OS와 iOS의 키보드를 표시해 주는 구조가 서로 다름을 알게 되었는데요.
다만 자체적으로 테스트하였을 땐 스킨스쿨 일기장 어플에선 채널톡이 겪었던 문제처럼 iOS에서 영역이 불편할 정도로 가려지는 문제는 없었습니다.
저희는 위 첨부 이미지에서 보이는 것과 같이 모든 OS에서 document를 밀어 영역을 확보해 주고 정상적으로 스크롤이 가능하여 UI가 가려지는 문제는 발생하지 않았습니다.
다만 iOS 16.6에서 채널톡과 비슷한 현상을 보였는데요. iOS 16.6에서는 가장 하단으로 스크롤 했음에도 margin 영역이 조금만 보여 일부 콘텐츠가 가려질 우려가 있다 판단하였습니다.
( 스킨스쿨 앱에서 document를 밀어주는 현상은 document의 height를 100vh 또는 100dvh 로 고정하였기 때문인 것 같아요. )
앞서 말씀드린 것처럼 키보드가 올라올 때 document를 밀어주는 현상을 확인했었습니다.
충분한 자료 조사 없이 시작한 경우라 간단하게 생각하여 scrollIntoView 메서드를 이용하여 충분히 해결이 가능하다고 판단하였습니다.
function handleFocusElement() {
document.querySelector("#skin-description").scrollIntoView({ block: "end" });
}
document.addEventListener("focus", handleFocusElement, true);
위 코드를 작성하고 테스트하였지만, 정상적으로 스크롤되지 않았습니다. 그래서 Mobile 기기의 키보드가 Animation을 가지고 올라오는 시간을 생각해 1000ms의 Timeout을 설정해 보았습니다.
function handleFocusElement() {
setTimeout(() => {
document.querySelector("#skin-description").scrollIntoView({ block: "end" });
}, 1000);
}
document.addEventListener("focus", handleFocusElement, true);
코드를 실행해 보았을 때 스크롤은 정상적으로 작동하였는데요. 어색함이 많이 보입니다.

이런 경우 키보드가 올라오는 시간을 확인하고 ms를 맞추는 방법이 있긴 하지만, 실제로는 사용자마다의 기기의 사양이 모두 다르기 ms의 타이밍이 다르다 판단하여 적합한 방법이 아니기 때문에 이 방법은 부적합으로 채택되지 않았어요.
첫 실패 이후 많은 자료와 정보를 검색했습니다.
먼저 stack overflow에서 다른 사람들은 어떻게 대응하였는지 코드를 확인해 보고, Visual Viewport API 를 찾게 되었습니다.
이번 사례에서 채널톡 - iOS15 대응기(feat. 크로스 브라우징) 를 발견하였고, 채널톡 문서에서 많은 정보를 얻었습니다.
채널톡의 사례 역시 Visual Viewport API를 이용하고 있었고, 이 방법으로 문제 해결이 가능하다고 판단하고 진행했습니다.
먼저 이 문제를 개선하기 위해 목표를 설정하고 개발하기 시작했습니다.
두 목표에 맞게 코드를 작성했습니다.
먼저 모든 입력 태그에 사용 가능하게 하기 위해서 activeElement 라는 프로퍼티를 찾았고 채널톡 문서에 있던 코드를 조합해 보았습니다.
Visual Viewport가 resize 될 경우 함수를 호출하고, 이전 높이(prevHeight)와 현재 높이(currentHeight)를 비교하여 적절한 위치에 스크롤하도록 하는 코드입니다.
let prevHeight = 0;
function scrollToActiveElement() {
var elRect = document.activeElement.getBoundingClientRect();
var offset = elRect.bottom - (elRect.height / 2);
// Custom Scroll in SkinSchool App
scrollTo( document.querySelector("view"), offset , 100);
}
function triggerKeyboardOpen(currentHeight) {
// Thank you Channel.io :)
if (
prevHeight - 30 > currentHeight &&
prevHeight - 100 < currentHeight
) {
scrollToActiveElement();
}
prevHeight = currentHeight;
}
function handleVisualViewportResize(event) {
triggerKeyboardOpen(event.currentTarget.height);
}
visualViewport.addEventListener("resize", handleVisualViewportResize);
코드를 적용하여 테스트를 결과는 다음과 같습니다.

Android OS에서는 textarea에 스크롤이 완벽하게 이동되었습니다. 하지만 iOS에서는 덜덜 떨리는 현상과 함께 스크롤이 되지 않았어요.
이 현상은 iOS 15와 16 동일하게 나타나고 있었고, 이 방법 역시 실패라고 생각하고 다른 대안을 생각해 보기로 했습니다.
일주일간 다른 업무를 먼저 처리하고, 다시 돌아와 두 번째 과정을 참고하여 코드를 개선하는 시간을 가져보았습니다.
먼저 두 번째 과정에서의 목표 (1) 번 CSS를 건들지 말자는 이 과정에서 과감하게 포기하였습니다. 이 방법에서는 채널톡 문서의 [1. document가 키보드 뒤쪽으로 스크롤되는 문제] 의 CSS를 참고했어요.
#make-scrollable {
position: absolute;
left: 0;
width: 1px;
height: calc(100% + 1px); // height를 100%보다 1px높게 잡아 실제로 scroll이 되도록 만듭니다.
}
위 CSS 를 참고하여 먼저 body에 make-scrollale라는 id를 가진 div 태그를 생성하고, input 태그로 스크롤 시키도록 해보았습니다.
keyboard가 열리기 시작하면 body의 overflow 속성을 hidden 시켜 사용자가 빈 영역을 활동하는 것을 막아 입력에만 집중하도록 하게 하고, make-scrollale를 노출시켜 가상의 height를 만들어주어 Scroll 시키도록 제작하였습니다.
isFocus 를 구현하기 위해서 채널톡 문서의 [1. Android, iOS 키보드 활성화 여부] 를 참고하였습니다.
그리고 추가적으로 채널톡 코드를 이용하던 prevHeight, currentHeight 조건문을 제거하였습니다.
<app-container>
<view>...</view>
<!-- JS에서 추가 되는 객체 -->
<!-- <div id="make-scrollale"></div> -->
</app-container>
#make-scrollale {
display:none;
position: absolute;
left: 0;
width: 1px;
height: calc(100% + 1px);
}
let isFocus = false;
// 항목일 있을경우 코드 작동
if (document.querySelectorAll('input, textarea').length > 0) {
appendScrollElement();
// onResize visualViewport
visualViewport.addEventListener("resize", handleVisualViewportResize);
}
function handleVisualViewportResize(event) {
triggerKeyboardOpen(event.currentTarget.height);
}
// make-scrollale 객체 추가
function appendScrollElement() {
const scrollElement = document.createElement('div');
scrollElement.setAttribute("id", "make-scrollale");
document.querySelector("view").appendChild(scrollElement);
}
function onFocus() {
document.querySelector("#make-scrollale").style.display = "block";
document.querySelector("view").style.overflow = "hidden";
}
function onFocusOut() {
document.querySelector("#make-scrollale").style.display = "none";
document.querySelector("view").style.overflow = "";
}
function triggerKeyboardOpen() {
if (isFocus) {
onFocus();
// 키보드를 열어준 객체로 스크롤합니다.
scrollToActiveElement();
}
else {
onFocusOut();
}
}
function scrollToActiveElement() {
var viewScrollHeight = document.querySelector('view').scrollHeight;
var offset = viewScrollHeight - window.visualViewport.height ;
document.querySelector("view").scrollTo(0, offset);
}
이 코드를 적용시켜 보았을 땐 다음 이미지 처럼 변경되었습니다.

깔끔하진 않지만 모든 OS에서 대응에 성공하였습니다. 하지만 iOS 16에서 화면이 키보드의 레이아웃으로 채워졌다가 사라지는 현상이 보입니다.
가상의 큰 영역이 키보드 열림과 동시에 visible 되면 iOS에서 키보드 레이아웃에 버그가 발생하는 것으로 파악되었습니다.
이 현상을 해결하기 위해 make-scrollale의 display를 block/none으로 토글 시키지 않고 height의 수치를 바꾸어 조절하고, transition을 추가해서 display block/none처럼 작동되지 않게 처리했습니다.
function onFocus() {
document.querySelector("#make-scrollale").style.height = "calc(100% + 1px)";
document.querySelector("view").style.overflow = "hidden";
}
function onFocusOut() {
document.querySelector("#make-scrollale").style.height = "0";
document.querySelector("view").style.overflow = "";
}
#make-scrollale {
position: absolute;
left: 0;
width: 1px;
height: 0;
transition: height 0.5s linear;
}
결과는 다음 이미지와 같습니다.

완벽한 대응은 아니었지만 처음과 비교하면 많은 개선이 되었습니다.
iOS에서는 스크롤이 완벽하게 떨어지지 않고 위아래로 움직이는 모습을 보였지만, 브라우저 대응이 되었다는 점에 굉장히 만족하였습니다.
위 성공 사례에서 부족하게 대응된 내용을 개선하기로 했습니다.
먼저 입력 항목을 Header 밑으로 표시하기 위해서 scrollToActiveElement 함수의 개선이 있었습니다.
기존에는 Scroll 객체의 ScrollHeight에서 Visual Viewport의 Height 만큼 빼서 스크롤하였습니다.
하지만 이러한 경우 입력 객체의 위치는 항상 하단에 위치하지 않기 때문에 유동적인 위치 조정을 위해 많은 시도와 여러 방향으로 해보았지만 항상 동일한 행동을 수행하는 방식을 찾지 못하여 결국 scrollIntoView로 다시 돌아왔습니다.
선택 객체의 부모 id에 wrap 이 포함되어 있으면 부모로 스크롤되도록 하였고, 아니라면 선택한 객체에 스크롤하도록 하였습니다.
또 scrollIntoView의 true 인자를 전달하여 객체의 Top 위치로 스크롤되도록 설정했습니다.
function scrollToActiveElement() {
// document.activeElement parent id contain wrap
if (document.activeElement.parentElement.id.includes('wrap')) {
document.activeElement.parentElement.scrollIntoView(true);
}
else {
document.activeElement.scrollIntoView(true);
}
}
추가로 더 편한 작성 환경을 위해 Header 우측에 [완료]라는 버튼을 추가하여, 쉽게 Input에서 이탈할 수 있게 하였습니다.
이렇게 개선된 코드로 테스트해보았을 경우 다음과 같이 보입니다.

많이 자연 스러워진 것을 확인 할 수 있습니다.
스킨스쿨에서는 고객의 사용 편리성을 위해서 많은 고민과 시도를 하고 있어요.
하지만 아직 사용자 UX 부분에서는 개선해야 할 부분이 많다고 느끼고 개선하도록 노력하고 있답니다.