diff --git a/docs-vitepress/guide/rn/application-api.md b/docs-vitepress/guide/rn/application-api.md index ba225bbfa8..42ac1e26ac 100644 --- a/docs-vitepress/guide/rn/application-api.md +++ b/docs-vitepress/guide/rn/application-api.md @@ -477,6 +477,28 @@ Mpx 框架默认会使用 `ReactNative.AppState.addEventListener('change', callb 在需要将 RN 应用嵌入到现有的 NA 应用中时,可能会出现AppState触发时机异常的情况(例如从 RN 页面跳转到 NA 页面时),此时可以将 disableAppStateListener 设置为 true 来禁用框架内部对 AppState 的监听。但需要在合适的时机手动调用 setAppShow() 与 setAppHide() 方法来进行驱动以确保对应的钩子能正常触发。 +### page-container {#page-container} + +#### mpx.config.rnConfig.disableSwipeBack + +```ts +(options: { disable: boolean }) => void +``` + +禁用或启用 iOS 页面左滑手势返回,主要用于 DRN(混合容器)等场景。 + +当页面内使用 `page-container` 组件时,框架会在容器显示时自动调用此方法禁用手势返回,以防止用户左滑时触发页面返回而非关闭容器;容器关闭或组件销毁时会再次调用以恢复。 + +在纯 RN 环境下,框架通过 React Navigation 的 `gestureEnabled` 选项自动处理手势返回的禁用与恢复,无需配置此项。 + +```javascript +// 示例(DRN 场景) +mpx.config.rnConfig.disableSwipeBack = ({ disable }) => { + // 调用 NA 提供的方法控制手势返回 + NativeModules.NavigationModule.setSwipeBackEnabled(!disable) +} +``` + ### 自定义设置底部虚拟按键区高度 {#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 9f726bfe32..62e7895f77 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) @@ -768,6 +768,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 左滑返回),并拦截系统返回事件,触发 `bindclose` 事件而非直接返回上一页。因此必须监听 `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 41c6840928..2ea5512aca 100644 --- a/packages/core/@types/index.d.ts +++ b/packages/core/@types/index.d.ts @@ -433,6 +433,19 @@ export interface RnConfig { * @returns Promise Resolves 为 true 表示权限获取成功,false 表示失败。 */ cameraPermission?: () => Promise + + /** + * 禁用或启用 iOS 页面左滑手势返回。 + * + * 当 `page-container` 组件显示时,框架会调用此方法禁用手势返回,以防止与容器交互冲突; + * 容器关闭或销毁时会再次调用以恢复。 + * + * 主要用于 DRN(混合容器)等场景,在纯 RN 环境下框架会通过 React Navigation 的 + * `gestureEnabled` 选项自动处理,无需配置此项。 + * + * @param options.disable true 表示禁用手势返回,false 表示恢复 + */ + disableSwipeBack?: (options: { disable: boolean }) => void } interface MpxConfig { diff --git a/packages/webpack-plugin/lib/platform/template/wx/component-config/index.js b/packages/webpack-plugin/lib/platform/template/wx/component-config/index.js index b5a65229fb..db46b1a417 100644 --- a/packages/webpack-plugin/lib/platform/template/wx/component-config/index.js +++ b/packages/webpack-plugin/lib/platform/template/wx/component-config/index.js @@ -42,6 +42,7 @@ const wxs = require('./wxs') const fixComponentName = require('./fix-component-name') const customBuiltInComponent = require('./custom-built-in-component') const rootPortal = require('./root-portal') +const pageContainer = require('./page-container') const stickyHeader = require('./sticky-header') const stickySection = require('./sticky-section') @@ -142,6 +143,7 @@ module.exports = function getComponentConfigs ({ warn, error }) { hyphenTagName({ print }), label({ print }), rootPortal({ print }), + pageContainer({ print }), stickyHeader({ print }), stickySection({ print }), defaultCatchAllComponentConfig() 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 new file mode 100644 index 0000000000..30ae83fcd7 --- /dev/null +++ b/packages/webpack-plugin/lib/platform/template/wx/component-config/page-container.js @@ -0,0 +1,19 @@ +const TAG_NAME = 'page-container' + +module.exports = function ({ print }) { + return { + test: TAG_NAME, + 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 0c880ea06a..c5dd753820 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', 'editor', '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', 'editor', 'keyboard-accessory', 'map'] /** * @param {function(object): function} print 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..222a32f1ac --- /dev/null +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-page-container.tsx @@ -0,0 +1,473 @@ +/** + * ✔ 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, 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' + +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()! + // 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 + } + + // 记录组件挂载时的初始 gestureEnabled 值,用于组件销毁时还原 + const originalGestureEnabledRef = useRef( + navigation.getState().routes.at(-1)?.options?.gestureEnabled ?? true + ) + + useEffect(() => { + if (isFirstPage) { + if (typeof global.__mpx.config?.rnConfig?.disableSwipeBack === 'function') { + // DRN 问题,当 resetRouterStack 页面数为1时会关闭 disableSwipeBack,此时需要再次 disableSwipeBack + global.__mpx.config.rnConfig.disableSwipeBack({ disable: show }) + } + + // 首页的返回事件无法通过usePreventRemove拦截,所以通过监听物理返回事件的方式来拦截 + if (__mpx_mode__ !== 'ios') { + removeBackPressListener() + if (show) { + addBackPressListener() + } + } + return () => { + removeBackPressListener() + if (typeof global.__mpx.config?.rnConfig?.disableSwipeBack === 'function') { + global.__mpx.config.rnConfig.disableSwipeBack({ disable: false }) + } + } + } else if (__mpx_mode__ === 'ios' && show) { + // 原生导航栏部分手势容器无法覆盖到,通过设置 gestureEnabled 来禁止系统手势返回 + navigation.setOptions({ gestureEnabled: false }) + return () => { + navigation.setOptions({ gestureEnabled: originalGestureEnabledRef.current }) + } + } + }, [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 contentGesture = Gesture.Pan() + .activeOffsetY([0, 0]) // 只要开始下滑就激活 + .failOffsetX([-10, 10]) // 横向位移超过 10px 则 fail,确保只处理纵向滑动 + .onUpdate((e) => { + if (e.translationY > 200) { + runOnJS(invokeRunOnJS)('close') + } + }) + /** + * 全屏幕 IOS 左滑手势返回(ios默认页面返回存在页面跟手逻辑,page-container暂不支持,对齐微信小程序) + * 1: 仅在屏幕左边缘滑动 才触发返回手势。 + */ + const screenGestureBase = Gesture.Pan() + .activeOffsetX([0, 0]) // 只要开始右滑就激活 + .failOffsetY([-10, 10]) // 纵向位移超过 10px 则 fail,确保只处理横向滑动 + .hitSlop({ left: 0, width: 30 }) // 从屏幕左侧 30px 内触发才处理 + .onUpdate((e) => { + if (e.translationX > 10) { + 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 26e7d7e27f..223d194596 100644 --- a/packages/webpack-plugin/lib/template-compiler/compiler.js +++ b/packages/webpack-plugin/lib/template-compiler/compiler.js @@ -41,7 +41,7 @@ const endTag = new RegExp(('^<\\/' + qnameCapture + '[^>]*>')) const doctype = /^]+>/i const comment = /^