From 3a80ad5dd5a54219195b9ad4b0bbf0da3476bd91 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Mon, 27 Jul 2026 15:11:08 +0800 Subject: [PATCH] feat(xwebview): refine web navigation lifecycle --- packages/xwebview/README.md | 26 ++++++++++++- packages/xwebview/package.json | 2 +- packages/xwebview/src/XWebViewHost.tsx | 52 +++++++++++++++++++------ packages/xwebview/src/runtime.ts | 11 ++++++ packages/xwebview/src/types.ts | 12 ++++++ packages/xwebview/tests/runtime.test.ts | 19 +++++++++ 6 files changed, 107 insertions(+), 15 deletions(-) diff --git a/packages/xwebview/README.md b/packages/xwebview/README.md index c0ea86d..8a7bade 100644 --- a/packages/xwebview/README.md +++ b/packages/xwebview/README.md @@ -1,6 +1,6 @@ # @xuqm/rn-xwebview -通用 React Native WebView 容器。`1.0.0` 只有三项正式入口: +通用 React Native WebView 容器。`1.1.0` 只有三项正式入口: - ``:应用根部挂载一次的全屏页面 Host。 - `openWebView(config)`:从任意业务服务打开页面,返回该页面自己的 Handle。 @@ -49,6 +49,8 @@ const page = openWebView({ page.reload() page.postMessage('hello') +page.suspend() // 临时展示宿主原生页面,WebView 实例和历史仍保留 +page.resume() page.close() const result = await page.closed ``` @@ -73,7 +75,27 @@ Host;Host 卸载会以 `host_unmounted` 关闭其全部页面。 ## 页面栈与系统返回 每次 `openWebView` 都生成独立页面和 Handle。页面栈只显示顶层页面,关闭后恢复下一层 -页面及其导航状态。Android 系统返回先执行网页历史返回,没有历史才关闭当前页。 +页面及其导航状态。默认导航栏的左返回按钮只在存在网页历史时显示,且只执行网页后退; +右侧关闭按钮用于明确关闭当前页。 + +Android 系统返回先执行网页历史返回,没有历史时默认关闭当前页。需要防止误退出的宿主 +可以启用双击关闭,并自行决定首次按键的提示样式: + +```tsx +const page = openWebView({ + behavior: { + hardwareBack: { + closeMode: 'doublePress', + doublePressIntervalMs: 1_000, + onFirstClosePress: () => showToast('再按一次退出当前页面'), + }, + }, + source: { uri: 'https://example.com' }, +}) +``` + +宿主需要短暂展示原生页面时调用 `page.suspend()`,原生页面结束后调用 +`page.resume()`;暂停不等于关闭,不触发 `closed`,也不创建第二份 WebView。 导航标题和显隐修改属于当前页面,不能污染下层页面。H5 隐藏原生导航栏但保留状态栏 时,Bridge 可以从页面上下文取得真实安全区高度。 diff --git a/packages/xwebview/package.json b/packages/xwebview/package.json index fd9c25a..d4ba41f 100644 --- a/packages/xwebview/package.json +++ b/packages/xwebview/package.json @@ -1,6 +1,6 @@ { "name": "@xuqm/rn-xwebview", - "version": "1.0.0", + "version": "1.1.0", "description": "XuqmGroup RN SDK — XWebView module", "license": "UNLICENSED", "main": "src/index.ts", diff --git a/packages/xwebview/src/XWebViewHost.tsx b/packages/xwebview/src/XWebViewHost.tsx index f9e2cac..59bba66 100644 --- a/packages/xwebview/src/XWebViewHost.tsx +++ b/packages/xwebview/src/XWebViewHost.tsx @@ -1,11 +1,17 @@ -import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react' +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' import { ImageBackground, Modal, Pressable, StatusBar, StyleSheet, Text, View } from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg' import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context' import { - closeActivePage, getXWebViewSnapshot, mountXWebViewHost, subscribeXWebViewRuntime, @@ -149,7 +155,7 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe const navigationBar = config.navigationBar ?? {} const backButton = { color: navigationBar.backButton?.color ?? presentation.titleColor, - visible: navigationBar.backButton?.visible ?? true, + visible: navigationBar.backButton?.visible ?? navigationState.canGoBack, } const closeButton = { color: navigationBar.closeButton?.color ?? presentation.titleColor, @@ -203,10 +209,7 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe {backButton.visible ? ( { - if (navigationState.canGoBack) handle.goBack() - else handle.close('back') - }} + onPress={() => handle.goBack()} style={styles.navigationButton} > @@ -299,14 +302,39 @@ export function XWebViewHost() { ) useEffect(() => mountXWebViewHost(), []) + const pendingHardwareClose = useRef<{ at: number; pageId: string } | null>(null) const handleRequestClose = useCallback(() => { - const page = snapshot.pages.at(-1) + const page = [...snapshot.pages].reverse().find(item => !item.suspended) if (!page) return - if (page.navigationState.canGoBack) page.handle.goBack() - else closeActivePage('back') + if (page.navigationState.canGoBack) { + pendingHardwareClose.current = null + page.handle.goBack() + return + } + + const hardwareBack = page.config.behavior?.hardwareBack + if (hardwareBack?.closeMode !== 'doublePress') { + pendingHardwareClose.current = null + page.handle.close('back') + return + } + + const now = Date.now() + const interval = Math.max(250, hardwareBack.doublePressIntervalMs ?? 1_000) + const pending = pendingHardwareClose.current + if (pending?.pageId === page.id && now - pending.at <= interval) { + pendingHardwareClose.current = null + page.handle.close('back') + return + } + pendingHardwareClose.current = { at: now, pageId: page.id } + hardwareBack.onFirstClosePress?.() }, [snapshot.pages]) + const visiblePages = snapshot.pages.filter(page => !page.suspended) + const activePageId = visiblePages.at(-1)?.id + return ( 0} + visible={visiblePages.length > 0} > {snapshot.pages.map((page, index) => ( - + ))} diff --git a/packages/xwebview/src/runtime.ts b/packages/xwebview/src/runtime.ts index ee8693e..f598626 100644 --- a/packages/xwebview/src/runtime.ts +++ b/packages/xwebview/src/runtime.ts @@ -24,6 +24,7 @@ export type XWebViewPage = { id: string navigationState: XWebViewNavigationState presentation: XWebViewPresentation + suspended: boolean } type InternalPage = XWebViewPage & { @@ -110,6 +111,8 @@ export function openWebView(config: XWebViewConfig): XWebViewPageHandle { goForward: () => controllerCall(id, controller => controller.goForward()), postMessage: message => controllerCall(id, controller => controller.postMessage(message)), reload: () => controllerCall(id, controller => controller.reload()), + resume: () => setPageSuspended(id, false), + suspend: () => setPageSuspended(id, true), } const page: InternalPage = { @@ -124,6 +127,7 @@ export function openWebView(config: XWebViewConfig): XWebViewPageHandle { }, presentation: defaultPresentation(config), resolveClosed, + suspended: false, timeout: null, } @@ -218,6 +222,13 @@ export function updatePageWindow(pageId: string, patch: XWebViewWindowPatch): vo emit() } +export function setPageSuspended(pageId: string, suspended: boolean): void { + const page = pageById(pageId) + if (!page || page.suspended === suspended) return + page.suspended = suspended + emit() +} + export function isActivePage(pageId: string): boolean { return activePage()?.id === pageId } diff --git a/packages/xwebview/src/types.ts b/packages/xwebview/src/types.ts index 7afbbe1..bdd826a 100644 --- a/packages/xwebview/src/types.ts +++ b/packages/xwebview/src/types.ts @@ -41,6 +41,12 @@ export type XWebViewPageHandle = XWebViewController & { readonly closed: Promise readonly id: string close(reason?: XWebViewPageCloseReason | string, data?: unknown): void + /** + * 临时隐藏全屏 WebView,但保留页面实例和网页历史。 + * 适用于宿主短暂展示原生页面,完成后通过 resume 恢复。 + */ + suspend(): void + resume(): void } export type XWebViewNavigationBackground = { @@ -203,6 +209,12 @@ export type XWebViewBehaviorConfig = { injectedJavaScript?: string injectedJavaScriptBeforeContentLoaded?: string mixedContent?: 'never' | 'compatibility' | 'always' + hardwareBack?: { + /** 默认立即关闭;doublePress 表示在指定时间内再次按返回键才关闭。 */ + closeMode?: 'immediate' | 'doublePress' + doublePressIntervalMs?: number + onFirstClosePress?: () => void + } onBridgeError?: (error: string) => void onClose?: (result: XWebViewPageClosedResult) => void onControllerReady?: (controller: XWebViewController | null) => void diff --git a/packages/xwebview/tests/runtime.test.ts b/packages/xwebview/tests/runtime.test.ts index 0d9e302..e87bd2b 100644 --- a/packages/xwebview/tests/runtime.test.ts +++ b/packages/xwebview/tests/runtime.test.ts @@ -54,3 +54,22 @@ test('dynamic navigation state belongs only to its page', () => { second.close() unmount() }) + +test('suspend and resume preserve the same page and handle', () => { + resetXWebViewRuntimeForTests() + const unmount = mountXWebViewHost() + const page = openWebView({ + source: { uri: 'https://suspend.test' }, + }) + + page.suspend() + assert.equal(getXWebViewSnapshot().pages[0].suspended, true) + assert.equal(getXWebViewSnapshot().pages[0].handle, page) + + page.resume() + assert.equal(getXWebViewSnapshot().pages[0].suspended, false) + assert.equal(getXWebViewSnapshot().pages[0].handle, page) + + page.close() + unmount() +})