Skip to main content

아이폰 홈 화면 앱 위의 그림자, 이번에는 시스템 블러

· 5 min read
summerz
summerz

지난번에는 아이폰 홈 화면 앱 위의 흰 띠를 잡았습니다. 문서 전체가 아래로 밀려 있던 문제였죠. 그런데 다른 앱을 만들다가 또 비슷한 걸 만났습니다. 다크 테마에서 앱 제목과 프로필 아이콘 윗부분이 또 뿌옇게 보이는 거예요.

한 번 겪었으니 이번엔 금방 끝나겠거니 했습니다. 화면이 비슷하게 망가졌다고 원인까지 같지는 않더군요.

다른 앱이었는데요, 똑같이 웹 앱(PWA)으로 만들고 있었습니다.

다크 테마에서 상단 제목 윗부분과 프로필 아이콘 상단이 흐려졌습니다. 라이트 테마에서는 같은 흰 그림자가 보이지 않았고요. 하단 안전 여백(safe area) 문제는 이미 따로 해결한 상태였고요. 기존 글에서 쓴 복구 로직으로 해결되는지도 체크해봤거든요. scrollY가 음수이거나 문서가 아래로 밀렸을 때 window.scrollTo(0, 0)으로 되돌리는 방식이요. 그런데 아니네요?

역시 진단부터​

로컬 테스트 서버에서만 쓰는 레이아웃 진단을 넣었습니다. 화면 요소의 좌표와 CSS 페인트 속성을 수집하는 진단입니다. 홈 화면에 설치한 앱을 실제 아이폰에서 열어 확인한 값은 이랬습니다. 좌표와 크기는 CSS 픽셀 기준입니다.

항목측정값
기기 화면375×812
웹 콘텐츠 영역375×768
window.scrollY0
visualViewport.offsetTop0
html, body, root의 y 좌표모두 0
제목 영역y=16, 높이 44
하단 메뉴의 아래 끝768
하단 안전 여백31px

잘못된 게 없네요? 제목을 감싸는 레이어와 그 상위 노드들도 확인했습니다. filter, backdrop-filter, box-shadow, text-shadow가 전부 none이었습니다. 상태표시줄 설정인 apple-mobile-web-app-status-bar-style도 default였고요. 조사한 앱 레이어에는 저 그림자를 그릴 속성이 없었습니다.

이번에는 시스템이 그리는 블러​

단서가 된 건 Zenn의 실기기 실험과 WebKit 소스 분석 글이었습니다. 화면 가장자리에 운영체제가 그리는 효과인 scroll edge effect를 다루는데, 상단 고정 요소의 배경색을 이용해 시스템 블러에 대응하는 방법이 나옵니다. OJapp의 실험 글에서도 헤더까지 번지는 블러와 상단 고정 요소를 이용한 우회를 확인할 수 있었습니다.

지난 글에서 의심했다가 그때의 원인은 아니라고 판단했던 바로 그 계열의 동작입니다. 겉보기만으로 구분하기 어려운 두 문제가 있었던 거죠.

핵심은 화면 위에 불투명한 띠를 덧그리는 데 있지 않았습니다. WebKit이 상단 고정 요소의 배경색을 읽도록 하되, 그 요소 자체는 아무것도 칠하지 않게 만드는 방식이었습니다. 빈 요소에 배경색을 주고, 배경을 글자 모양으로 잘라 내는 background-clip: text를 적용합니다. 글자가 없으니 실제로 칠해지는 부분도 없습니다.

여기서 높이가 하필 11px인 이유도 나옵니다. WebKit의 LocalFrameView::fixedContainerEdges 소스에는 thinBorderWidth = 10이라는 경계가 있습니다. 배경색을 읽는 경로에서 너비나 높이가 이 값 이하이면 제외합니다. 11px는 그 경계를 넘기려는 값입니다. 앱 제목 위에 여백 11px를 추가하자는 뜻이 아니었어요.

색을 가져올 때도 화면에 칠해진 픽셀을 읽는 대신 렌더러의 스타일에서 배경색을 얻습니다. 배경색을 갖고 있지만 눈에는 보이지 않는 요소를 두는 방식이 가능한 이유입니다.

보이지 않는 고정 요소 하나​

우선 첫 페인트 전에 홈 화면에서 실행한 앱인지 판별해 html에 속성을 붙입니다. 아래 스크립트는 head 안에 둡니다.

<script>
if (
navigator.standalone === true ||
matchMedia("(display-mode: standalone)").matches
) document.documentElement.setAttribute("data-standalone", "");
</script>

그리고 body 안에서 앱 루트 앞에 빈 요소를 하나 둡니다. 화면 읽기 프로그램이 읽을 내용도 없으니 aria-hidden을 붙였습니다.

<div id="ios-status-background" aria-hidden="true"></div>
<div id="root"></div>

스타일은 이렇습니다. 기본적으로 숨겨 두고, iOS용 CSS 기능 검사와 설치형 실행 조건을 함께 만족할 때만 적용합니다.

#ios-status-background {
display: none;
}

@supports (-webkit-touch-callout: none) {
html[data-standalone] #ios-status-background {
display: block;
position: fixed;
inset: 0 0 auto;
height: 11px;
z-index: 2147483647;
pointer-events: none;
background-color: inherit;
-webkit-background-clip: text;
background-clip: text;
}
}

position: fixed이므로 본문 공간을 차지하지 않고, pointer-events: none이므로 아래 버튼을 누르는 데도 끼어들지 않습니다. 높은 z-index는 상단 배경색을 샘플링할 때 이 요소를 후보로 잡도록 하려는 설정입니다.

배경색은 별도로 하드코딩하지 않았습니다. 기존 AppearanceProvider가 테마에 따라 html과 body의 배경색을 설정하고 있어서, inherit로 그 색을 받으면 됩니다. 다크는 #0A1118, 라이트는 #F3F6F9입니다. CSS 배경색은 기본적으로 상속되지 않으니 여기서는 inherit를 명시했습니다.

실제 화면에 검은 띠나 흰 띠를 얹어 가리는 방식은 아닙니다. 요소의 사각형은 존재하지만, 빈 텍스트로 배경을 잘라 놓아서 색 띠는 그리지 않습니다. 보이는 그림자를 없애려고 보이지 않는 요소를 넣는다니, 설명하면서도 좀 이상하긴 하지만(...), 뭐 그렇습니다. 앞에 가짜를 둬서 막는 거죠.

브라우저에서 확인한 것과 아이폰에서 확인한 것​

앱 쪽 타입 검사와 기존 테스트 55개는 통과했습니다. 다만 이것으로 아이폰의 시스템 블러가 없어졌다고 말할 수는 없죠.

데스크톱의 Playwright WebKit에서는 테스트 중에만 iOS 전용 CSS 조건을 강제로 활성화했습니다. 거기서 확인한 건 다크·라이트 배경색 상속, 높이 11px의 고정 위치, pointer-events: none입니다. 보정 요소를 표시했을 때와 숨겼을 때 상단 11px의 스크린샷 픽셀도 같았습니다. 실제 색 띠를 덧그리지 않는다는 확인까지 한 겁니다. 이 테스트 브라우저의 너비는 402px로, 실기기의 375px와 구분했습니다.

최종 확인은 홈 화면에 설치한 아이폰 앱에서 했습니다. 이건 시스템 블러를 끄는 공식 CSS API가 아니라 특정 iOS와 WebKit의 상단 배경색 판별 동작을 이용한 우회라서, 운영체제가 바뀌면 다시 확인해야 합니다. 이번에 해결을 확인한 범위도 실제 테스트한 설치형 아이폰 PWA입니다. 다크에서만 그림자가 보였던 내부 이유까지 밝혀낸 것은 아니고요.

다음에 또 비슷한 띠가 나타나면 적어도 scrollY부터 보게 되겠죠. 이제 의심할 원인도 두 개가 됐습니다.

관련 링크​