diff --git a/.agents/skills/mpx2rn/references/rn-script-reference.md b/.agents/skills/mpx2rn/references/rn-script-reference.md index 7c301c9379..101d7f89f9 100644 --- a/.agents/skills/mpx2rn/references/rn-script-reference.md +++ b/.agents/skills/mpx2rn/references/rn-script-reference.md @@ -725,6 +725,7 @@ Mpx.config.rnConfig = { | `openTypeHandler.onShareAppMessage` | 对应模板中 `open-type="share"`:框架会先取当前页 `onShareAppMessage` 的返回(含与默认 `title` / `path` 的合并及可选 `promise` 异步结果),再调用本回调,入参为 `{ title, path, imageUrl? }`,由宿主调起系统分享等能力。 | | `openTypeHandler.onUserInfo` | 对应模板中 `open-type="getUserInfo"`:由宿主实现获取用户信息的逻辑,结果需满足按钮侧对 `bindgetuserinfo` 的约定(以 `@mpxjs/webpack-plugin` 中 `mpx-button` 运行时为准)。 | | `getBottomVirtualHeight` | Android 底部虚拟区域高度修正。 | +| `setSwipeBackEnabled` | `(enabled) => void`,DRN 等混合容器用于设置宿主页面侧滑返回状态;`page-container` 展示和关闭时会分别传入 `false` 和 `true`。 | | `loadChunkAsync` | 异步分包加载实现。 | | `downloadChunkAsync` | 分包下载实现,用于实现 preloadRule。 | | `supportSubpackage` | 是否启用分包相关异步加载能力,与页面 `json` 中 `async` 等配合。 | diff --git a/.agents/skills/mpx2rn/references/rn-template-reference.md b/.agents/skills/mpx2rn/references/rn-template-reference.md index d8674caa94..b1afd29c0c 100644 --- a/.agents/skills/mpx2rn/references/rn-template-reference.md +++ b/.agents/skills/mpx2rn/references/rn-template-reference.md @@ -880,6 +880,16 @@ movable-view 的可移动区域。 - wait-for 为 RN 环境特有属性,具体含义可参考[react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/gesture-composition/#requireexternalgesturetofail) - RN 环境 movable 相关组件暂不支持缩放能力 +### page-container + +页面容器,可在页面内创建覆盖当前页面的弹层或抽屉。 + +RN 支持 `show`、`duration`、`z-index`、`overlay`、`position`、`round`、`close-on-slide-down`、`overlay-style`、`custom-style` 以及进入、离开、遮罩点击事件。 + +`bindclose` 是 RN 环境特有的可选事件。点击遮罩、系统返回或滑动关闭时,可通过该事件将父组件中受控的 `show` 同步为 `false`;未绑定时不会抛出异常,但业务状态也不会自动更新。 + +容器显示期间会临时禁用 iOS 页面侧滑返回,关闭后恢复页面原有的 React Navigation 配置。DRN 等混合容器可通过 `Mpx.config.rnConfig.setSwipeBackEnabled` 对接宿主手势控制。 + ### image 图片组件 diff --git a/docs-vitepress/guide/rn/application-api.md b/docs-vitepress/guide/rn/application-api.md index c16e0e29ca..052b8eb9d1 100644 --- a/docs-vitepress/guide/rn/application-api.md +++ b/docs-vitepress/guide/rn/application-api.md @@ -494,6 +494,28 @@ Mpx 框架默认会使用 `ReactNative.AppState.addEventListener('change', callb 在需要将 RN 应用嵌入到现有的 NA 应用中时,可能会出现AppState触发时机异常的情况(例如从 RN 页面跳转到 NA 页面时),此时可以将 disableAppStateListener 设置为 true 来禁用框架内部对 AppState 的监听。但需要在合适的时机手动调用 setAppShow() 与 setAppHide() 方法来进行驱动以确保对应的钩子能正常触发。 +### page-container {#page-container} + +#### mpx.config.rnConfig.setSwipeBackEnabled + +```ts +(enabled: boolean) => void +``` + +设置是否启用 iOS 页面侧滑返回,主要用于 DRN(混合容器)等场景。 + +当页面内使用 `page-container` 组件时,框架会在容器显示时自动调用此方法禁用手势返回,以防止用户左滑时触发页面返回而非关闭容器;容器关闭或组件销毁时会再次调用以恢复。 + +在纯 RN 环境下,框架通过 React Navigation 的 `gestureEnabled` 选项自动处理手势返回的禁用与恢复,无需配置此项。 + +```javascript +// 示例(DRN 场景) +mpx.config.rnConfig.setSwipeBackEnabled = (enabled) => { + // 调用 NA 提供的方法控制手势返回 + NativeModules.NavigationModule.setSwipeBackEnabled(enabled) +} +``` + ### 自定义设置底部虚拟按键区高度 {#custom-bottom-bar-height} #### mpx.config.rnConfig.getBottomVirtualHeight diff --git a/docs-vitepress/guide/rn/component.md b/docs-vitepress/guide/rn/component.md index 6d80990e29..2a35e2572a 100644 --- a/docs-vitepress/guide/rn/component.md +++ b/docs-vitepress/guide/rn/component.md @@ -5,7 +5,7 @@ ### 目录概览 {#directory-overview} - #### 基础组件 -**容器组件**:[view](#view) · [scroll-view](#scroll-view) · [swiper](#swiper) · [swiper-item](#swiper-item) · [movable-area](#movable-area) · [movable-view](#movable-view) · [root-portal](#root-portal) · [sticky-section](#sticky-section) · [sticky-header](#sticky-header) · [cover-view](#cover-view) +**容器组件**:[view](#view) · [scroll-view](#scroll-view) · [swiper](#swiper) · [swiper-item](#swiper-item) · [movable-area](#movable-area) · [movable-view](#movable-view) · [page-container](#page-container) · [root-portal](#root-portal) · [sticky-section](#sticky-section) · [sticky-header](#sticky-header) · [cover-view](#cover-view) **媒体组件**:[image](#image) · [video](#video) · [canvas](#canvas) @@ -774,6 +774,80 @@ API 视图容器。 功能同 [image 组件](#image) +### page-container + +页面容器。可在页面内创建一个覆盖于页面之上的子页面容器,常用于实现弹出层、抽屉等交互效果。 + +属性 + +| 属性名 | 类型 | 默认值 | 说明 | +| ----- | ---- | ----- | ---- | +| show | boolean | `false` | 是否显示容器 | +| duration | number | `300` | 动画时长,单位毫秒 | +| z-index | number | `100` | z-index 层级 | +| overlay | boolean | `true` | 是否显示遮罩层 | +| position | string | `bottom` | 弹出位置,可选值为 `top`、`bottom`、`right`、`center` | +| round | boolean | `false` | 是否显示圆角,仅 `position` 为 `top` 或 `bottom` 时生效 | +| close-on-slide-down | boolean | `false` | 是否在下滑时关闭容器,仅 `position` 为 `bottom` 时生效 | +| overlay-style | string | | 自定义遮罩层样式 | +| custom-style | string | | 自定义容器样式 | + +事件 + +| 事件名 | 说明 | +| ----- | ---- | +| bindbeforeenter | 进入前触发 | +| bindenter | 进入时触发 | +| bindafterenter | 进入后触发 | +| bindbeforeleave | 离开前触发 | +| bindleave | 离开时触发 | +| bindafterleave | 离开后触发 | +| bindclickoverlay | 点击遮罩层时触发 | +| bindclose | RN 环境特有可选事件,容器关闭时触发,可用于同步 `show` 状态到父组件,`event.detail = { value: false }` | + +> [!tip] 注意 +> +> - 在 RN 环境下,当容器显示时会禁用页面手势返回(iOS 左滑返回),并拦截系统返回事件。若需要响应点击遮罩、系统返回或滑动关闭并同步受控的 `show` 状态,应监听 `bindclose` 并将 `show` 设置为 `false`。 +> - 为什么需要 `bindclose`?特殊场景:在微信小程序中 `show={{a}}` 在关闭`page-container`后,a会变为false(类似wx:model的效果),所以需要bindclose来模拟此效果 + +示例 + +```html + + + +``` + ## 自定义组件 {#custom-component} diff --git a/packages/core/@types/index.d.ts b/packages/core/@types/index.d.ts index 01d4b387c6..9e0af8bafd 100644 --- a/packages/core/@types/index.d.ts +++ b/packages/core/@types/index.d.ts @@ -538,6 +538,19 @@ export interface RnConfig { * @returns Promise Resolves 为 true 表示权限获取成功,false 表示失败。 */ cameraPermission?: () => Promise + + /** + * 设置是否启用 iOS 页面侧滑返回。 + * + * 当 `page-container` 组件显示时,框架会调用此方法禁用手势返回,以防止与容器交互冲突; + * 容器关闭或销毁时会再次调用以恢复。 + * + * 主要用于 DRN(混合容器)等场景,在纯 RN 环境下框架会通过 React Navigation 的 + * `gestureEnabled` 选项自动处理,无需配置此项。 + * + * @param enabled true 表示启用侧滑返回,false 表示禁用 + */ + setSwipeBackEnabled?: (enabled: boolean) => void } interface MpxConfig { diff --git a/packages/core/src/platform/createApp.ios.js b/packages/core/src/platform/createApp.ios.js index c6aeaf67f2..6ba6b5097a 100644 --- a/packages/core/src/platform/createApp.ios.js +++ b/packages/core/src/platform/createApp.ios.js @@ -11,6 +11,7 @@ import * as ReactNative from 'react-native' import { initAppProvides } from './export/inject' import { NavigationContainer, createNativeStackNavigator, SafeAreaProvider, GestureHandlerRootView } from './env/navigationHelper' import MpxNav from '@mpxjs/webpack-plugin/lib/runtime/components/react/dist/mpx-nav' +import { NavigationContainerContext } from '@mpxjs/webpack-plugin/lib/runtime/components/react/dist/context' import { wrapAppLifecycleHooks } from '../core/perf' const appHooksMap = makeMap(mergeLifecycle(LIFECYCLE).app) @@ -160,6 +161,7 @@ export default function createApp (options) { global.__mpxAppLaunched = false global.__mpxOptionsMap[currentInject.moduleId] = memo((props) => { const firstRef = useRef(true) + const navigationContainerRef = useRef(null) const initialRouteRef = useRef({ initialRouteName: firstPage, initialParams: {} @@ -222,17 +224,23 @@ export default function createApp (options) { return createElement(SafeAreaProvider, null, - createElement(NavigationContainer, + createElement(NavigationContainerContext.Provider, { - onStateChange, - onUnhandledAction + value: navigationContainerRef }, - createElement(Stack.Navigator, + createElement(NavigationContainer, { - initialRouteName, - screenOptions: navScreenOpts + ref: navigationContainerRef, + onStateChange, + onUnhandledAction }, - ...getPageScreens(initialRouteName, initialParams) + createElement(Stack.Navigator, + { + initialRouteName, + screenOptions: navScreenOpts + }, + ...getPageScreens(initialRouteName, initialParams) + ) ) ) ) diff --git a/packages/webpack-plugin/lib/platform/template/wx/component-config/page-container.js b/packages/webpack-plugin/lib/platform/template/wx/component-config/page-container.js index 5d57d5caba..676004b580 100644 --- a/packages/webpack-plugin/lib/platform/template/wx/component-config/page-container.js +++ b/packages/webpack-plugin/lib/platform/template/wx/component-config/page-container.js @@ -3,6 +3,7 @@ const TAG_NAME = 'page-container' module.exports = function () { return { test: TAG_NAME, + // 支付宝保留事件名转换,RN 平台继续将组件映射为内建实现。 event: [ { test: 'beforeleave', @@ -10,6 +11,18 @@ module.exports = function () { return 'beforeLeave' } } - ] + ], + ios (tag, { el }) { + el.isBuiltIn = true + return 'mpx-page-container' + }, + android (tag, { el }) { + el.isBuiltIn = true + return 'mpx-page-container' + }, + harmony (tag, { el }) { + el.isBuiltIn = true + return 'mpx-page-container' + } } } diff --git a/packages/webpack-plugin/lib/platform/template/wx/component-config/unsupported.js b/packages/webpack-plugin/lib/platform/template/wx/component-config/unsupported.js index 5ac62f6c41..cfea680943 100644 --- a/packages/webpack-plugin/lib/platform/template/wx/component-config/unsupported.js +++ b/packages/webpack-plugin/lib/platform/template/wx/component-config/unsupported.js @@ -13,7 +13,7 @@ const JD_UNSUPPORTED_TAG_NAME_ARR = ['functional-page-navigator', 'live-pusher', // 快应用不支持的标签集合 const QA_UNSUPPORTED_TAG_NAME_ARR = ['movable-view', 'movable-area', 'open-data', 'official-account', 'editor', 'functional-page-navigator', 'live-player', 'live-pusher', 'ad', 'cover-image'] // RN不支持的标签集合 -const RN_UNSUPPORTED_TAG_NAME_ARR = ['open-data', 'official-account', 'editor', 'functional-page-navigator', 'live-player', 'live-pusher', 'ad', 'audio', 'match-media', 'page-container', 'keyboard-accessory', 'map'] +const RN_UNSUPPORTED_TAG_NAME_ARR = ['open-data', 'official-account', 'editor', 'functional-page-navigator', 'live-player', 'live-pusher', 'ad', 'audio', 'match-media', 'keyboard-accessory', 'map'] /** * @param {function(object): function} print diff --git a/packages/webpack-plugin/lib/runtime/components/react/context.ts b/packages/webpack-plugin/lib/runtime/components/react/context.ts index 1455d3f056..9e8f1f0f7b 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/context.ts +++ b/packages/webpack-plugin/lib/runtime/components/react/context.ts @@ -1,4 +1,4 @@ -import { createContext, Dispatch, MutableRefObject, SetStateAction } from 'react' +import { createContext, Dispatch, MutableRefObject, RefObject, SetStateAction } from 'react' import type { NativeSyntheticEvent, Animated, ScaledSize, TextStyle } from 'react-native' import { noop } from '@mpxjs/utils' @@ -95,6 +95,8 @@ export const IntersectionObserverContext = createContext(null) +export const NavigationContainerContext = createContext | null>(null) + export const SwiperContext = createContext({}) export const KeyboardAvoidContext = createContext(null) diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-page-container.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-page-container.tsx new file mode 100644 index 0000000000..be38c2143e --- /dev/null +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-page-container.tsx @@ -0,0 +1,481 @@ +/** + * ✔ show + * ✔ duration + * ✔ z-index + * ✔ overlay + * ✔ position + * ✔ round + * ✔ close-on-slide-down + * ✔ overlay-style + * ✔ custom-style + * ✔ bindbeforeenter + * ✔ bindenter + * ✔ bindafterenter + * ✔ bindbeforeleave + * ✔ bindleave + * ✔ bindafterleave + * ✔ bindclickoverlay + * ✔ bindclose RN下特有属性,用于同步show状态到父组件 + */ +import React, { createElement, forwardRef, useContext, useEffect, useRef, useState } from 'react' +import { StyleSheet, BackHandler, TouchableWithoutFeedback, StyleProp, ViewStyle } from 'react-native' +import Animated, { useSharedValue, useAnimatedStyle, withTiming, cancelAnimation, runOnJS, WithTimingConfig, Easing, AnimationCallback } from 'react-native-reanimated' +import { GestureDetector, Gesture } from 'react-native-gesture-handler' +import Portal from './mpx-portal/index' +import { usePreventRemove } from '@react-navigation/native' +import { extendObject, useLayout, useNavigation, useRunOnJSCallback } from './utils' +import useInnerProps, { getCustomEvent } from './getInnerListeners' +import useNodesRef from './useNodesRef' +import { noop } from '@mpxjs/utils' +import { NavigationContainerContext } from './context' + +type Position = 'top' | 'bottom' | 'right' | 'center'; + +interface PageContainerProps { + show: boolean; + duration?: number; + 'z-index'?: number; + overlay?: boolean; + position?: Position; + round?: boolean; + 'close-on-slide-down'?: boolean; + 'overlay-style'?: StyleProp; + 'custom-style'?: StyleProp; + bindbeforeenter?: (event: CustomEvent) => void; + bindenter?: (event: CustomEvent) => void; + bindafterenter?: (event: CustomEvent) => void; + bindbeforeleave?: (event: CustomEvent) => void; + bindleave?: (event: CustomEvent) => void; + bindafterleave?: (event: CustomEvent) => void; + bindclickoverlay?: (event: CustomEvent) => void; + + bindclose?: (event: CustomEvent<{ value: boolean}>) => void; + children: React.ReactNode; +} + +function nextTick (cb: () => void) { + setTimeout(cb, 0) +} + +function getInitialTranslate (position: Position) { + switch (position) { + case 'top': + return -100 + case 'bottom': + return 100 + case 'right': + return 100 + case 'center': + return 0 + } +} +function getRoundStyle (position: Position) { + switch (position) { + case 'top': + return { + borderBottomLeftRadius: 20, + borderBottomRightRadius: 20 + } + case 'bottom': + return { + borderTopLeftRadius: 20, + borderTopRightRadius: 20 + } + default: return {} + } +} + +// 禁用页面返回 +function useDisablePageBack (show: boolean, close: () => void) { + /** + * 如果当前页面是首页,则需要拦截返回关闭容器的逻辑 + * 如果不是首页,则由RN逻辑完成拦截(usePreventRemove) + */ + const navigation = useNavigation()! + const navigationContainerRef = useContext(NavigationContainerContext) + // TODO resetRouterStack 可能会导致 isFirstPage 发生变化,需要监听路由变化 + const isFirstPage = navigation.getState().routes.length === 1 + const onBackPressRef = useRef(() => false as boolean) + onBackPressRef.current = () => { + if (show) { + close() + return true + } + return false + } + + const backHandlerSubscriptionRef = useRef | null>(null) + const addBackPressListener = () => { + backHandlerSubscriptionRef.current = BackHandler.addEventListener('hardwareBackPress', () => onBackPressRef.current()) + } + const removeBackPressListener = () => { + backHandlerSubscriptionRef.current?.remove() + backHandlerSubscriptionRef.current = null + } + + useEffect(() => { + if (isFirstPage) { + if (typeof global.__mpx.config?.rnConfig?.setSwipeBackEnabled === 'function') { + // DRN 问题,当 resetRouterStack 页面数为1时会开启侧滑返回,此时需要再次关闭 + global.__mpx.config.rnConfig.setSwipeBackEnabled(!show) + } + + // 首页的返回事件无法通过usePreventRemove拦截,所以通过监听物理返回事件的方式来拦截 + if (__mpx_mode__ !== 'ios') { + removeBackPressListener() + if (show) { + addBackPressListener() + } + } + return () => { + removeBackPressListener() + if (typeof global.__mpx.config?.rnConfig?.setSwipeBackEnabled === 'function') { + global.__mpx.config.rnConfig.setSwipeBackEnabled(true) + } + } + } else if (__mpx_mode__ === 'ios' && show) { + // 原生导航栏部分手势容器无法覆盖到,通过设置 gestureEnabled 来禁止系统手势返回 + const gestureEnabled = navigationContainerRef?.current?.getCurrentOptions()?.gestureEnabled ?? true + navigation.setOptions({ gestureEnabled: false }) + return () => { + navigation.setOptions({ gestureEnabled }) + } + } + }, [show]) + + // 路由返回拦截 + usePreventRemove(show, () => { + close() + }) +} + +const PageContainer = forwardRef((props, ref) => { + const { + show, + duration = 300, + 'z-index': zIndex = 100, + overlay = true, + position = 'bottom', + round = false, + 'close-on-slide-down': closeOnSlideDown = false, + 'overlay-style': overlayStyle, + 'custom-style': customStyle, + bindclose, // RN下特有属性,用于同步show状态到父组件 + bindbeforeenter, + bindenter, + bindafterenter, + bindbeforeleave, + bindleave, + bindafterleave, + bindclickoverlay, + children + } = props + + const isFirstRenderFlag = useRef(true) + const isFirstRender = isFirstRenderFlag.current + if (isFirstRenderFlag.current) { + isFirstRenderFlag.current = false + } + + const triggerBeforeEnterEvent = () => bindbeforeenter?.(getCustomEvent('beforeenter', {}, {}, props)) + const triggerEnterEvent = () => bindenter?.(getCustomEvent('enter', {}, {}, props)) + const triggerAfterEnterEvent = () => bindafterenter?.(getCustomEvent('afterenter', {}, {}, props)) + const triggerBeforeLeaveEvent = () => bindbeforeleave?.(getCustomEvent('beforeleave', {}, {}, props)) + const triggerLeaveEvent = () => bindleave?.(getCustomEvent('leave', {}, {}, props)) + const triggerAfterLeaveEvent = () => bindafterleave?.(getCustomEvent('afterleave', {}, {}, props)) + + const close = () => bindclose?.(getCustomEvent('close', {}, { detail: { value: false, source: 'close' } }, props)) + + // 控制组件是否挂载 + const [internalVisible, setInternalVisible] = useState(show) + + const overlayOpacity = useSharedValue(0) + const contentOpacity = useSharedValue(position === 'center' ? 0 : 1) + const contentTranslate = useSharedValue(getInitialTranslate(position)) + + const overlayAnimatedStyle = useAnimatedStyle(() => ({ + opacity: overlayOpacity.value + })) + + const sharedPosition = useSharedValue(position) + useEffect(() => { + sharedPosition.set(position) + }, [position]) + const contentAnimatedStyle = useAnimatedStyle(() => { + let transform: ViewStyle['transform'] = [] + if (sharedPosition.value === 'top' || sharedPosition.value === 'bottom') { + transform = [{ translateY: `${contentTranslate.value}%` }] + } else if (sharedPosition.value === 'right') { + transform = [{ translateX: `${contentTranslate.value}%` }] + } + + return { + opacity: contentOpacity.value, + transform + } + }) + + const currentTick = useRef(0) + function createTick () { + currentTick.current++ + const current = currentTick.current + return () => { + return currentTick.current === current + } + } + + const invokeRunOnJSRef = useRef({ + animateInEnd: noop, + animateOutEnd: noop, + close + }) + invokeRunOnJSRef.current.close = close + const invokeRunOnJS = useRunOnJSCallback(invokeRunOnJSRef) + + function clearAnimation () { + cancelAnimation(overlayOpacity) + cancelAnimation(contentOpacity) + cancelAnimation(contentTranslate) + } + + // 播放入场动画 + const animateIn = () => { + const isCurrentTick = createTick() + triggerBeforeEnterEvent() + nextTick(() => { + const animateOutFinish = internalVisible === false + setInternalVisible(true) + triggerEnterEvent() + if (!isCurrentTick()) return + + invokeRunOnJSRef.current.animateInEnd = () => { + if (!isCurrentTick()) return + triggerAfterEnterEvent() + } + /** + * 对齐 微信小程序 + * 如果退场动画已经结束,则需将内容先移动到对应动画的初始位置 + * 否则,结束退场动画,从当前位置作为初始位置完成进场动画,且退场动画时长将缩短 + */ + let durationTime = duration + if (animateOutFinish) { + contentTranslate.set(getInitialTranslate(position)) + contentOpacity.set(position === 'center' ? 0 : 1) + } else { + clearAnimation() + if (position === 'center') { + durationTime = durationTime * (1 - contentOpacity.value) + } else { + durationTime = durationTime * (Math.abs(contentTranslate.value) / 100) + } + } + + const timingConfig: WithTimingConfig = { + duration: durationTime, + easing: Easing.out(Easing.quad) + } + const animationCallback: AnimationCallback = () => { + 'worklet' + runOnJS(invokeRunOnJS)('animateInEnd') + } + + overlayOpacity.value = withTiming(1, timingConfig) + if (position === 'center') { + contentOpacity.value = withTiming(1, timingConfig, animationCallback) + contentTranslate.value = withTiming(0, timingConfig) + } else { + contentOpacity.value = withTiming(1, timingConfig) + contentTranslate.value = withTiming(0, timingConfig, animationCallback) + } + }) + } + + // 播放离场动画 + const animateOut = () => { + const isCurrentTick = createTick() + triggerBeforeLeaveEvent() + nextTick(() => { + triggerLeaveEvent() + if (!isCurrentTick()) return + + invokeRunOnJSRef.current.animateOutEnd = () => { + if (!isCurrentTick()) return // 如果动画被cancelAnimation依然会触发回调,所以在此也需要判断Tick + triggerAfterLeaveEvent() + setInternalVisible(false) + } + + let durationTime = duration + if (position === 'center') { + durationTime = durationTime * (contentOpacity.value) + } else { + // durationTime * (1 - |contentTranslate| / 100) + durationTime = durationTime * (1 - Math.abs(contentTranslate.value) / 100) + } + const timingConfig: WithTimingConfig = { + duration: durationTime, + easing: Easing.out(Easing.quad) + } + const animationCallback: AnimationCallback = () => { + 'worklet' + runOnJS(invokeRunOnJS)('animateOutEnd') + } + if (position === 'center') { + contentOpacity.value = withTiming(0, timingConfig, animationCallback) + contentTranslate.value = withTiming(getInitialTranslate(position), timingConfig) + } else { + contentOpacity.value = withTiming(1, timingConfig) + contentTranslate.value = withTiming(getInitialTranslate(position), timingConfig, animationCallback) + } + overlayOpacity.value = withTiming(0, timingConfig) + }) + } + + useEffect(() => { + // 如果展示状态和挂载状态一致,则不需要做任何操作 + if (show) { + animateIn() + } else { + if (!isFirstRender) animateOut() + } + }, [show]) + + useDisablePageBack(show, close) + + const contentGestureCloseTriggered = useSharedValue(false) + const contentGesture = Gesture.Pan() + .activeOffsetY([0, 0]) // 只要开始下滑就激活 + .failOffsetX([-10, 10]) // 横向位移超过 10px 则 fail,确保只处理纵向滑动 + .onBegin(() => { + contentGestureCloseTriggered.value = false + }) + .onUpdate((e) => { + if (!contentGestureCloseTriggered.value && e.translationY > 200) { + contentGestureCloseTriggered.value = true + runOnJS(invokeRunOnJS)('close') + } + }) + /** + * 全屏幕 IOS 左滑手势返回(ios默认页面返回存在页面跟手逻辑,page-container暂不支持,对齐微信小程序) + * 1: 仅在屏幕左边缘滑动 才触发返回手势。 + */ + const screenGestureCloseTriggered = useSharedValue(false) + const screenGestureBase = Gesture.Pan() + .activeOffsetX([0, 0]) // 只要开始右滑就激活 + .failOffsetY([-10, 10]) // 纵向位移超过 10px 则 fail,确保只处理横向滑动 + .hitSlop({ left: 0, width: 30 }) // 从屏幕左侧 30px 内触发才处理 + .onBegin(() => { + screenGestureCloseTriggered.value = false + }) + .onUpdate((e) => { + if (!screenGestureCloseTriggered.value && e.translationX > 10) { + screenGestureCloseTriggered.value = true + runOnJS(invokeRunOnJS)('close') + } + }) + // closeOnSlideDown 时与 contentGesture 并行识别,避免两者竞争 + const screenGesture = closeOnSlideDown + ? screenGestureBase.simultaneousWithExternalGesture(contentGesture) + : screenGestureBase + + const renderMask = () => { + const onPress = () => { + close() + bindclickoverlay?.(getCustomEvent( + 'clickoverlay', + {}, + { detail: { value: false, source: 'clickoverlay' } }, + props + )) + } + return createElement(TouchableWithoutFeedback, { onPress }, + createElement(Animated.View, { style: [styles.overlay, overlayStyle, overlayAnimatedStyle] })) + } + + const renderContent = () => { + const contentView = ( + + {children} + + ) + + return closeOnSlideDown + ? {contentView} + : contentView + } + + const nodeRef = useRef(null) + useNodesRef(props, ref, nodeRef, {}) + const { layoutRef, layoutProps } = useLayout({ props, hasSelfPercent: false, nodeRef }) + const innerProps = useInnerProps( + extendObject( + {}, + props, + { + ref: nodeRef + }, + layoutProps + ), + [], + { layoutRef } + ) + const wrapperProps = extendObject( + innerProps, + { + style: [styles.wrapper, { zIndex }] + } + ) + + const renderWrapped = () => { + const wrappedView = ( + // TODO 若开启pointerEvents="box-none" screenGesture 应包裹在page/app中才能保证手势全屏生效,如不开启pointerEvents="box-none" 则后方元素不可操作 + + {overlay ? renderMask() : null} + {renderContent()} + + ) + return __mpx_mode__ === 'ios' + ? {wrappedView} + : wrappedView + } + + // TODO 是否有必要支持refs? dataset? + return createElement(Portal, null, internalVisible ? renderWrapped() : null) +}) + +const styles = StyleSheet.create({ + wrapper: extendObject( + { + justifyContent: 'flex-end', + alignItems: 'center' + } as const, + StyleSheet.absoluteFillObject + ), + overlay: extendObject( + { + backgroundColor: 'rgba(0,0,0,0.5)' + }, + StyleSheet.absoluteFillObject + ), + container: { + position: 'absolute', + backgroundColor: 'white', + overflow: 'hidden' + } +}) + +const positionStyle: Record = { + bottom: { bottom: 0, width: '100%', height: 'auto' }, + top: { top: 0, width: '100%', height: 'auto' }, + right: extendObject({}, StyleSheet.absoluteFillObject, { right: 0 }), + center: extendObject({}, StyleSheet.absoluteFillObject) +} + +PageContainer.displayName = 'MpxPageContainer' + +export default PageContainer diff --git a/packages/webpack-plugin/lib/template-compiler/compiler.js b/packages/webpack-plugin/lib/template-compiler/compiler.js index 8a6c5fb2f0..25cf8f9769 100644 --- a/packages/webpack-plugin/lib/template-compiler/compiler.js +++ b/packages/webpack-plugin/lib/template-compiler/compiler.js @@ -43,7 +43,7 @@ const endTag = new RegExp(('^<\\/' + qnameCapture + '[^>]*>')) const doctype = /^]+>/i const comment = /^