diff --git a/src/features/webview/WebViewScreen.tsx b/src/features/webview/WebViewScreen.tsx index 342e7f3..429ceee 100644 --- a/src/features/webview/WebViewScreen.tsx +++ b/src/features/webview/WebViewScreen.tsx @@ -2,10 +2,14 @@ import * as WebBrowser from "expo-web-browser"; import { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, BackHandler, Linking, Platform, StyleSheet, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { WebView, type WebViewNavigation } from "react-native-webview"; +import { WebView, type WebViewMessageEvent, type WebViewNavigation } from "react-native-webview"; import type { ShouldStartLoadRequest } from "react-native-webview/lib/WebViewTypes"; import { WEB_URL } from "@/constants/config"; import WebViewMessage from "@/features/webview/components/WebViewMessage"; +import { + DEFAULT_SAFE_AREA_COLORS, + parseSafeAreaColorsMessage, +} from "@/features/webview/safeAreaColors"; import { isSameOrigin } from "@/utils/url"; // WebView는 서드파티 컴포넌트라 NativeWind의 className이 적용되지 않는다. style로 채운다. @@ -19,6 +23,7 @@ export default function WebViewScreen() { const [isLoading, setIsLoading] = useState(true); const [hasError, setHasError] = useState(false); const [canGoBack, setCanGoBack] = useState(false); + const [safeAreaColors, setSafeAreaColors] = useState(DEFAULT_SAFE_AREA_COLORS); // 안드로이드 하드웨어 백 버튼은 기본적으로 앱을 종료한다. 웹 히스토리가 남아 있으면 뒤로 보낸다. useEffect(() => { @@ -40,6 +45,9 @@ export default function WebViewScreen() { const handleRetry = useCallback(() => { setHasError(false); setIsLoading(true); + // 다시 띄우는 웹이 색을 알려줄 때까지는 기본값으로 돌아간다 — 실패 직전 화면 색이 남으면 + // 엉뚱한 화면 위에 그 색 스트립이 얹힌다. + setSafeAreaColors(DEFAULT_SAFE_AREA_COLORS); webViewRef.current?.reload(); }, []); @@ -47,6 +55,15 @@ export default function WebViewScreen() { setCanGoBack(navigation.canGoBack); }, []); + // 웹은 화면 배경이 바뀔 때마다 세이프에어리어 스트립 색을 보낸다. 아는 메시지만 반영한다. + const handleMessage = useCallback((event: WebViewMessageEvent) => { + const colors = parseSafeAreaColorsMessage(event.nativeEvent.data); + + if (colors) { + setSafeAreaColors(colors); + } + }, []); + // 서비스 바깥 주소는 웹뷰 안에서 열지 않고 시스템 브라우저·기본 앱으로 넘긴다. const handleShouldStartLoad = useCallback((request: ShouldStartLoadRequest) => { const { url } = request; @@ -89,10 +106,10 @@ export default function WebViewScreen() { return ( // 세이프에어리어는 네이티브가 담당하고 웹은 주어진 영역을 100%로 채우기만 한다. // 위아래 스트립은 맞닿는 웹 화면과 같은 색으로 칠해야 경계선이 보이지 않는데, - // 위는 웹 본문 배경 / 아래는 Bottom Nav 배경이라 색이 서로 달라 - // SafeAreaView 하나로는 칠할 수 없다. 인셋을 직접 재서 나눠 칠한다. + // 위아래 색이 서로 다르고 화면마다도 달라서 SafeAreaView 하나로는 칠할 수 없다. + // 인셋을 직접 재서 나눠 칠하고, 색은 웹이 알려준 값을 쓴다(safeAreaColors.ts 참고). - + setHasError(true)} @@ -104,6 +121,7 @@ export default function WebViewScreen() { }} onLoadEnd={() => setIsLoading(false)} onLoadStart={() => setIsLoading(true)} + onMessage={handleMessage} onNavigationStateChange={handleNavigationStateChange} onShouldStartLoadWithRequest={handleShouldStartLoad} ref={webViewRef} @@ -116,7 +134,7 @@ export default function WebViewScreen() { )} - + ); } diff --git a/src/features/webview/safeAreaColors.ts b/src/features/webview/safeAreaColors.ts new file mode 100644 index 0000000..cb10f35 --- /dev/null +++ b/src/features/webview/safeAreaColors.ts @@ -0,0 +1,60 @@ +/** + * 세이프에어리어 스트립 색 — stream-client-web이 postMessage로 알려준다. + * + * 앱은 WebView 안의 DOM을 볼 수 없다(별도 저장소·별도 배포라 앱 안에 웹 코드가 없고, + * WebView는 픽셀만 보여준다). 그래서 어떤 화면이 무슨 배경인지는 웹이 말해주는 수밖에 없다. + * + * 예전에는 이 색을 tailwind.config.js에 hex로 복사해 뒀는데, 웹에서 화면마다 배경이 + * 달라지면서(web#50) 흰 배경 화면에서 회색 띠가 보였다. 웹만 배포돼도 조용히 어긋나는 + * 구조라 값을 앱이 들고 있지 않도록 바꿨다. + * + * 송신부: stream-client-web의 `src/components/ui/useNativeSafeAreaColors.ts`. + * 표식 문자열과 필드 이름은 양쪽이 맞춰야 한다. + */ + +const SAFE_AREA_COLORS_MESSAGE_TYPE = "safeAreaColors"; + +export interface SafeAreaColors { + bottom: string; + top: string; +} + +// 웹이 색을 알려주기 전(로딩 중)과, 이 기능이 없는 옛 웹이 물렸을 때 쓰는 값. +// stream-client-web의 WDS 배경 토큰 기본값이다 — 어디까지나 폴백이고, 웹이 말해주면 덮인다. +export const DEFAULT_SAFE_AREA_COLORS: SafeAreaColors = { + bottom: "#FFFFFF", + top: "#F7F7F8", +}; + +// onMessage는 WebView 안의 어떤 스크립트든 보낼 수 있다. 색 자리에 이상한 값이 들어와 +// 스트립이 검게 칠해지는 일이 없도록 표기를 확인하고 통과시킨다. +const CSS_COLOR_PATTERN = /^#[0-9a-f]{3,8}$|^rgba?\([\d\s.,%/]+\)$/i; + +function isColor(value: unknown): value is string { + return typeof value === "string" && CSS_COLOR_PATTERN.test(value); +} + +/** 웹이 보낸 원문에서 스트립 색을 꺼낸다. 이 앱이 아는 메시지가 아니면 `null`. */ +export function parseSafeAreaColorsMessage(data: string): SafeAreaColors | null { + let payload: unknown; + + try { + payload = JSON.parse(data); + } catch { + // JSON이 아니면 우리 메시지가 아니다. 웹이 다른 용도로 postMessage를 쓸 수 있으므로 + // 오류로 다루지 않고 흘려보낸다. + return null; + } + + if (typeof payload !== "object" || payload === null) { + return null; + } + + const { bottom, top, type } = payload as Record; + + if (type !== SAFE_AREA_COLORS_MESSAGE_TYPE || !isColor(top) || !isColor(bottom)) { + return null; + } + + return { bottom, top }; +} diff --git a/tailwind.config.js b/tailwind.config.js index 58480d0..407a91f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -3,15 +3,7 @@ module.exports = { content: ["./src/**/*.{js,jsx,ts,tsx}"], presets: [require("nativewind/preset")], theme: { - extend: { - colors: { - // stream-client-web이 쓰는 WDS 배경 토큰 값을 그대로 옮긴 것. - // 세이프에어리어 스트립을 웹 화면과 같은 색으로 잇는 용도로만 쓴다. - // 웹에서 토큰 값이 바뀌면 여기도 함께 맞춰야 한다. - "web-background-alternative": "#F7F7F8", - "web-background-normal": "#FFFFFF", - }, - }, + extend: {}, }, plugins: [], };