feat(xwebview): refine web navigation lifecycle
这个提交包含在:
父节点
9a1d351468
当前提交
3a80ad5dd5
@ -1,6 +1,6 @@
|
|||||||
# @xuqm/rn-xwebview
|
# @xuqm/rn-xwebview
|
||||||
|
|
||||||
通用 React Native WebView 容器。`1.0.0` 只有三项正式入口:
|
通用 React Native WebView 容器。`1.1.0` 只有三项正式入口:
|
||||||
|
|
||||||
- `<XWebViewHost />`:应用根部挂载一次的全屏页面 Host。
|
- `<XWebViewHost />`:应用根部挂载一次的全屏页面 Host。
|
||||||
- `openWebView(config)`:从任意业务服务打开页面,返回该页面自己的 Handle。
|
- `openWebView(config)`:从任意业务服务打开页面,返回该页面自己的 Handle。
|
||||||
@ -49,6 +49,8 @@ const page = openWebView({
|
|||||||
|
|
||||||
page.reload()
|
page.reload()
|
||||||
page.postMessage('hello')
|
page.postMessage('hello')
|
||||||
|
page.suspend() // 临时展示宿主原生页面,WebView 实例和历史仍保留
|
||||||
|
page.resume()
|
||||||
page.close()
|
page.close()
|
||||||
const result = await page.closed
|
const result = await page.closed
|
||||||
```
|
```
|
||||||
@ -73,7 +75,27 @@ Host;Host 卸载会以 `host_unmounted` 关闭其全部页面。
|
|||||||
## 页面栈与系统返回
|
## 页面栈与系统返回
|
||||||
|
|
||||||
每次 `openWebView` 都生成独立页面和 Handle。页面栈只显示顶层页面,关闭后恢复下一层
|
每次 `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 隐藏原生导航栏但保留状态栏
|
导航标题和显隐修改属于当前页面,不能污染下层页面。H5 隐藏原生导航栏但保留状态栏
|
||||||
时,Bridge 可以从页面上下文取得真实安全区高度。
|
时,Bridge 可以从页面上下文取得真实安全区高度。
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xuqm/rn-xwebview",
|
"name": "@xuqm/rn-xwebview",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "XuqmGroup RN SDK — XWebView module",
|
"description": "XuqmGroup RN SDK — XWebView module",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"main": "src/index.ts",
|
"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 { ImageBackground, Modal, Pressable, StatusBar, StyleSheet, Text, View } from 'react-native'
|
||||||
import Clipboard from '@react-native-clipboard/clipboard'
|
import Clipboard from '@react-native-clipboard/clipboard'
|
||||||
import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'
|
import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'
|
||||||
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'
|
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
closeActivePage,
|
|
||||||
getXWebViewSnapshot,
|
getXWebViewSnapshot,
|
||||||
mountXWebViewHost,
|
mountXWebViewHost,
|
||||||
subscribeXWebViewRuntime,
|
subscribeXWebViewRuntime,
|
||||||
@ -149,7 +155,7 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe
|
|||||||
const navigationBar = config.navigationBar ?? {}
|
const navigationBar = config.navigationBar ?? {}
|
||||||
const backButton = {
|
const backButton = {
|
||||||
color: navigationBar.backButton?.color ?? presentation.titleColor,
|
color: navigationBar.backButton?.color ?? presentation.titleColor,
|
||||||
visible: navigationBar.backButton?.visible ?? true,
|
visible: navigationBar.backButton?.visible ?? navigationState.canGoBack,
|
||||||
}
|
}
|
||||||
const closeButton = {
|
const closeButton = {
|
||||||
color: navigationBar.closeButton?.color ?? presentation.titleColor,
|
color: navigationBar.closeButton?.color ?? presentation.titleColor,
|
||||||
@ -203,10 +209,7 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe
|
|||||||
{backButton.visible ? (
|
{backButton.visible ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
hitSlop={HIT_SLOP}
|
hitSlop={HIT_SLOP}
|
||||||
onPress={() => {
|
onPress={() => handle.goBack()}
|
||||||
if (navigationState.canGoBack) handle.goBack()
|
|
||||||
else handle.close('back')
|
|
||||||
}}
|
|
||||||
style={styles.navigationButton}
|
style={styles.navigationButton}
|
||||||
>
|
>
|
||||||
<IconBack color={backButton.color} size={24} />
|
<IconBack color={backButton.color} size={24} />
|
||||||
@ -299,14 +302,39 @@ export function XWebViewHost() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => mountXWebViewHost(), [])
|
useEffect(() => mountXWebViewHost(), [])
|
||||||
|
const pendingHardwareClose = useRef<{ at: number; pageId: string } | null>(null)
|
||||||
|
|
||||||
const handleRequestClose = useCallback(() => {
|
const handleRequestClose = useCallback(() => {
|
||||||
const page = snapshot.pages.at(-1)
|
const page = [...snapshot.pages].reverse().find(item => !item.suspended)
|
||||||
if (!page) return
|
if (!page) return
|
||||||
if (page.navigationState.canGoBack) page.handle.goBack()
|
if (page.navigationState.canGoBack) {
|
||||||
else closeActivePage('back')
|
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])
|
}, [snapshot.pages])
|
||||||
|
|
||||||
|
const visiblePages = snapshot.pages.filter(page => !page.suspended)
|
||||||
|
const activePageId = visiblePages.at(-1)?.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
animationType="slide"
|
animationType="slide"
|
||||||
@ -314,12 +342,12 @@ export function XWebViewHost() {
|
|||||||
onRequestClose={handleRequestClose}
|
onRequestClose={handleRequestClose}
|
||||||
presentationStyle="fullScreen"
|
presentationStyle="fullScreen"
|
||||||
statusBarTranslucent
|
statusBarTranslucent
|
||||||
visible={snapshot.pages.length > 0}
|
visible={visiblePages.length > 0}
|
||||||
>
|
>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<View style={styles.host}>
|
<View style={styles.host}>
|
||||||
{snapshot.pages.map((page, index) => (
|
{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>
|
</View>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export type XWebViewPage = {
|
|||||||
id: string
|
id: string
|
||||||
navigationState: XWebViewNavigationState
|
navigationState: XWebViewNavigationState
|
||||||
presentation: XWebViewPresentation
|
presentation: XWebViewPresentation
|
||||||
|
suspended: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type InternalPage = XWebViewPage & {
|
type InternalPage = XWebViewPage & {
|
||||||
@ -110,6 +111,8 @@ export function openWebView(config: XWebViewConfig): XWebViewPageHandle {
|
|||||||
goForward: () => controllerCall(id, controller => controller.goForward()),
|
goForward: () => controllerCall(id, controller => controller.goForward()),
|
||||||
postMessage: message => controllerCall(id, controller => controller.postMessage(message)),
|
postMessage: message => controllerCall(id, controller => controller.postMessage(message)),
|
||||||
reload: () => controllerCall(id, controller => controller.reload()),
|
reload: () => controllerCall(id, controller => controller.reload()),
|
||||||
|
resume: () => setPageSuspended(id, false),
|
||||||
|
suspend: () => setPageSuspended(id, true),
|
||||||
}
|
}
|
||||||
|
|
||||||
const page: InternalPage = {
|
const page: InternalPage = {
|
||||||
@ -124,6 +127,7 @@ export function openWebView(config: XWebViewConfig): XWebViewPageHandle {
|
|||||||
},
|
},
|
||||||
presentation: defaultPresentation(config),
|
presentation: defaultPresentation(config),
|
||||||
resolveClosed,
|
resolveClosed,
|
||||||
|
suspended: false,
|
||||||
timeout: null,
|
timeout: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,6 +222,13 @@ export function updatePageWindow(pageId: string, patch: XWebViewWindowPatch): vo
|
|||||||
emit()
|
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 {
|
export function isActivePage(pageId: string): boolean {
|
||||||
return activePage()?.id === pageId
|
return activePage()?.id === pageId
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,12 @@ export type XWebViewPageHandle = XWebViewController & {
|
|||||||
readonly closed: Promise<XWebViewPageClosedResult>
|
readonly closed: Promise<XWebViewPageClosedResult>
|
||||||
readonly id: string
|
readonly id: string
|
||||||
close(reason?: XWebViewPageCloseReason | string, data?: unknown): void
|
close(reason?: XWebViewPageCloseReason | string, data?: unknown): void
|
||||||
|
/**
|
||||||
|
* 临时隐藏全屏 WebView,但保留页面实例和网页历史。
|
||||||
|
* 适用于宿主短暂展示原生页面,完成后通过 resume 恢复。
|
||||||
|
*/
|
||||||
|
suspend(): void
|
||||||
|
resume(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type XWebViewNavigationBackground = {
|
export type XWebViewNavigationBackground = {
|
||||||
@ -203,6 +209,12 @@ export type XWebViewBehaviorConfig = {
|
|||||||
injectedJavaScript?: string
|
injectedJavaScript?: string
|
||||||
injectedJavaScriptBeforeContentLoaded?: string
|
injectedJavaScriptBeforeContentLoaded?: string
|
||||||
mixedContent?: 'never' | 'compatibility' | 'always'
|
mixedContent?: 'never' | 'compatibility' | 'always'
|
||||||
|
hardwareBack?: {
|
||||||
|
/** 默认立即关闭;doublePress 表示在指定时间内再次按返回键才关闭。 */
|
||||||
|
closeMode?: 'immediate' | 'doublePress'
|
||||||
|
doublePressIntervalMs?: number
|
||||||
|
onFirstClosePress?: () => void
|
||||||
|
}
|
||||||
onBridgeError?: (error: string) => void
|
onBridgeError?: (error: string) => void
|
||||||
onClose?: (result: XWebViewPageClosedResult) => void
|
onClose?: (result: XWebViewPageClosedResult) => void
|
||||||
onControllerReady?: (controller: XWebViewController | null) => void
|
onControllerReady?: (controller: XWebViewController | null) => void
|
||||||
|
|||||||
@ -54,3 +54,22 @@ test('dynamic navigation state belongs only to its page', () => {
|
|||||||
second.close()
|
second.close()
|
||||||
unmount()
|
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()
|
||||||
|
})
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户