feat(xwebview): refine web navigation lifecycle
这个提交包含在:
父节点
9a1d351468
当前提交
3a80ad5dd5
@ -1,6 +1,6 @@
|
||||
# @xuqm/rn-xwebview
|
||||
|
||||
通用 React Native WebView 容器。`1.0.0` 只有三项正式入口:
|
||||
通用 React Native WebView 容器。`1.1.0` 只有三项正式入口:
|
||||
|
||||
- `<XWebViewHost />`:应用根部挂载一次的全屏页面 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 可以从页面上下文取得真实安全区高度。
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 ? (
|
||||
<Pressable
|
||||
hitSlop={HIT_SLOP}
|
||||
onPress={() => {
|
||||
if (navigationState.canGoBack) handle.goBack()
|
||||
else handle.close('back')
|
||||
}}
|
||||
onPress={() => handle.goBack()}
|
||||
style={styles.navigationButton}
|
||||
>
|
||||
<IconBack color={backButton.color} size={24} />
|
||||
@ -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 (
|
||||
<Modal
|
||||
animationType="slide"
|
||||
@ -314,12 +342,12 @@ export function XWebViewHost() {
|
||||
onRequestClose={handleRequestClose}
|
||||
presentationStyle="fullScreen"
|
||||
statusBarTranslucent
|
||||
visible={snapshot.pages.length > 0}
|
||||
visible={visiblePages.length > 0}
|
||||
>
|
||||
<SafeAreaProvider>
|
||||
<View style={styles.host}>
|
||||
{snapshot.pages.map((page, index) => (
|
||||
<HostPage key={page.id} active={index === snapshot.pages.length - 1} page={page} />
|
||||
<HostPage key={page.id} active={page.id === activePageId} page={page} />
|
||||
))}
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -41,6 +41,12 @@ export type XWebViewPageHandle = XWebViewController & {
|
||||
readonly closed: Promise<XWebViewPageClosedResult>
|
||||
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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户