Skip to content

[SBISSUE-21901] fix: Keep modal content clear of the Android navigation bar - #313

Open
OnestarLee wants to merge 6 commits into
mainfrom
fix/bottomsheet-navbar-inset
Open

[SBISSUE-21901] fix: Keep modal content clear of the Android navigation bar#313
OnestarLee wants to merge 6 commits into
mainfrom
fix/bottomsheet-navbar-inset

Conversation

@OnestarLee

@OnestarLee OnestarLee commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

For Internal Contributors

SBISSUE-21901

Description Of Changes

문제

Android에서 edge-to-edge가 적용된 앱에서 메시지를 길게 눌러 나오는 액션 시트의 마지막 항목이 시스템 내비게이션 바 뒤에 그려져 일부가 가려지고 탭이 되지 않습니다.

제보 환경은 @sendbird/uikit-react-native 3.12.6, react-native 0.83.6, Expo SDK 55(edge-to-edge 강제), Pixel 9 Pro, Android 17(targetSdk 35), 3버튼 내비게이션입니다.

재현 조건

Pixel 9 Pro API 36 에뮬레이터에 3버튼 내비게이션을 켜고 확인했습니다. edge-to-edge만으로는 재현되지 않고, 호스트 앱이 SendbirdUIKitContainer보다 위에서 하단 여백을 이미 소비한 경우에만 발생합니다. 예를 들어 컨테이너를 SafeAreaView edges={['bottom']}로 감싸거나, 커스텀 하단 탭 바나 화면 레이아웃이 하단 여백을 차지하는 구성입니다.

밀도 3.0 화면에서 픽셀로 측정한 값입니다. 시트가 하단 여백을 0px만 확보해서, 마지막 항목 171px144px이 내비게이션 바(y=2712부터) 뒤로 들어갔습니다.

원인

각각은 그 자체로 문제가 없지만, 두 가지가 겹칠 때 잘림이 생깁니다.

1. 모달 창이 edge-to-edge가 아니었습니다.

모달은 자기만의 네이티브 창에 표시됩니다. ModalnavigationBarTranslucent를 넘기지 않아서 창 decor가 모달 내용 뷰를 904dp로 깎는데, JS 레이아웃은 창 전체인 952dp를 그대로 사용합니다. 그래서 시트가 내비게이션 바 뒤로 흘러넘칩니다.

2. 시트가 여백을 모달 밖에서 읽었습니다.

BottomSheet<Modal>보다 위에서 useSafeAreaPadding을 호출해서, 모달 창이 아니라 앱을 담고 있는 뷰의 여백을 받습니다. react-native-safe-area-context의 Android 구현은 하단 여백을 max(min(view.bottom - windowHeight, 0) + windowInsets.bottom, 0)으로 계산하는데, 이 식은 provider 뷰가 창 하단에서 얼마나 떠 있는지를 빼는 구조입니다. 호스트가 하단 여백을 이미 소비했다면 이 값이 0이 됩니다. 실제 로그로도 모달 안에서 insets.bottom = 0인데 initialWindowMetrics.insets.bottom = 48로 확인했습니다.

영향 범위

잘림이 발생하는 곳은 <Modal>statusBarTranslucent를 전달하는 액션 시트와 사용자 프로필 카드입니다. 리액션 시트 두 개는 이 값을 전달하지 않아 모달 창이 edge-to-edge가 되지 않고 창 decor가 창을 미리 깎아 주므로 잘림은 발생하지 않으며, 여백 값을 앱 트리에서 읽는 문제만 해당합니다.

변경 내용

  • Modal이 앱 창의 모드에 맞춰 모달 창도 edge-to-edge로 맞춥니다. 판정은 initialWindowMetrics.frame의 하단을 화면 높이와 비교합니다. frameandroid.R.id.content 기준이라 창이 시스템 바 뒤까지 그릴 때만 화면 전체가 됩니다. 같은 스냅샷의 insets는 decor view 기준이라 edge-to-edge 여부와 무관하게 raw 값을 돌려주므로 판정에 쓸 수 없습니다. 렌더 시점에 계산해 모듈 평가 순서에 의존하지 않게 했습니다.
  • Modal 안에 SafeAreaProvider를 중첩해서, 모달 내용이 앱 트리가 아니라 자기 창의 여백을 측정합니다. initialMetrics로 초기값을 채워 첫 프레임에 빈 화면이 보이지 않게 했습니다.
  • 새로 추가한 ModalSafeArea가 모든 방향의 여백을 자식에게 전달합니다. 자식을 함수로 받는 형태인데, ReactionUserListBottomSheettabIndex, scrollRef, 탭 인디케이터 애니메이션 값을 모달 안으로 옮기지 않기 위한 선택입니다. RN Modal은 닫힐 때 자식을 언마운트하므로 상태를 옮기면 수명이 바뀝니다.
  • 액션 시트, 리액션 시트 두 개, 사용자 프로필 카드가 모두 이 helper를 통해 같은 방식으로 여백을 읽습니다.

테스트 결과

Android · bare RN 0.82 샘플 (Pixel 9 Pro API 36, 3버튼 내비게이션)

조건 수정 전 수정 후
edge-to-edge 켬 + 호스트가 하단 여백 소비 (제보 조건) 여백 0px, 마지막 항목 144px 가려짐 여백 48dp, 항목 4개 모두 노출
edge-to-edge 켬, 호스트 소비 없음 여백 48dp 여백 48dp 유지, 이중 여백 없음
edge-to-edge 끔 시트가 내비게이션 바 위에서 끝남 항목 위치가 수정 전과 동일

창 모드 판정이 두 축에서 의도대로 갈리는지 확인했습니다.

기기 / targetSdk 창 모드 판정값 결과
A API 36 / targetSdk 36 OS 강제 edge-to-edge (frame 하단 952 = 화면 952) true 여백 48dp, 마지막 항목 하단이 내비게이션 바 상단(2712)에 정확히 맞음. 호스트 소비 여부와 무관하게 동일
C API 35 / targetSdk 34 edge-to-edge 아님 (frame 하단 904 < 화면 952) false 시트가 내비게이션 바 위에서 끝나고 항목 4개 모두 노출, 내비게이션 바는 불투명 유지(측정값 rgb(0,0,0))

축 C의 insets 스냅샷은 이 상태에서도 bottom: 48을 보고합니다. insets 기준으로 판정하면 edge-to-edge가 아닌 앱의 모달까지 내비게이션 바 뒤로 그려지므로, frame 기준이어야 합니다.

가로 모드로 앱을 시작한 경우(2856×1280, 내비게이션 바 우측), 모달 안에서 읽은 값은 다음과 같습니다. 우측 내비게이션 바에 해당하는 paddingEnd: 48이 들어오고, 가로에서는 하단 내비게이션 바가 없으므로 paddingBottom: 0입니다.

modal safeArea = {"paddingTop":52,"paddingBottom":0,"paddingStart":52,"paddingEnd":48}

중앙 정렬 다이얼로그(Alert)는 프레임을 픽셀 비교했을 때 콘텐츠 영역 y 200~2711에서 2512행 중 0행이 달라, 위치가 전혀 이동하지 않았습니다.

Android · Expo 56 샘플 (react-native 0.85.3, react-native-safe-area-context 5.7, 제보와 같은 uikit 3.12.6)

조건 시트 높이 하단 여백 결과
기본 샘플, uikit 3.12.6 828px 48dp 재현되지 않음
기본 샘플 + 호스트가 하단 여백 소비, uikit 3.12.6 684px 0px 재현, 마지막 항목 144px 가려짐
위 조건 + 이 PR 828px 48dp 해결
기본 샘플 + 이 PR 828px 48dp 회귀 없음

기본 Expo 샘플은 SendbirdUIKitContainer가 트리 최상단에 있어 하단 여백 48dp를 정상적으로 받습니다. bare RN에서 얻은 수치와 픽셀 단위로 일치했고, edge-to-edge 강제만으로는 부족하고 호스트의 하단 여백 소비가 함께 필요하다는 점이 두 환경에서 같게 확인됐습니다.

iOS (iPhone 16 Pro, iOS 18.2)

조건 결과
호스트가 하단 여백 소비 + 이 PR 시트 흰 영역 1635~2621, 마지막 항목 아이콘 하단 2471, 안전 영역 시작 2520102px(34pt) 확보, 가려짐 없음
호스트 소비 없음 + 이 PR 위와 완전히 같은 수치, 이중 여백 없음

iOS도 모달 창 자신의 여백을 읽으므로 호스트 트리 구성과 무관하게 같은 결과가 나옵니다.

리액션 시트 (Android, 리액션 켠 상태, 호스트가 하단 여백 소비 + 이 PR)

시트 결과
액션 시트(이모지 행 포함) 흰 영역이 화면 하단까지, 항목 4개 + 48dp 여백 정상
ReactionListBottomSheet 흰 영역 2295~2711, 마지막 이모지 2691 → 가려짐 없음
ReactionUserListBottomSheet 흰 영역 1727~2711 → 가려짐 없음

리액션 시트 두 개는 모달 창이 edge-to-edge가 아니므로 내비게이션 바 바로 위에서 끝나고 별도 여백은 두지 않습니다. 수정 전에도 창이 깎여 있어 여백이 잘려 나갔으므로 화면상 차이는 없습니다. 이 시트들도 48dp를 확보하려면 statusBarTranslucent 전달을 추가해야 하는데, 티켓 범위를 넘는 시각 변경이라 이 PR에서는 다루지 않았습니다.

기존 사용자에게 미치는 영향

  • iOS는 navigationBarTranslucent와 edge-to-edge 판정이 Android로 제한되어 있어 창 모드 변화가 없습니다. 모달 안에서 읽는 여백 값만 자기 창 기준으로 바뀝니다.
  • edge-to-edge가 아닌 Android 앱은 창 모드 판정이 false가 되어 모달 창 모드가 바뀌지 않습니다. 내비게이션 바도 기존처럼 불투명하게 유지됩니다(축 C에서 측정). styles.statusBarTranslucentfalse로 둔 앱도 기존 경로를 그대로 사용합니다.
  • edge-to-edge 앱은 다이얼로그와 시트 위치가 그대로이고, 내비게이션 바 영역의 표현만 딤 처리된 배경에서 시트 배경색으로 바뀝니다. RN의 edgeToEdgeEnabled를 켜지 않았지만 targetSdk 35·36으로 Android 15·16에서 동작하는 앱도 OS가 액티비티를 edge-to-edge로 만들기 때문에 이 변화를 보게 됩니다.
  • 이 문제를 피하려고 커스텀 시트 헤더나 푸터에 직접 하단 여백을 넣어 둔 앱은 여백이 이중으로 들어갈 수 있습니다. 릴리스 노트에 안내가 필요한 항목입니다.
  • 앱이 내비게이션 바가 숨겨진 상태로 시작하면 판정이 false가 되어 여백을 확보하지 않습니다. 이때는 모달 창도 내비게이션 바 아래로 확장되지 않으므로 잘림 없이 여백만 없는 상태가 됩니다.
  • 공개 API는 ModalSafeArea export 추가뿐이고, BottomSheet의 props는 그대로입니다. 네이티브 코드와 의존성 변경은 없습니다.

확인한 검사

yarn lint, yarn build(5개 패키지), tsc, jest 34 suites / 190 tests 모두 통과했습니다. yarn test를 그대로 실행하면 50개 suite가 실패하는데, 이 PR과 무관하게 로컬의 .claude/worktrees/ 사본이 jest haste map에서 패키지 이름을 중복 제공해서 생기는 문제입니다. --modulePathIgnorePatterns "/.claude/worktrees/"를 주면 모두 통과합니다.

CHANGELOG는 릴리스 시점에 추가하는 것으로 두고 이 PR에서는 건드리지 않았습니다.

Types Of Changes

What types of changes does your code introduce to this project?
Put an x in the boxes that apply_

  • Bugfix
  • New feature
  • Documentation (correction or otherwise)
  • Cosmetics (whitespace, appearance (ex) Prettier)
  • Build configuration
  • Improvement (refactor code)
  • Test

🤖 Generated with Claude Code

…21901)

On Android with edge-to-edge enforcement the long-press message action
BottomSheet rendered its last item underneath the system navigation bar,
where it was partially hidden and un-tappable. Reproduced on a Pixel 9 Pro
API 36 emulator with 3-button navigation.

Two things went wrong together:

- A modal is presented in its own native window. `Modal` left that window
  out of edge-to-edge, so the window decor inset the modal content view
  while the JS layout kept spanning the whole window, and the content
  overflowed behind the navigation bar.
- The sheets read their safe area insets from a component rendered above
  the modal, so they got the insets of the view that hosts the app. When
  the app has already consumed the bottom inset above
  SendbirdUIKitContainer, that value is `0` and no inset was reserved.

`Modal` now matches the app window mode and nests a `SafeAreaProvider` so
its content measures the insets of its own window, and the bottom anchored
sheets read the insets inside the modal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 11.34%. Comparing base (7eb5bf0) to head (f0e4514).

Files with missing lines Patch % Lines
...t-native-foundation/src/components/Modal/index.tsx 0.00% 10 Missing ⚠️
...act-native-foundation/src/ui/BottomSheet/index.tsx 0.00% 7 Missing ⚠️
...s/ReactionBottomSheets/ReactionListBottomSheet.tsx 0.00% 5 Missing ⚠️
...-foundation/src/components/Modal/ModalSafeArea.tsx 0.00% 3 Missing ⚠️
...actionBottomSheets/ReactionUserListBottomSheet.tsx 0.00% 3 Missing ⚠️
...uikit-react-native/src/contexts/UserProfileCtx.tsx 0.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #313      +/-   ##
==========================================
- Coverage   11.35%   11.34%   -0.02%     
==========================================
  Files         360      361       +1     
  Lines        9094     9105      +11     
  Branches     2563     2585      +22     
==========================================
  Hits         1033     1033              
+ Misses       8060     7996      -64     
- Partials        1       76      +75     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

OnestarLee and others added 2 commits July 29, 2026 19:59
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@OnestarLee OnestarLee left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

세로 모드의 보고된 재현 조건은 정상적으로 해결되는 것을 확인했습니다. 다만 가로 모드에서 내비게이션 바가 측면으로 이동하는 경우에는 아래 두 경로가 남아 있어 확인 부탁드립니다.

Comment thread packages/uikit-react-native-foundation/src/components/Modal/index.tsx Outdated
OnestarLee and others added 2 commits July 29, 2026 20:41
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Detect the edge-to-edge window from the bottom, left and right insets. In
  landscape the navigation inset moves to the side, so a bottom-only check
  left the modal window mode out of sync with the app window.
- Turn the safe area helper into `ModalSafeArea`, which supplies every edge
  to its children instead of only padding the bottom. The reaction sheets
  read the horizontal insets outside the modal before, so a side navigation
  bar could cover their last column.
- Seed the nested provider with `initialWindowMetrics` so the modal content
  never waits for the first native measurement.

`BottomSheet` and the user profile card now go through the same helper, so
every modal reads its insets one way.
@OnestarLee
OnestarLee requested review from bang9 and danney-chun July 30, 2026 01:26

@bang9 bang9 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍 실기 검증 결과를 정리해서 올립니다. 티켓의 근본 원인과 이 PR의 수정 효과는 재현으로 확인했습니다. IS_EDGE_TO_EDGE_WINDOW 판정식 하나만 의도를 확인하고 싶은데, 동작상 문제는 아니라 머지를 막을 사안은 아닙니다.

TL;DR

  • ✅ SBISSUE-21901 증상을 main에서 재현하고, 이 브랜치에서 해소되는 것을 4개 모달 표면에서 픽셀 단위로 확인했습니다.
  • ✅ 기본 조건(호스트 앱이 inset을 소비하지 않는 sample)에서는 회귀 없습니다. VoiceMessageInput은 이중 padding 126px이 제거되어 오히려 개선됩니다.
  • ⚠️ IS_EDGE_TO_EDGE_WINDOW는 edge-to-edge를 판정하지 못합니다. 사실상 "Android + nav bar 존재"와 동의어라서 e2e가 꺼진 앱에서도 항상 true입니다.
  • ℹ️ 실제로 버그를 고치는 것은 중첩 SafeAreaProvider 이고, navigationBarTranslucent는 nav bar 배경 처리(시각적 효과)만 담당합니다.

1. 근본 원인

useSafeAreaPadding이 참조하는 provider는 SendbirdUIKitContainer의 root SafeAreaProvider이고, 이 provider는 자신의 뷰(액티비티 계층) 를 측정합니다. RN Modal은 별도 네이티브 윈도우(Dialog)이므로 React context는 경계를 넘어가지만 측정 대상은 넘어가지 않습니다.

react-native-safe-area-context 5.6.2 SafeAreaUtils.kt:

bottom = max(min(visibleRect.top + view.height - windowHeight, 0f) + windowInsets.bottom, 0f)

호스트 앱이 UIKit 위에서 bottom inset을 이미 소비한 경우(자체 SafeAreaView edges={['bottom']}, 하단 탭바, expo-router layout 등) root provider의 뷰는 화면 하단에 닿지 않으므로 위 식이 0을 돌려줍니다. 반면 모달 윈도우는 여전히 nav bar 뒤까지 full-bleed이므로 paddingBottom: 0이 되어 마지막 항목이 nav bar 아래로 들어갑니다.

티켓 첨부 스크린샷도 정확히 이 상태입니다 — 시트 배경이 화면 맨 아래까지 도달하고 Reply in thread가 nav bar band 안에 통째로 들어가 있습니다. 그리고 티켓 환경(Expo SDK 55, edge-to-edge 강제)에서는 isEdgeToEdgeFeatureFlagOn이 이미 true라 RN 쪽 getter가 field || isEdgeToEdgeFeatureFlagOn이므로 navigationBarTranslucent prop은 no-op입니다. 즉 이 티켓을 실제로 고치는 것은 중첩 provider 하나입니다.

재현을 위해 sample 앱에 아래 조건만 임시로 넣었습니다.

<SafeAreaProvider>
  <SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
    <SendbirdUIKitContainer ...>{/* ... */}</SendbirdUIKitContainer>
  </SafeAreaView>
</SafeAreaProvider>

2. 수정 검증 — 재현 조건에서 main vs branch

Pixel 2 / API 36 / OS 강제 edge-to-edge(Gradle 속성 없음) / 3버튼 내비 48dp = 126px, nav bar band 1794..1920.
모든 이미지에서 빨간 선 = nav bar 상단 y=1794, 왼쪽 = main(7eb5bf07 + 재현 패치, JS 번들 0be5934288f7), 오른쪽 = fix/bottomsheet-navbar-inset(3fc04cd3 + 동일 패치, JS 번들 2ebf7323d3fb)입니다.

action sheet, host consumes bottom inset: left=main 7eb5bf07, right=fix/bottomsheet-navbar-inset 3fc04cd3, Pixel 2 API 36

액션 시트 — 왼쪽 main: Reply in thread 행이 opaque nav bar 안에 통째로 들어가 back/home 아이콘이 텍스트 위에 겹칩니다(터치도 nav bar가 먹습니다). 오른쪽 branch: 빨간 선 위로 완전히 올라옵니다.

attachments sheet, host consumes bottom inset: left=main, right=branch, Pixel 2 API 36

첨부 시트 — 동일 증상, 동일 해소.

profile card, host consumes bottom inset: left=main, right=branch, Pixel 2 API 36

프로필 카드 — 왼쪽 main: user ID가 nav bar 아이콘 밑에 깔립니다.

voice message input, host consumes bottom inset: left=main, right=branch, no change

음성 메시지 입력 — 이 조건에서는 양쪽 동일합니다. SendInputModalstatusBarTranslucent를 넘기지 않아 모달 윈도우 자체가 nav bar 위에서 잘리기 때문에 원래 가려지지 않습니다.

측정값(uiautomator bounds, 행 높이 = DialogSheet.Item 48dp × createStyleSheet 스케일 1.143 × density 2.625 = 144px):

표면 main 마지막 항목 branch 마지막 항목 행 하단 (main → branch) 적용된 paddingBottom
액션 시트 Reply in thread 1815..1881 1689..1755 1920 → 1794 0 → 126 (= nav bar 높이)
첨부 시트 Files 1815..1881 1689..1755 1920 → 1794 0 → 126
프로필 카드 user ID 1812..1872 1686..1746 0 → 126
음성 메시지 입력 Cancel 1662..1711 Cancel 1662..1711 변화 없음 변화 없음

branch에서 행 하단이 nav bar 상단(1794)에 정확히 맞는다는 점이, 중첩 provider가 액티비티가 아니라 모달 윈도우를 측정하고 있다는 직접 증거입니다.

3. 회귀 검증 — 기본 sample(호스트가 inset 미소비)

호스트가 inset을 소비하지 않으면 root provider의 값과 모달 윈도우의 값이 우연히 일치하므로 main에도 문제가 없습니다. 이 조건에서 변경으로 인한 회귀가 없는지 확인했습니다. 아래는 모두 왼쪽 = main 7eb5bf07(번들 c13f74139bd2), 오른쪽 = fix/bottomsheet-navbar-inset 3fc04cd3(번들 b7f54c4244eb)입니다.

profile card: left=main 7eb5bf07, right=fix/bottomsheet-navbar-inset 3fc04cd3, Pixel 2 API 36

프로필 카드 / API 36 — 콘텐츠 y는 완전히 동일하고 카드 상단 흰 밴드만 33px 내려갑니다(카드가 33px 짧아짐). 하단은 양쪽 모두 여유가 남습니다. 눈에 보이는 차이는 branch에서 nav bar가 translucent가 되어 카드 배경이 아이콘 뒤로 이어지는 것뿐입니다.

voice message input: left=main 7eb5bf07, right=fix/bottomsheet-navbar-inset 3fc04cd3, Pixel 2 API 36

음성 메시지 입력 / API 36 — 여기는 버그가 하나 고쳐집니다. mainCancel/녹음 버튼 행 아래로 126px이 빈 흰 공간으로 떠 있습니다. VoiceMessageInput24 + safeArea.paddingBottom을 적용하는데(f72bb5f, SBISSUE-21340) root provider가 준 126이 이미 nav bar 위에서 잘린 창 안에서 이중 계산되기 때문입니다. branch는 중첩 provider가 이 창의 실제 inset(0)을 읽어서 그 126px이 사라집니다.

action sheet on API 36 (OS-enforced edge-to-edge): left=main, right=branch, content position unchanged

액션 시트 / API 36 — 콘텐츠 y 동일(Reply in thread 1689..1755, 행 하단 = nav bar 상단 1794). main은 opaque nav bar가 시트 흰 배경 위에 얹혀 잘려 보이고, branch는 translucent라 배경이 이어집니다. 가려지는 항목은 양쪽 모두 없습니다.

action sheet on API 34 with edgeToEdgeEnabled=true: left=main, right=branch, both paddingBottom=126

액션 시트 / API 34 + edgeToEdgeEnabled=true — main/branch 모두 Reply in thread 1689..1755, 행 하단이 nav bar 상단(1794)에 정확히 맞습니다(paddingBottom = 126 = nav bar 높이). 이 축에서는 isEdgeToEdgeFeatureFlagOntrue라 이 PR의 prop이 no-op이므로 예상된 결과입니다.

action sheet on API 34 with edgeToEdgeEnabled=false: left=main has an opaque nav bar, right=branch turns it translucent

액션 시트 / API 34 + edgeToEdgeEnabled=false — 콘텐츠 위치는 양쪽 동일(1689..1755, paddingBottom = 126)하고 가려짐도 없습니다. 다만 nav bar 배경이 main의 불투명 검정에서 branch의 translucent로 바뀝니다. 이 조건은 액티비티가 edge-to-edge가 아닌 케이스(API 34, Gradle 속성 off, OS 강제 없음)인데도 모달에 navigationBarTranslucent가 적용됐다는 뜻이고, 아래 4번 항목의 근거입니다.

4. ⚠️ IS_EDGE_TO_EDGE_WINDOW가 edge-to-edge를 판정하지 못합니다

const initialInsets = initialWindowMetrics?.insets;
const IS_EDGE_TO_EDGE_WINDOW =
  Platform.OS === 'android' &&
  Math.max(initialInsets?.bottom ?? 0, initialInsets?.left ?? 0, initialInsets?.right ?? 0) > 0;

initialWindowMetrics.insets의 출처를 따라가면 (SafeAreaContextModule.kt)

val decorView = reactApplicationContext.currentActivity?.window?.decorView as ViewGroup?
val insets = getSafeAreaInsets(decorView)   // ← decorView 기준

이고, getSafeAreaInsets에서 view === rootView === decorView이므로 view.height == windowHeight가 항상 성립합니다 → min(...) = 0bottom = rootWindowInsets.bottom. decorView는 edge-to-edge 여부와 무관하게 항상 화면 전체를 차지하고(inset을 소비하는 것은 android.R.id.content 쪽입니다), rootWindowInsets는 decor fitting과 무관하게 raw inset을 돌려줍니다.

결론: 이 상수는 "Android이고 nav bar/좌우 inset이 존재함"과 동의어이며, e2e가 꺼진 앱에서도 true입니다. 위 3번의 마지막 이미지(API 34 + edgeToEdgeEnabled=false)가 실측 근거입니다 — 액티비티가 e2e가 아닌데도 모달의 nav bar가 translucent로 바뀝니다. 따라서 실질적으로

navigationBarTranslucent={Boolean(statusBarTranslucent)}

와 같습니다. 의도를 확인하고 싶습니다.

  • "e2e일 때만 켠다"가 의도였다면 로직이 의도와 다릅니다. e2e가 아닌 호스트 앱의 모든 UIKit 모달이 nav bar 뒤까지 그려지게 됩니다(중첩 provider가 padding을 넣어주므로 콘텐츠는 가려지지 않지만, backdrop/시트 배경이 nav bar 밑으로 확장되는 동작 변경입니다). 진짜 판정이 필요하면 insets가 아니라 frame을 봐야 합니다 — getInitialWindowMetricsframeandroid.R.id.content의 프레임이라 e2e일 때만 화면 전체가 됩니다.

    const m = initialWindowMetrics;
    const IS_EDGE_TO_EDGE_WINDOW =
      Platform.OS === 'android' && !!m && m.frame.y + m.frame.height >= Dimensions.get('screen').height;
  • "항상 켠다"가 의도였다면 판정식과 이름을 없애고 navigationBarTranslucent={Boolean(statusBarTranslucent)}로 두고 이유를 주석으로 남기는 편이 읽는 사람에게 정확합니다.

부수적으로, initialWindowMetrics모듈 로드 시점 값이라 그 시점에 currentActivity가 없으면(백그라운드 headless 시작 등) null이 되어 이 상수가 앱 생애 동안 false로 굳습니다. 모듈 스코프 상수보다 렌더 시점 계산이 안전합니다.

5. 검증 방법

브랜치 커밋 JS 번들 sha
before main 7eb5bf07 c13f74139bd2 (기본) / 0be5934288f7 (재현 패치)
after fix/bottomsheet-navbar-inset 3fc04cd3 b7f54c4244eb (기본) / 2ebf7323d3fb (재현 패치)

JS 번들을 내장한 릴리스 APK로 측정했고, 위 sha로 각 축이 실제 APK에 반영됐음을 확인했습니다. Gradle이 워크스페이스 소스 변경을 추적하지 못해(라이브러리가 node_modules 심볼릭 링크로 해석됨) createBundleReleaseJsAndAssets가 up-to-date로 스킵되며, 강제 재생성하지 않으면 브랜치를 바꿔도 번들이 동일합니다.

측정 축은 세 가지입니다.

기기 edge-to-edge 경로 isEdgeToEdgeFeatureFlagOn
A Pixel 2 / API 36 OS 강제 (Gradle 속성 없음) false → 이 PR의 prop이 동작
B Pixel 2 / API 34 edgeToEdgeEnabled=true true → 이 PR의 prop이 no-op
C Pixel 2 / API 34 없음 (edgeToEdgeEnabled=false) false

표면마다 앱을 재시작하고 매 실행마다 새 사용자 ID를 써서 상태 오염을 배제했습니다. 좌표는 uiautomator bounds, 시트/카드 경계는 픽셀 스캔으로 측정했습니다.

기기의 내비게이션 설정은 보고되는 inset과 실제로 그려지는 바가 일치하는지 확인한 뒤 측정했습니다(dumpsys window displaysapp=1080x1794 = bottom inset 126px, 스크린샷의 nav bar band 1794..1920과 일치). AVD에 navbar.gesturalnavbar.threebutton overlay가 동시에 켜져 있으면 앱에는 24dp를 보고하면서 화면에는 48dp 바를 그려서, 시트 마지막 행이 바의 정확히 절반에 걸친 것처럼 보이는 가짜 증상이 나옵니다. 이 값들은 그 상태를 정리한 뒤 재측정한 것입니다.

6. 검증하지 못한 것

  • ReactionListBottomSheet / ReactionUserListBottomSheet — UI 자동화가 반복 실패해서 수치 미확보
  • iOS
  • 세로/가로 회전 중 상태 변화
  • initialMetrics가 액티비티 값으로 시드되어 발생할 수 있는 첫 프레임 여백 점프 — 정적 스크린샷으로는 관측 불가

…1901 review)

`initialWindowMetrics.insets` is measured from the decor view, which spans the
whole screen whether or not the window draws behind the system bars, so the
previous check was true for every Android device that has a navigation bar. It
turned the modal window edge-to-edge even in apps whose own window is not,
which showed up as a translucent navigation bar behind the modal.

`frame` is measured from the content view instead, and that view only spans the
whole screen when the window is edge-to-edge. Comparing its bottom against the
screen height gives the answer the check was meant to give, and reading it
during render removes the dependency on module evaluation order.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants