diff --git a/.agents/skills/mpx2rn/references/rn-template-reference.md b/.agents/skills/mpx2rn/references/rn-template-reference.md index 55e6cd3708..87fb61295d 100644 --- a/.agents/skills/mpx2rn/references/rn-template-reference.md +++ b/.agents/skills/mpx2rn/references/rn-template-reference.md @@ -61,6 +61,7 @@ - [video](#video) - [web-view](#web-view) - [root-portal](#root-portal) + - [section-list](#section-list) - [sticky-section](#sticky-section) - [sticky-header](#sticky-header) - [cover-view](#cover-view) @@ -1409,6 +1410,61 @@ level 有效值: - style 样式中不支持使用百分比计算、css variable +### section-list + +跨端虚拟列表组件,可自定义分组头、列表头、列表项,自动分段渲染兼容各端。 + +#### 属性 + +| 属性名 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| height | string \| number | `100%` | 组件高度 | +| width | string \| number | `100%` | 组件宽度 | +| list-data | array | `[]` | 列表数据;分组头数据需包含 `isSectionHeader: true`,分组尾数据需包含 `isSectionFooter: true` | +| enable-sticky | boolean | `false` | 启用分组吸顶 | +| scroll-event-throttle | number | `0` | 控制 scroll 事件触发频率 | +| enhanced | boolean | `false` | 开启滚动增强能力 | +| bounces | boolean | `true` | iOS 下边界弹性控制,需同时开启 `enhanced` | +| use-list-header | boolean | `false` | 使用自定义列表头 | +| list-header-data | object | `{}` | 列表头数据 | +| use-list-footer | boolean | `false` | 使用自定义列表页脚 | +| list-footer-data | object | `{}` | 列表页脚数据 | +| generic:recycle-item | string | | 列表项抽象节点组件名 | +| generic:section-header | string | | 列表分组头抽象节点组件名 | +| generic:section-footer | string | | 列表分组尾抽象节点组件名 | +| generic:list-header | string | | 列表头抽象节点组件名 | +| generic:list-footer | string | | 列表页脚抽象节点组件名 | +| item-height | object | `{}` | 列表项高度配置,支持 `getter` / `value` | +| section-header-height | object | `{}` | 分组头部高度配置,支持 `getter` / `value` | +| section-footer-height | object | `{}` | 分组尾部高度配置,支持 `getter` / `value` | +| list-header-height | number | `0` | 列表头部固定高度,不支持 `getter` / `value` | +| enable-back-to-top | boolean | `false` | 点击状态栏时滚动到顶部,仅 iOS 环境支持 | +| end-reached-threshold | number | `0.1` | 触底事件触发阈值 | +| refresher-enabled | boolean | `false` | 开启自定义下拉刷新 | +| refresher-triggered | boolean | `false` | 设置当前下拉刷新状态,true 表示已触发 | +| show-scrollbar | boolean | `true` | 滚动条显隐控制 | +| simultaneous-handlers | array\ | `[]` | RN 环境特有属性,允许多个手势同时识别和处理 | +| wait-for | array\ | `[]` | RN 环境特有属性,允许延迟激活处理某些手势 | + +#### 事件 + +| 事件名 | 说明 | +| --- | --- | +| bindscroll | 滚动时触发,`event.detail.scrollTop` 返回纵向滚动位置 | +| bindscrolltolower | 滚动到底部 / 触底通知 | +| bindrefresherrefresh | 自定义下拉刷新被触发 | + +#### 方法 + +| 方法名 | 说明 | +| --- | --- | +| scrollToIndex | 通过 ref 获取实例后可调用,`scrollToIndex({ index, animated, viewOffset, viewPosition })`,用于滚动到指定索引 | + +#### 注意事项 + +- 当使用列表项、列表头、自定义分组头或者自定义分组尾,必须配置对应 `item-height`、`section-header-height`、`section-footer-height`、`list-header-height` 高度参数,否则会出现滚动异常。 +- RN 环境中,section-list 通过 RN 的 `SectionList` 实现分组吸顶。开启 `enable-sticky` 且快速滑动时,自定义分组头有时会出现闪烁,属于 RN 底层实现限制。 + ### sticky-section 吸顶布局容器,仅支持作为 `` 的直接子节点 diff --git a/docs-vitepress/.vitepress/config.ts b/docs-vitepress/.vitepress/config.ts index 17be349077..768a6c79b9 100644 --- a/docs-vitepress/.vitepress/config.ts +++ b/docs-vitepress/.vitepress/config.ts @@ -146,6 +146,7 @@ const sidebar: ExtendedSidebar = { { text: '网络请求', link: '/guide/extend/fetch' }, { text: '数据 Mock', link: '/guide/extend/mock' }, { text: 'WebView Bridge', link: '/guide/extend/webview-bridge' }, + { text: '扩展组件', link: '/guide/extend/extend-component' }, ], }, { diff --git a/docs-vitepress/guide/extend/extend-component.md b/docs-vitepress/guide/extend/extend-component.md new file mode 100644 index 0000000000..0ab757e7ec --- /dev/null +++ b/docs-vitepress/guide/extend/extend-component.md @@ -0,0 +1,195 @@ +# Mpx 扩展组件 + +除基础组件外,Mpx 额外提供一些扩展组件。扩展组件需要在页面或组件的 `usingComponents` 中注册后使用。 + +```html + +``` + +Mpx 会根据当前编译的目标平台(wx/ali/web/ios/android/harmony),自动解析到对应平台的扩展组件实现。 + + +## section-list + +跨端虚拟列表组件,可自定义分组头、列表头、列表项,自动分段渲染兼容各端。 + +支持平台:RN + +### 属性 + +| 属性名 | 类型 | 默认值 | 说明 | +|-----------------------|-------------|----------|------------------------| +| height | String/Number | 100% | 组件高度 | +| width | String/Number | 100% | 组件宽度 | +| list-data | Array | [] | 列表数据,如需使用列表分组头 `section-header`,对应 item 的数据需要包含 `isSectionHeader: true` 标识;如需使用列表分组尾 `section-footer`,对应 item 的数据需要包含 `isSectionFooter: true` 标识 | +| enable-sticky | Boolean | false | 启用分组吸顶 | +| scroll-event-throttle | Number | 0 | RN 环境特有属性,控制 scroll 事件触发频率 | +| enhanced | Boolean | false | RN 环境特有属性,开启滚动增强能力 | +| bounces | Boolean | true | RN 环境特有属性,iOS 下边界弹性控制,需同时开启 `enhanced` | +| use-list-header | Boolean | false | 使用自定义列表头 | +| list-header-data | Object | {} | 列表头数据 | +| use-list-footer | Boolean | false | 使用自定义列表页脚 | +| list-footer-data | Object | {} | 列表页脚数据 | +| generic:recycle-item | String | | 列表项,抽象节点组件名,对应组件需要通过 usingComponents 注册 | +| generic:section-header | String | | 列表分组头,抽象节点组件名,对应组件需要通过 usingComponents 注册 | +| generic:section-footer | String | | 列表分组尾,抽象节点组件名,对应组件需要通过 usingComponents 注册 | +| generic:list-header | String | | 列表头,抽象节点组件名,对应组件需要通过 usingComponents 注册 | +| generic:list-footer | String | | 列表页脚,抽象节点组件名,对应组件需要通过 usingComponents 注册 | +| item-height | Object | {} | 列表项高度配置(支持 getter/value),必须配置 | +| section-header-height | Object | {} | 分组头部高度配置(getter/value),若使用了自定义分组头必须配置 | +| section-footer-height | Object | {} | 分组尾部高度配置(getter/value),若使用了自定义分组尾必须配置 | +| list-header-height | Number | 0 | 列表头部高度,若使用了列表头必须配置 | +| enable-back-to-top | Boolean | false | 点击状态栏时滚动到顶部,仅 iOS 环境支持 | +| end-reached-threshold | Number | 0.1 | 触底事件触发阈值 | +| refresher-enabled | Boolean | false | 开启自定义下拉刷新 | +| refresher-triggered | Boolean | false | 设置当前下拉刷新状态,true 表示已触发 | +| show-scrollbar | Boolean | true | 滚动条显隐控制 | +| simultaneous-handlers | Array\ | [] | RN 环境特有属性,允许多个手势同时识别和处理 | +| wait-for | Array\ | [] | RN 环境特有属性,允许延迟激活处理某些手势 | + +#### `item-height`/`section-header-height`/`section-footer-height` 格式说明 {#section-list-height-config} + +`item-height`、`section-header-height`、`section-footer-height` 支持如下格式: + +```js +height: { + value: 400, // 定高 + getter: function (item, index) { + const seed = item.id % 2 || 0 + const heights = [100, 300] + return heights[seed] + } +} +``` + +**说明:** +- `value`:默认高度(所有项相同高度时直接用 value 即可)。 +- `getter`:函数形式,可接收每一项的数据和索引,按需返回不同高度(动态高度需求时使用)。 +- `getter` 优先级 大于 value。 + +> 建议性能要求较高(如超大数据集)优先使用 `value` 定高。 + +`list-header-height` 仅支持 Number 类型,用于声明列表头部固定高度,不支持 `getter` / `value` 配置对象。 + +### 事件 + +| 事件名 | 说明 | +|-----------------------|-----------------------------------| +| bindscroll | 滚动时触发,`event.detail.scrollTop` 返回纵向滚动位置 | +| bindscrolltolower | 滚动到底部/触底通知 | +| bindrefresherrefresh | 自定义下拉刷新被触发 | + +### 方法 {#section-list-methods} + +| 方法名 | 说明 | +|-------|------| +| scrollToIndex | 滚动到指定索引 | + +`scrollToIndex({ index, animated, viewOffset, viewPosition })` 参数说明: +- `index`:目标索引 +- `animated`:是否滚动动画 +- `viewOffset`:滚动偏移量 +- `viewPosition`:滚动定位,0:顶部, 0.5:中间, 1:底部 + +### 用法示例 + +```js + + + +``` + +### 其它说明 + +- 当使用了列表项、列表头、自定义分组头或者自定义分组尾,必须配置对应 `item-height`、`section-header-height`、`section-footer-height`、`list-header-height` 高度参数,否则会出现滚动异常情况。 +- 可通过 ref 获取实例并调用 `scrollToIndex` 方法实现滚动。 +- 如果用户滑动的速度超过渲染的速度,则会先看到空白的内容,这是为了长列表优化不得不作出的妥协。 +- 当某行滑出渲染区域之外后,其内部状态将不会保留。 +- 在 RN 环境,section-list 通过 RN 提供的 SectionList 实现分组吸顶。受 RN 底层实现机制限制,开启 `enable-sticky` 且快速滑动时,自定义分组头有时会出现闪烁现象。此问题需要等待 RN 官方修复,我们会持续关注并跟进。 +- 若某行需要使用 `section-header` 对应的抽象节点渲染,则该行数据必须包含 `isSectionHeader: true` 字段;若某行需要使用 `section-footer` 对应的抽象节点渲染,则该行数据必须包含 `isSectionFooter: true` 字段;否则默认使用 `recycle-item` 对应的抽象节点渲染 +- `isSectionFooter: true` 的数据建议放在对应分组的最后一行、下一个 `isSectionHeader: true` 数据之前,用于表示当前分组的尾部。 diff --git a/docs-vitepress/guide/extend/index.md b/docs-vitepress/guide/extend/index.md index 2c437ab62a..d4b742542e 100644 --- a/docs-vitepress/guide/extend/index.md +++ b/docs-vitepress/guide/extend/index.md @@ -35,3 +35,7 @@ export default function install(proxyMPX) { - 小程序API转换及promisify:@mpxjs/api-proxy [详细介绍](#api-proxy) [源码地址](https://github.com/didi/mpx/tree/master/packages/api-proxy) - mock数据:@mpxjs/mock [详细介绍](#mock) [源码地址](https://github.com/didi/mpx/tree/master/packages/mock) + +## 扩展组件 {#extend-components} + +Mpx 提供跨端扩展组件能力,详见 [Mpx 扩展组件](./extend-component)。 diff --git a/docs-vitepress/guide/rn/component.md b/docs-vitepress/guide/rn/component.md index 537e495088..271d94bd44 100644 --- a/docs-vitepress/guide/rn/component.md +++ b/docs-vitepress/guide/rn/component.md @@ -735,7 +735,7 @@ API > [!tip] 注意 > > - style 样式不支持中使用百分比计算、css variable - + ### sticky-section 吸顶布局容器,仅支持作为 `` 的直接子节点 diff --git a/package.json b/package.json index aff26f9786..2927f847d3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@babel/preset-env": "^7.25.3", "@babel/preset-typescript": "^7.24.7", "@testing-library/jest-dom": "^4.2.4", + "@types/node": "18.19.70", "@types/jest": "^27.0.1", "@types/node": "18.19.70", "@typescript-eslint/eslint-plugin": "^5.2.0", diff --git a/packages/webpack-plugin/lib/index.js b/packages/webpack-plugin/lib/index.js index cadd09dfac..22abd6d118 100644 --- a/packages/webpack-plugin/lib/index.js +++ b/packages/webpack-plugin/lib/index.js @@ -30,6 +30,7 @@ const AddEnvPlugin = require('./resolver/AddEnvPlugin') const PackageEntryPlugin = require('./resolver/PackageEntryPlugin') const DynamicRuntimePlugin = require('./resolver/DynamicRuntimePlugin') const FixDescriptionInfoPlugin = require('./resolver/FixDescriptionInfoPlugin') +const ExtendComponentsPlugin = require('./resolver/ExtendComponentsPlugin') // const CommonJsRequireDependency = require('webpack/lib/dependencies/CommonJsRequireDependency') // const HarmonyImportSideEffectDependency = require('webpack/lib/dependencies/HarmonyImportSideEffectDependency') // const RequireHeaderDependency = require('webpack/lib/dependencies/RequireHeaderDependency') @@ -416,11 +417,13 @@ class MpxWebpackPlugin { const addEnvPlugin = new AddEnvPlugin('before-file', this.options.env, this.options.fileConditionRules, 'file') const packageEntryPlugin = new PackageEntryPlugin('before-file', this.options.miniNpmPackages, this.options.normalNpmPackages, 'file') const dynamicPlugin = new DynamicPlugin('result', this.options.dynamicComponentRules) + const extendComponentsPlugin = new ExtendComponentsPlugin('before-file', this.options.mode, 'file') if (Array.isArray(compiler.options.resolve.plugins)) { + compiler.options.resolve.plugins.push(extendComponentsPlugin) compiler.options.resolve.plugins.push(addModePlugin) } else { - compiler.options.resolve.plugins = [addModePlugin] + compiler.options.resolve.plugins = [extendComponentsPlugin, addModePlugin] } if (this.options.env) { compiler.options.resolve.plugins.push(addEnvPlugin) diff --git a/packages/webpack-plugin/lib/resolver/AddEnvPlugin.js b/packages/webpack-plugin/lib/resolver/AddEnvPlugin.js index c11691cc60..8c1ea3ffb0 100644 --- a/packages/webpack-plugin/lib/resolver/AddEnvPlugin.js +++ b/packages/webpack-plugin/lib/resolver/AddEnvPlugin.js @@ -19,7 +19,7 @@ module.exports = class AddEnvPlugin { const envPattern = new RegExp(`\\.${env}(\\.|$)`) resolver.getHook(this.source).tapAsync('AddEnvPlugin', (request, resolveContext, callback) => { - if (request.env) { + if (request.__mpxResolvedExtendComponent || request.env) { return callback() } const obj = { diff --git a/packages/webpack-plugin/lib/resolver/AddModePlugin.js b/packages/webpack-plugin/lib/resolver/AddModePlugin.js index 88098f233f..446e5bfdbf 100644 --- a/packages/webpack-plugin/lib/resolver/AddModePlugin.js +++ b/packages/webpack-plugin/lib/resolver/AddModePlugin.js @@ -21,7 +21,7 @@ module.exports = class AddModePlugin { const defaultModePattern = new RegExp(`\\.${defaultMode}(\\.|$)`) resolver.getHook(this.source).tapAsync('AddModePlugin', (request, resolveContext, callback) => { - if (request.mode || request.env) { + if (request.__mpxResolvedExtendComponent || request.mode || request.env) { return callback() } const obj = { diff --git a/packages/webpack-plugin/lib/resolver/ExtendComponentsPlugin.js b/packages/webpack-plugin/lib/resolver/ExtendComponentsPlugin.js new file mode 100644 index 0000000000..eacb1898c6 --- /dev/null +++ b/packages/webpack-plugin/lib/resolver/ExtendComponentsPlugin.js @@ -0,0 +1,68 @@ +const path = require('path') +const toPosix = require('../utils/to-posix') +const EXTEND_COMPONENT_PATH_REGEXP = /@mpxjs\/webpack-plugin\/lib\/runtime\/components\/extends\/[^/]+$/ +const RN_COMPONENTS_DIST_PATH = 'lib/runtime/components/react/dist' +const EXTEND_COMPONENTS = { + 'section-list': { + ios: `${RN_COMPONENTS_DIST_PATH}/mpx-section-list.jsx`, + android: `${RN_COMPONENTS_DIST_PATH}/mpx-section-list.jsx`, + harmony: `${RN_COMPONENTS_DIST_PATH}/mpx-section-list.jsx` + } +} + +/** + * 扩展组件路径解析插件 + * 将 @mpxjs/webpack-plugin/lib/runtime/components/extends/[component-name] 格式的路径 + * 解析为对应平台的实际组件路径 + */ +module.exports = class ExtendComponentsPlugin { + constructor (source, mode, target) { + this.source = source + this.target = target + this.mode = mode + } + + apply (resolver) { + const target = resolver.ensureHook(this.target) + const mode = this.mode + + resolver.getHook(this.source).tapAsync('ExtendComponentsPlugin', (request, resolveContext, callback) => { + if (request.__mpxResolvedExtendComponent) return callback() + + const componentName = getComponentName(request) + if (!componentName) { + return callback() + } + + // 检查组件是否在配置中 + const componentTargetMap = EXTEND_COMPONENTS[componentName] + const targetSubPath = componentTargetMap && componentTargetMap[mode] + if (!targetSubPath) { + return callback(new Error(`Extended component "${componentName}" cannot be used on the ${mode} platform.`)) + } + const targetPath = path.join(request.descriptionFileRoot, targetSubPath) + const targetRelativePath = `./${targetSubPath}` + + const redirectRequest = Object.assign({}, request, { + path: targetPath, + relativePath: targetRelativePath, + __mpxResolvedExtendComponent: true + }) + + resolver.doResolve( + target, + redirectRequest, + `resolve extend component: ${componentName} to ${targetPath}`, + resolveContext, + callback + ) + }) + } +} + +function getComponentName (request) { + if (!request.path) return + const requestPath = toPosix(request.path) + if (!EXTEND_COMPONENT_PATH_REGEXP.test(requestPath)) return + return path.basename(requestPath, path.extname(requestPath)) +} diff --git a/packages/webpack-plugin/lib/runtime/components/extends/section-list.mpx b/packages/webpack-plugin/lib/runtime/components/extends/section-list.mpx new file mode 100644 index 0000000000..6f515e863b --- /dev/null +++ b/packages/webpack-plugin/lib/runtime/components/extends/section-list.mpx @@ -0,0 +1,14 @@ + + + + diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-section-list.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-section-list.tsx new file mode 100644 index 0000000000..9a73bb2949 --- /dev/null +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-section-list.tsx @@ -0,0 +1,535 @@ +import { forwardRef, useRef, useMemo, createElement, useImperativeHandle } from 'react' +import type { ComponentType } from 'react' +import { SectionList, RefreshControl, NativeSyntheticEvent, NativeScrollEvent } from 'react-native' +import type { SectionListData, SectionListProps as RNSectionListProps } from 'react-native' +import { Gesture, GestureDetector } from 'react-native-gesture-handler' +import { hasOwn } from '@mpxjs/utils' +import useInnerProps, { getCustomEvent } from './getInnerListeners' +import { extendObject, useLayout, useTransformStyle, GestureHandler, flatGesture } from './utils' +interface ListItem { + isSectionHeader?: boolean; + isSectionFooter?: boolean; + [key: string]: any; +} + +interface SectionExtra { + headerData: ListItem | null; + footerData: ListItem | null; + hasSectionHeader?: boolean; + hasSectionFooter?: boolean; +} + +interface Section extends SectionExtra { + data: ListItem[]; +} + +type RNSection = SectionListData + +const TypedSectionList = SectionList as unknown as ComponentType> + +interface ItemHeightType { + value?: number; + getter?: (item: any, index: number) => number; +} + +interface MpxSectionListProps { + enhanced?: boolean; + bounces?: boolean; + height?: number | string; + width?: number | string; + generichash?: string; + style?: Record; + 'scroll-event-throttle'?: number; + 'list-data'?: ListItem[]; + 'item-height'?: ItemHeightType; + 'section-header-height'?: ItemHeightType; + 'section-footer-height'?: ItemHeightType; + 'list-header-data'?: any; + 'list-header-height'?: number; + 'use-list-header'?: boolean; + 'list-footer-data'?: any; + 'use-list-footer'?: boolean; + 'genericrecycle-item'?: string; + 'genericsection-header'?: string; + 'genericsection-footer'?: string; + 'genericlist-header'?: string; + 'genericlist-footer'?: string; + 'enable-var'?: boolean; + 'parent-width'?: number; + 'parent-height'?: number; + 'enable-sticky'?: boolean; + 'enable-back-to-top'?: boolean; + 'end-reached-threshold'?: number; + 'refresher-enabled'?: boolean; + 'show-scrollbar'?: boolean; + 'refresher-triggered'?: boolean; + 'wait-for'?: Array; + 'simultaneous-handlers'?: Array; + bindrefresherrefresh?: (event: any) => void; + bindscrolltolower?: (event: any) => void; + bindscroll?: (event: any) => void; + [key: string]: any; +} + +interface ScrollPositionParams { + index: number; + animated?: boolean; + viewOffset?: number; + viewPosition?: number; +} + +const getGeneric = (generichash: string, generickey: string) => { + if (!generichash || !generickey) return null + return global.__mpxGenericsMap?.[generichash]?.[generickey]?.() || null +} + +const _SectionList = forwardRef((props = {}, ref) => { + const { + enhanced = false, + bounces = true, + height, + width, + generichash, + style = {}, + 'list-data': listData, + 'scroll-event-throttle': scrollEventThrottle = 0, + 'item-height': itemHeight = {}, + 'section-header-height': sectionHeaderHeight = {}, + 'section-footer-height': sectionFooterHeight = {}, + 'list-header-height': listHeaderHeight = 0, + 'list-header-data': listHeaderData = null, + 'use-list-header': useListHeader = false, + 'list-footer-data': listFooterData = null, + 'use-list-footer': useListFooter = false, + 'genericrecycle-item': genericrecycleItem, + 'genericsection-header': genericsectionHeader, + 'genericsection-footer': genericsectionFooter, + 'genericlist-header': genericListHeader, + 'genericlist-footer': genericListFooter, + 'enable-var': enableVar, + 'parent-font-size': parentFontSize, + 'parent-width': parentWidth, + 'parent-height': parentHeight, + 'enable-sticky': enableSticky = false, + 'enable-back-to-top': enableBackToTop = false, + 'end-reached-threshold': onEndReachedThreshold = 0.1, + 'refresher-enabled': refresherEnabled, + 'show-scrollbar': showScrollbar = true, + 'refresher-triggered': refresherTriggered, + 'simultaneous-handlers': originSimultaneousHandlers, + 'wait-for': waitFor + } = props + + const refreshing = !!refresherTriggered + + const scrollViewRef = useRef(null) + const sectionListGestureRef = useRef() + + const indexMap = useRef<{ [key: string]: string }>({}) + + const reverseIndexMap = useRef<{ [key: string]: number }>({}) + + const { + hasSelfPercent, + setWidth, + setHeight + } = useTransformStyle(style, { enableVar, parentWidth, parentHeight }) + + const { layoutRef, layoutStyle, layoutProps } = useLayout({ props, hasSelfPercent, setWidth, setHeight, nodeRef: scrollViewRef }) + + const onRefresh = () => { + const { bindrefresherrefresh } = props + bindrefresherrefresh && + bindrefresherrefresh( + getCustomEvent('refresherrefresh', {}, { layoutRef }, props) + ) + } + + const onEndReached = () => { + const { bindscrolltolower } = props + bindscrolltolower && + bindscrolltolower( + getCustomEvent('scrolltolower', {}, { layoutRef }, props) + ) + } + + const onScroll = (event: NativeSyntheticEvent) => { + const { bindscroll } = props + if (bindscroll) { + const { nativeEvent } = event + bindscroll( + getCustomEvent('scroll', event, { + detail: { + scrollTop: nativeEvent.contentOffset.y + }, + layoutRef + }, props) + ) + } + } + + // 通过sectionIndex和rowIndex获取原始索引 + const getOriginalIndex = (sectionIndex: number, rowIndex: number | 'header' | 'footer'): number => { + const key = `${sectionIndex}_${rowIndex}` + return reverseIndexMap.current[key] ?? -1 // 如果找不到,返回-1 + } + + const scrollToIndex = ({ index, animated, viewOffset = 0, viewPosition = 0 }: ScrollPositionParams) => { + if (!scrollViewRef.current) return + // 通过索引映射表快速定位位置 + const position = indexMap.current[index] + if (!position) return + const [sectionIndex, itemIndex] = position.split('_') + const targetSectionIndex = Number(sectionIndex) || 0 + const targetItemIndex = itemIndex === 'header' + ? 0 + : itemIndex === 'footer' + ? convertedListData[targetSectionIndex].data.length + 1 + : Number(itemIndex) + 1 + scrollViewRef.current.scrollToLocation?.({ + itemIndex: targetItemIndex, + sectionIndex: targetSectionIndex, + animated, + viewOffset, + viewPosition + }) + } + + const getItemHeight = ({ sectionIndex, rowIndex }: { sectionIndex: number, rowIndex: number }) => { + if (!itemHeight) { + return 0 + } + if ((itemHeight as ItemHeightType).getter) { + const item = convertedListData[sectionIndex].data[rowIndex] + // 使用getOriginalIndex获取原始索引 + const originalIndex = getOriginalIndex(sectionIndex, rowIndex) + return (itemHeight as ItemHeightType).getter?.(item, originalIndex) || 0 + } else { + return (itemHeight as ItemHeightType).value || 0 + } + } + + const getSectionExtraHeight = ({ sectionIndex, type }: { sectionIndex: number, type: 'header' | 'footer' }) => { + const item = convertedListData[sectionIndex] + const isHeader = type === 'header' + if (!(isHeader ? item.hasSectionHeader : item.hasSectionFooter)) return 0 + const sectionExtraHeight = (isHeader ? sectionHeaderHeight : sectionFooterHeight) as ItemHeightType + if (sectionExtraHeight.getter) { + const sectionExtraData = isHeader ? item.headerData : item.footerData + return sectionExtraHeight.getter?.(sectionExtraData, getOriginalIndex(sectionIndex, type)) || 0 + } + return sectionExtraHeight.value || 0 + } + + const convertedListData = useMemo(() => { + const sections: Section[] = [] + let currentSection: Section | null = null + // 清空之前的索引映射 + indexMap.current = {} + // 清空反向索引映射 + reverseIndexMap.current = {} + + // 处理 listData 为空的情况 + if (!listData || !listData.length) { + return sections + } + + listData.forEach((item: ListItem, index: number) => { + if (item.isSectionHeader) { + // 如果已经存在一个 section,先把它添加到 sections 中 + if (currentSection) { + sections.push(currentSection) + } + // 创建新的 section + currentSection = { + headerData: item, + footerData: null, + data: [], + hasSectionHeader: true, + hasSectionFooter: false + } + // 为 section header 添加索引映射 + const sectionIndex = sections.length + indexMap.current[index] = `${sectionIndex}_header` + // 添加反向索引映射 + reverseIndexMap.current[`${sectionIndex}_header`] = index + } else if (item.isSectionFooter) { + // 如果没有当前 section,创建一个默认的 + if (!currentSection) { + // 创建默认section (无header的section) + currentSection = { + headerData: null, + footerData: null, + data: [], + hasSectionHeader: false, + hasSectionFooter: false + } + } + const sectionIndex = sections.length + currentSection.footerData = item + currentSection.hasSectionFooter = true + indexMap.current[index] = `${sectionIndex}_footer` + // 添加反向索引映射 + reverseIndexMap.current[`${sectionIndex}_footer`] = index + sections.push(currentSection) + currentSection = null + } else { + // 如果没有当前 section,创建一个默认的 + if (!currentSection) { + // 创建默认section (无header的section) + currentSection = { + headerData: null, + footerData: null, + data: [], + hasSectionHeader: false, + hasSectionFooter: false + } + } + // 将 item 添加到当前 section 的 data 中 + const itemIndex = currentSection.data.length + currentSection.data.push(item) + let sectionIndex + // 为 item 添加索引映射 - 存储格式为: "sectionIndex_itemIndex" + if (!currentSection.hasSectionHeader && sections.length === 0) { + // 在默认section中(第一个且无header) + sectionIndex = 0 + indexMap.current[index] = `${sectionIndex}_${itemIndex}` + } else { + // 在普通section中 + sectionIndex = sections.length + indexMap.current[index] = `${sectionIndex}_${itemIndex}` + } + // 添加反向索引映射 + reverseIndexMap.current[`${sectionIndex}_${itemIndex}`] = index + } + }) + // 添加最后一个 section + if (currentSection) { + sections.push(currentSection) + } + return sections + }, [listData]) + + const { getItemLayout } = useMemo(() => { + const layouts: Array<{ length: number, offset: number, index: number }> = [] + let offset = 0 + + if (useListHeader) { + // 计算列表头部的高度 + offset += listHeaderHeight + } + + // 遍历所有 sections + convertedListData.forEach((section: Section, sectionIndex: number) => { + // 添加 section header 的位置信息 + const headerHeight = getSectionExtraHeight({ sectionIndex, type: 'header' }) + layouts.push({ + length: headerHeight, + offset, + index: layouts.length + }) + offset += headerHeight + + // 添加该 section 中所有 items 的位置信息 + section.data.forEach((item: ListItem, itemIndex: number) => { + const contentHeight = getItemHeight({ sectionIndex, rowIndex: itemIndex }) + layouts.push({ + length: contentHeight, + offset, + index: layouts.length + }) + offset += contentHeight + }) + + // 添加该 section 尾部位置信息 + // 因为即使 sectionList 没传 renderSectionFooter,getItemLayout 中的 index 的计算也会包含尾部节点 + const footerHeight = getSectionExtraHeight({ sectionIndex, type: 'footer' }) + layouts.push({ + length: footerHeight, + offset, + index: layouts.length + }) + offset += footerHeight + }) + return { + itemLayouts: layouts, + getItemLayout: (data: any, index: number) => layouts[index] + } + }, [convertedListData, useListHeader, itemHeight.value, itemHeight.getter, sectionHeaderHeight.value, sectionHeaderHeight.getter, sectionFooterHeight.value, sectionFooterHeight.getter, listHeaderHeight]) + + const scrollAdditionalProps = extendObject( + { + style: [ + hasOwn(style, 'flex') || hasOwn(style, 'flexGrow') ? null : { flexGrow: 0 }, + { height, width }, + style, + layoutStyle + ], + alwaysBounceVertical: false, + alwaysBounceHorizontal: false, + scrollEventThrottle: scrollEventThrottle, + scrollsToTop: enableBackToTop, + showsVerticalScrollIndicator: showScrollbar, + onEndReachedThreshold, + ref: scrollViewRef, + bounces: enhanced ? bounces : false, + stickySectionHeadersEnabled: enableSticky, + onScroll: onScroll, + onEndReached: onEndReached + }, + refresherEnabled ? { refreshing } : null, + layoutProps + ) + + const nativeGesture = useMemo(() => { + const simultaneousHandlers = flatGesture(originSimultaneousHandlers) + const waitForHandlers = flatGesture(waitFor) + const gesture = Gesture.Native().withRef(sectionListGestureRef as any) + if (simultaneousHandlers && simultaneousHandlers.length) { + gesture.simultaneousWithExternalGesture(...simultaneousHandlers) + } + if (waitForHandlers && waitForHandlers.length) { + gesture.requireExternalGestureToFail(...waitForHandlers) + } + return gesture + }, [originSimultaneousHandlers, waitFor]) + + useImperativeHandle(ref, () => { + return { + gestureRef: sectionListGestureRef, + scrollToIndex + } + }) + + const innerProps = useInnerProps(extendObject({}, props, scrollAdditionalProps), [ + 'id', + 'enhanced', + 'height', + 'width', + 'list-data', + 'item-height', + 'section-header-height', + 'section-footer-height', + 'list-header-height', + 'list-header-data', + 'use-list-header', + 'list-footer-data', + 'use-list-footer', + 'genericrecycle-item', + 'genericsection-header', + 'genericsection-footer', + 'genericlist-header', + 'genericlist-footer', + 'show-scrollbar', + 'lower-threshold', + 'scroll-event-throttle', + 'enable-sticky', + 'enable-back-to-top', + 'end-reached-threshold', + 'refresher-triggered', + 'refresher-enabled', + 'bindrefresherrefresh', + 'bindscrolltolower', + 'bindscroll', + 'simultaneous-handlers', + 'wait-for' + ], { layoutRef }) + + // 使用 useMemo 获取 GenericComponent 并创建渲染函数,避免每次组件更新都重新创建函数引用导致不必要的重新渲染 + const renderItem = useMemo( + () => { + const ItemComponent = getGeneric(generichash, genericrecycleItem) + if (!ItemComponent) return undefined + return ({ item }: { item: ListItem }) => createElement(ItemComponent, { itemData: item }) + }, + [generichash, genericrecycleItem] + ) + + const renderSectionHeader = useMemo( + () => { + const SectionHeaderComponent = getGeneric(generichash, genericsectionHeader) + if (!SectionHeaderComponent) return undefined + return (sectionData: { section: RNSection }) => { + if (!sectionData.section.hasSectionHeader) return null + return createElement(SectionHeaderComponent, { itemData: sectionData.section.headerData }) + } + }, + [generichash, genericsectionHeader] + ) + + const renderSectionFooter = useMemo( + () => { + const SectionFooterComponent = getGeneric(generichash, genericsectionFooter) + if (!SectionFooterComponent) return undefined + return (sectionData: { section: RNSection }) => { + if (!sectionData.section.hasSectionFooter) return null + return createElement(SectionFooterComponent, { itemData: sectionData.section.footerData }) + } + }, + [generichash, genericsectionFooter] + ) + + const ListHeaderGenericComponent = useMemo( + () => { + if (!useListHeader) return null + return getGeneric(generichash, genericListHeader) + }, + [useListHeader, generichash, genericListHeader] + ) + + const ListFooterGenericComponent = useMemo( + () => { + if (!useListFooter) return null + return getGeneric(generichash, genericListFooter) + }, + [useListFooter, generichash, genericListFooter] + ) + + const ListHeaderComponent = useMemo( + () => { + if (!ListHeaderGenericComponent) return null + return createElement(ListHeaderGenericComponent, { listHeaderData }) + }, + [ListHeaderGenericComponent, listHeaderData] + ) + + const ListFooterComponent = useMemo( + () => { + if (!ListFooterGenericComponent) return null + return createElement(ListFooterGenericComponent, { listFooterData }) + }, + [ListFooterGenericComponent, listFooterData] + ) + + const sectionListProps: RNSectionListProps = extendObject( + { + sections: convertedListData, + renderItem: renderItem, + getItemLayout: getItemLayout, + ListHeaderComponent: useListHeader ? ListHeaderComponent : null, + ListFooterComponent: useListFooter ? ListFooterComponent : null, + renderSectionHeader: renderSectionHeader, + renderSectionFooter: renderSectionFooter, + refreshControl: refresherEnabled + ? createElement(RefreshControl, { + onRefresh: onRefresh, + refreshing: refreshing + }) + : undefined + }, + innerProps + ) + + return createElement( + GestureDetector, + { gesture: nativeGesture }, + createElement( + TypedSectionList, + sectionListProps + ) + ) +}) + +_SectionList.displayName = 'MpxSectionList' + +export default _SectionList diff --git a/packages/webpack-plugin/lib/runtime/components/react/utils.tsx b/packages/webpack-plugin/lib/runtime/components/react/utils.tsx index 1c2d8e4157..a2b2b82cab 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/utils.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/utils.tsx @@ -288,6 +288,7 @@ export interface TextPassThroughValueOptions { export interface GestureHandler { nodeRefs?: Array<{ getNodeInstance: () => { nodeRef: unknown } }> current?: unknown + handlerTag?: number } // ============================================================ @@ -1722,7 +1723,10 @@ export function flatGesture (gestures: Array = []) { return gesture.nodeRefs .map((item: { getNodeInstance: () => any }) => item.getNodeInstance()?.instance?.gestureRef || {}) } - return gesture?.current ? [gesture] : [] + if (gesture && (gesture.current || gesture.handlerTag !== undefined)) { + return [gesture] + } + return [] })) || [] }