← 제작기

내가 그린 건 화면이 아니라 한 겹이었다

iOS 쪽에는 SplashView 파일이 하나 있습니다. 앱을 켜면 “Classical Mood”가 필기체로 한 글자씩 나타났다가 본 화면으로 넘어가는 그 화면입니다. 안드로이드로 옮기는 일은 이 화면을 다시 그리는 일이라고 생각했습니다.

세 번 틀렸는데, 세 번 모두 그림이 잘못된 것은 아니었습니다. 그림은 처음부터 맞았고, 그 그림을 어느 겹에 놓았는지가 틀렸습니다.

글자 사이로 추천 탭이 비쳤다

글자는 제대로 나왔지만, 스플래시가 떠 있는 내내 글자와 글자 사이의 빈 곳으로 뒤에 있는 추천 탭이 비쳤습니다.

화면을 감싼 Box 에 배경이 없었습니다. 불투명한 것은 글자뿐이어서, 같은 스택 안에서 뒤에 깔려 있던 본 화면이 그대로 보였습니다. 저는 “갈색 바탕 위의 글자”를 만들었다고 생각했지만, 실제로 만든 것은 아무 바탕 없이 떠 있는 글자였습니다.

고친 것은 .background(ClassicalBrown) 한 줄입니다. 한 줄이라는 점이 오히려 문제였습니다. 빠뜨려도 코드는 멀쩡해 보이고, 글자가 나온다는 점에서는 화면도 멀쩡해 보입니다.

내 화면보다 먼저 뜨는 화면이 있었다

배경을 채우고 나니 다음 문제가 보였습니다. 런처 아이콘을 누르면 흰 화면에 아이콘이 잠깐 떴다가 그다음에 갈색 스플래시가 나왔습니다. 두 화면이 서로 아무 상관 없어 보였습니다.

안드로이드는 앱의 첫 프레임이 그려지기 전에 운영체제가 자기 창을 먼저 띄웁니다. 그 창의 배경은 액티비티 테마의 windowBackground 이고, 기본값은 Material Light 의 흰색이었습니다. 이 시점에는 제 Compose 코드가 아직 실행되지도 않았으니, 코드 안에서는 손쓸 방법이 없습니다.

그 창에도 같은 색을 알려 줬습니다. core-splashscreen 을 넣고, 시작 창의 배경과 아이콘을 스플래시 애니메이션이 쓰는 것과 같은 갈색, 같은 금색 높은음자리표로 맞췄습니다.

겹이 하나 더 있었습니다. 운영체제의 창이 사라진 뒤 Compose 의 첫 프레임이 그려지기까지, 그사이에도 액티비티의 배경이 잠깐 보입니다. 그곳까지 같은 갈색으로 칠하고 나서야 이음매가 사라졌습니다.

런처 아이콘 탭
  │
  ├─ OS 시작 창          Theme.App.Starting   windowSplashScreenBackground
  ├─ 액티비티 배경        Theme.App.Main       android:windowBackground
  └─ Compose 첫 프레임    SplashScreen.kt      Modifier.background(…)

     세 곳의 색이 같아야 한 화면으로 보인다

“실행 화면의 배경색”은 한 군데에 있는 값이 아니었습니다. 세 군데에 있고, 하나라도 다르면 정확히 그 자리에서 화면이 번쩍입니다.

같은 화면인데 부르는 곳에 따라 달라졌다

온보딩은 첫 실행 때 다섯 개 탭을 소개하고, 설정의 “앱 소개 다시 보기”로 언제든 다시 볼 수 있습니다. 같은 컴포넌트를 두 곳에서 부르는 것입니다.

첫 실행 때는 화면 전체를 덮었는데, 설정에서 부르면 하단 탭 바가 그대로 남았습니다.

설정 화면이 자기 안에서 온보딩을 그렸기 때문입니다. 설정 탭은 Scaffold 의 콘텐츠 영역 안에 있고, 하단 탭 바는 그 Scaffold 에 속합니다. 콘텐츠 영역 안에서는 아무리 크게 그려도 그 영역 밖으로 나갈 수 없습니다. 첫 실행 쪽은 Scaffold 바깥에서 그려지고 있었습니다. 같은 컴포넌트가 다른 겹에서 그려져 다른 결과를 낸 것입니다.

다시 보기 신호를 바깥으로 끌어올려 두 경로가 같은 자리에서 그려지게 했습니다. 그랬더니 이번에는 건너뛰기 버튼이 상태 표시줄 밑으로 들어갔습니다. Scaffold 바깥으로 나오면서, Scaffold 가 대신 해 주던 안전 영역 계산을 물려받지 못한 것입니다. 겹을 옮기면 그 겹이 해 주던 일도 함께 잃습니다.

원칙: 같은 컴포넌트를 두 곳에서 부른다면, 두 곳이 같은 겹인지부터 확인합니다.

iOS 에서는 왜 한 번에 됐나

iOS 의 SplashView 는 ZStack 하나였습니다. 배경색을 깔고 그 위에 글자를 올리면 끝이고, 그 ZStack 이 화면 전체를 차지합니다. 배경을 빠뜨릴 일도, 먼저 뜨는 다른 화면도 없습니다.

안드로이드에서 실행 첫 순간의 화면은 여러 주체가 나눠 그립니다. 창 관리자가 시작 창을 띄우고, 액티비티 테마가 그 뒤를 이어받고, 그다음에야 Compose 가 그립니다. 하나처럼 보이게 하려면 세 주체에게 각각 같은 값을 알려 줘야 합니다. 이식이 “그림을 다시 그리는 일”에서 “이음매를 찾는 일”로 바뀌는 지점이었습니다.

앱을 쓰는 동안에는 보이지 않는다

세 가지 모두 평소에 앱을 쓰는 동안에는 볼 수 없습니다. 두 가지는 실행하는 몇 초 사이에만 있고, 나머지 하나는 첫 실행이거나 설정에서 일부러 불러야 나옵니다. 한 번 지나가고 나면 남는 것은 정상적인 추천 탭뿐입니다.

실제 기기에서 애니메이션이 진행되는 도중에 화면을 잡아야 보였습니다. 스플래시가 떠 있는 시간은 모두 합쳐 2.5초 남짓입니다. 그 안에서도 첫 프레임이 흰색인지 갈색인지 확인하려면 런처 아이콘을 누른 직후를, 글자 사이로 무엇이 비치는지 보려면 글자가 절반쯤 나왔을 때를 잡아야 합니다.

이 사이트에 앞서 쓴 두 글은 결국 검사는 물어본 것에만 답한다는 같은 이야기였습니다. 이번에는 여기에 언제 보느냐도 질문의 일부라는 것이 더 붙었습니다. 앱이 다 뜬 뒤의 화면만 보면 세 가지 모두 통과합니다.


정리하면

  • “화면을 만들었다”와 “화면을 덮었다”는 다른 말입니다. 결과를 정하는 것은 무엇을 그렸는지보다 어느 겹에 놓았는지입니다.
  • 실행 첫 순간의 배경색은 한 곳이 아니라 세 곳에 있습니다. 한 곳만 고치면 나머지 자리에서 화면이 번쩍입니다.
  • 같은 컴포넌트라도 부르는 위치가 다르면 다른 화면이 됩니다. 겹을 옮기면 그 겹이 해 주던 일도 함께 사라집니다.
  • 지나가는 순간에만 있는 버그는 다 뜬 화면으로는 잡을 수 없습니다. 언제 볼지를 정해야 보입니다.

On the iOS side there is a single SplashView file. Open the app and “Classical Mood” writes itself out letter by letter in a cursive face, then hands off to the main screen. Porting it to Android, I assumed, meant drawing that screen again.

I got it wrong three times. But not once was the drawing wrong. The drawing was right from the start; what was wrong was which layer I put it on.

The Recommend tab showed through the letters

The letters came out fine. But the main screen behind them was visible in every gap between the letters — for the entire time the splash was up.

The Box wrapping the screen had no background. The only opaque thing was the text, so the tab sitting behind it in the same stack showed straight through. I thought I had built “letters on a brown field.” What I had actually built was letters floating on nothing.

The fix is one line: .background(ClassicalBrown). Being one line is precisely the problem — leave it out and the code still looks complete, and so does the screen, as long as “the letters appear” is the only thing you check.

Another screen was drawn before mine

With the background filled in, the next seam became visible. Tapping the launcher icon showed a white screen with the icon for a beat, and then the brown splash. The two looked entirely unrelated to each other.

Android shows a window of its own before the app's first frame is drawn. That window's background is the activity theme's windowBackground, which defaulted to Material Light's white. At that moment my Compose code has not even run, so there is nothing to fix from inside it.

So I told that window the same colour. I added core-splashscreen and set the starting window's background and icon to the same brown and the same gold treble clef the splash animation already uses.

Then there was one more layer. Between the OS window going away and Compose's first frame being drawn, the activity's own background shows briefly. Only after painting that the same brown did the seam disappear.

tap the launcher icon
  │
  ├─ OS starting window    Theme.App.Starting   windowSplashScreenBackground
  ├─ activity background   Theme.App.Main       android:windowBackground
  └─ Compose first frame   SplashScreen.kt      Modifier.background(…)

     all three must match to read as one screen

“The launch background colour” was not one value in one place. It lives in three, and if any one of them differs, that is exactly where the flash happens.

The same screen behaved differently depending on who called it

The onboarding walkthrough introduces all five tabs on first launch, and Settings has a “View App Introduction” button to replay it any time. Two callers, one component.

On first launch it covered the whole screen. Called from Settings, the bottom navigation bar stayed visible underneath it.

Because Settings was drawing the walkthrough inside itself. The Settings tab lives in the Scaffold's content slot, and the bottom bar belongs to that Scaffold. Nothing drawn inside the slot can escape it, however large. The first-launch path was drawing outside the Scaffold entirely. Same component, different layer, different screen.

I lifted the replay signal out so both paths render in the same place. Which then put the Skip button underneath the status bar — moving outside the Scaffold meant no longer inheriting the inset handling it had been doing for me. Move something to another layer and you also lose whatever that layer was giving you.

Principle: if one component is called from two places, first check whether those two places are the same layer.

Why iOS got this right the first time

iOS's SplashView is a single ZStack. Lay down a background colour, put the letters on top, done — and that ZStack owns the whole screen. There is no background to forget, and no other screen drawn ahead of it.

Android's first moment of launch has several owners. The window manager puts up a starting window, the activity theme backs it, and only then does Compose draw. To make them read as one thing, each of the three has to be told the same value. That is where porting stopped being “redraw the picture” and became “find the seams.”

None of it is visible while you use the app

None of the three can be seen during ordinary use. Two of them exist only in the few seconds it takes to launch; the third appears only on a first run, or when you deliberately replay it from Settings. Once you are past it, all that is left is a perfectly normal Recommend tab.

They only showed up when I captured the device mid-animation. The splash is up for a little over two and a half seconds in total, and even inside that window, seeing whether the first frame is white or brown means catching the instant after the launcher icon is tapped, and seeing what shows through the letters means catching them half-written.

The two posts before this one on this site were making the same point — a check only answers what you asked it. This one adds a clause. When you look is part of the question too. Look only once everything has settled and all three pass.


In short

  • “I built a screen” and “I covered the screen” are different claims. What decides the outcome is not what you drew but which layer you put it on.
  • The launch background colour lives in three places, not one. Fix only one and it flashes at the others.
  • One component called from two places becomes two different screens. And moving something between layers costs you whatever that layer was doing for you.
  • A bug that lives only in a state you pass through cannot be caught on a settled screen. You have to decide when to look.