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()
+})