diff --git a/src/components/ui/ScreenLayout.tsx b/src/components/ui/ScreenLayout.tsx index 3c3d277..36a4e3b 100644 --- a/src/components/ui/ScreenLayout.tsx +++ b/src/components/ui/ScreenLayout.tsx @@ -5,6 +5,7 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom"; import BottomNav, { type BottomNavValue } from "@/components/ui/BottomNav"; import { ScreenHeaderContext } from "@/components/ui/screenHeaderContext"; import { ScreenSheetPortalContext } from "@/components/ui/screenSheetPortalContext"; +import { useNativeSafeAreaColors } from "@/components/ui/useNativeSafeAreaColors"; // Bottom Nav 탭 ↔ 라우트 경로 매핑. 화면이 늘어나면 여기에 추가한다. const BOTTOM_NAV_PATHS: Record = { @@ -52,6 +53,8 @@ interface ScreenLayoutProps { // 같이 움직여야 하는 고정 px는 FeedbacksQaCard(캐러셀 카드)와 BililgeReturnConfirmModal // 둘뿐이다. 화면과 같은 배경 위에 서는 컬럼이라 그림자로 경계를 표시한다 // — App.tsx가 같은 브레이크포인트로 이 컬럼을 가운데 세운다. +// 세이프에어리어 자리는 앱 셸이 담당하므로 여기서 env(safe-area-inset-*)를 더하지 않는다(중복 여백이 된다). +// 대신 그 자리를 무슨 색으로 칠할지는 이 화면만 알기 때문에 useNativeSafeAreaColors로 앱에 알려준다. // 세이프에어리어는 env(safe-area-inset-*)로 확보한다(index.html의 viewport-fit=cover와 한 쌍). // 고정 px를 더하면 앱에서 네이티브 인셋과 겹쳐 두 번 들어가지만, env()는 환경이 채우는 값이라 // 앱 WebView에서는 0이 되어 중복이 생기지 않는다 — 폰 브라우저·PWA(standalone)에서만 실제 인셋이 잡힌다. @@ -71,6 +74,8 @@ function ScreenLayout({ const navigate = useNavigate(); const bottomNavValue = getBottomNavValueFromPath(location.pathname); + useNativeSafeAreaColors(background, hasBottomNav); + return ( diff --git a/src/components/ui/useNativeSafeAreaColors.ts b/src/components/ui/useNativeSafeAreaColors.ts new file mode 100644 index 0000000..77004cf --- /dev/null +++ b/src/components/ui/useNativeSafeAreaColors.ts @@ -0,0 +1,52 @@ +import { useEffect } from "react"; + +import type { ScreenBackground } from "@/components/ui/ScreenLayout"; +import { isInAppShell, postBridgeMessage } from "@/lib/bridge/bridge"; +import { SAFE_AREA_COLORS_MESSAGE_TYPE } from "@/lib/bridge/messages/safeAreaColors"; + +// 화면 배경 ↔ WDS 시맨틱 토큰. index.css가 Tailwind 색으로 별칭 연결해 둔 그 변수들이다. +const BACKGROUND_CSS_VARIABLES: Record = { + alternative: "--semantic-background-normal-alternative", + normal: "--semantic-background-normal-normal", +}; + +function readCssVariable(name: string): string { + return getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); +} + +// 앱은 WebView 위아래에 세이프에어리어 인셋 높이만큼 스트립을 깔고, 맞닿는 웹 화면과 같은 색으로 +// 칠해 경계선을 없앤다. 앱은 웹의 DOM을 볼 수 없으므로(별도 저장소·별도 배포, WebView 안은 +// 픽셀만 보인다) 웹이 자기 색을 알려주지 않으면 알 방법이 없다. +// +// hex를 앱에 복사해 두면 화면이 늘거나 토큰이 바뀔 때마다 두 저장소가 따로 어긋난다(#56) — +// 그래서 상수가 아니라 getComputedStyle로 지금 적용된 값을 읽어서 보낸다. 다크 테마가 붙어도 +// 같은 경로로 따라간다. +// +// 어느 색을 보낼지는 라우트 11개를 실측해서 나온 규칙 그대로다. +// top = 컬럼 배경 +// bottom = Bottom Nav가 있으면 Nav 배경(bg-background-normal), 없으면 컬럼 배경이 곧 바닥 +export function useNativeSafeAreaColors( + background: ScreenBackground, + hasBottomNav: boolean, +) { + useEffect(() => { + if (!isInAppShell()) { + return; + } + + const top = readCssVariable(BACKGROUND_CSS_VARIABLES[background]); + const bottom = hasBottomNav + ? readCssVariable(BACKGROUND_CSS_VARIABLES.normal) + : top; + + // 토큰을 못 읽었으면(WDS 로드 전 등) 보내지 않는다 — 앱이 자기 기본값을 유지하는 편이 + // 빈 문자열을 받아 검정으로 칠하는 것보다 낫다. + if (!top || !bottom) { + return; + } + + postBridgeMessage(SAFE_AREA_COLORS_MESSAGE_TYPE, { bottom, top }); + }, [background, hasBottomNav]); +} diff --git a/src/lib/bridge/bridge.ts b/src/lib/bridge/bridge.ts new file mode 100644 index 0000000..7d44d7e --- /dev/null +++ b/src/lib/bridge/bridge.ts @@ -0,0 +1,48 @@ +/** + * 웹 → 앱 postMessage 브리지. + * + * `window.ReactNativeWebView.postMessage(JSON.stringify({ type, payload }))`로 보낸다. + * `type`은 메시지 표식, `payload`는 메시지별 본문이다. 송신하는 쪽은 봉투 형식을 모르고 + * `postBridgeMessage(type, payload)`만 부른다. + * + * 수신부: stream-client-app의 `src/features/webview/bridge/bridge.ts`. + * + * 메시지 추가 절차: + * 1. `messages/<이름>.ts`에 type 상수 · payload 인터페이스 (앱과 같은 이름·같은 필드) + * 2. 아래 `BridgePayloads`에 한 줄 등록 — `postBridgeMessage`의 타입은 여기서 파생된다 + * 3. stream-client-app에 같은 type 상수로 파서·핸들러 작성 (PR을 서로 링크) + */ + +import { + SAFE_AREA_COLORS_MESSAGE_TYPE, + type SafeAreaColorsPayload, +} from "@/lib/bridge/messages/safeAreaColors"; + +// stream-client-app(WebView 셸)이 주입하는 전역. 브라우저로 열면 없다. +declare global { + interface Window { + ReactNativeWebView?: { postMessage: (message: string) => void }; + } +} + +interface BridgePayloads { + [SAFE_AREA_COLORS_MESSAGE_TYPE]: SafeAreaColorsPayload; +} + +type BridgeMessageType = keyof BridgePayloads; + +/** 앱 셸 안에서 열렸는지. 브라우저·개발 뷰에서는 `false`. */ +export function isInAppShell(): boolean { + return window.ReactNativeWebView !== undefined; +} + +/** + * 앱 셸에 메시지를 보낸다. 셸 밖(브라우저)이면 아무것도 하지 않는다. + * 단방향이라 앱이 받았는지는 알 수 없다 — 앱은 모르는 메시지·형식 불일치를 조용히 버린다. + */ +export function postBridgeMessage( + type: K, + payload: BridgePayloads[K], +): void { + window.ReactNativeWebView?.postMessage(JSON.stringify({ payload, type })); +} diff --git a/src/lib/bridge/messages/safeAreaColors.ts b/src/lib/bridge/messages/safeAreaColors.ts new file mode 100644 index 0000000..979d31f --- /dev/null +++ b/src/lib/bridge/messages/safeAreaColors.ts @@ -0,0 +1,16 @@ +/** + * 세이프에어리어 스트립 색 메시지. + * + * 앱은 WebView 위아래에 세이프에어리어 인셋 높이만큼 스트립을 깔고, 맞닿는 웹 화면과 같은 색으로 + * 칠해 경계선을 없앤다. 그 색은 화면마다 다르고 웹만 알기 때문에 웹이 보낸다. + * + * 수신부: stream-client-app의 `src/features/webview/bridge/messages/safeAreaColors.ts`. + * 표식 문자열과 payload 필드 이름은 양쪽이 맞춰야 한다. + */ + +export const SAFE_AREA_COLORS_MESSAGE_TYPE = "safeAreaColors"; + +export interface SafeAreaColorsPayload { + bottom: string; + top: string; +}