diff --git a/src/config.json b/src/config.json
index 03457edc3f..fbd278151d 100644
--- a/src/config.json
+++ b/src/config.json
@@ -690,6 +690,19 @@
"author": "dsj",
"dd": false
},
+ {
+ "version": "3.0.0",
+ "name": "PickerView",
+ "type": "component",
+ "cName": "选择器视图",
+ "desc": "PickerView 是 Picker 的内容区域。",
+ "sort": 15,
+ "show": true,
+ "taro": true,
+ "v15": false,
+ "dd": true,
+ "author": "songsong"
+ },
{
"version": "3.0.0",
"name": "Radio",
diff --git a/src/packages/pickerview/__test__/__snapshots__/pickerview.spec.tsx.snap b/src/packages/pickerview/__test__/__snapshots__/pickerview.spec.tsx.snap
new file mode 100644
index 0000000000..723cd2df1b
--- /dev/null
+++ b/src/packages/pickerview/__test__/__snapshots__/pickerview.spec.tsx.snap
@@ -0,0 +1,318 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`should match base 1`] = `
+
+
+
+
+
+ 南京市
+
+
+ 无锡市
+
+
+ 海北藏族自治区
+
+
+ 北京市
+
+
+ 连云港市
+
+
+ 大庆市
+
+
+ 绥化市
+
+
+ 潍坊市
+
+
+ 乌鲁木齐市
+
+
+
+
+
+
+
+`;
+
+exports[`should match cascade 1`] = `
+
+
+
+
+
+ 北京 | 测试
+
+
+ 上海 | 测试
+
+
+
+
+
+
+ 朝阳区 | 测试
+
+
+ 海淀区 | 测试
+
+
+ 大兴区 | 测试
+
+
+ 东城区 | 测试
+
+
+ 西城区 | 测试
+
+
+ 丰台区 | 测试
+
+
+
+
+
+
+
+`;
+
+exports[`should match onchange 1`] = `
+
+`;
+
+exports[`should render tiled 1`] = `
+
+
+
+
+
+ 南京市
+
+
+ 无锡市
+
+
+ 海北藏族自治区
+
+
+ 北京市
+
+
+ 连云港市
+
+
+ 大庆市
+
+
+ 绥化市
+
+
+ 潍坊市
+
+
+ 乌鲁木齐市
+
+
+
+
+
+
+
+`;
+
+exports[`should render with Multi Column 1`] = `
+
+
+
+
+
+ 周一
+
+
+ 周二
+
+
+ 周三
+
+
+ 周四
+
+
+ 周五
+
+
+
+
+
+
+ 上午
+
+
+ 下午
+
+
+ 晚上
+
+
+
+
+
+
+
+`;
diff --git a/src/packages/pickerview/__test__/pickerview.spec.tsx b/src/packages/pickerview/__test__/pickerview.spec.tsx
new file mode 100644
index 0000000000..5dfd175869
--- /dev/null
+++ b/src/packages/pickerview/__test__/pickerview.spec.tsx
@@ -0,0 +1,217 @@
+import React, { useEffect, useRef, useState } from 'react'
+import { render, waitFor, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import PickerView from '../pickerview'
+import { PickerOptions } from '../types'
+
+const listData = [
+ [
+ { value: 1, label: '南京市' },
+ { value: 2, label: '无锡市' },
+ { value: 3, label: '海北藏族自治区' },
+ { value: 4, label: '北京市' },
+ { value: 5, label: '连云港市' },
+ { value: 8, label: '大庆市' },
+ { value: 9, label: '绥化市' },
+ { value: 10, label: '潍坊市' },
+ { value: 12, label: '乌鲁木齐市' },
+ ],
+]
+
+const MultiColumnData = [
+ [
+ { label: '周一', value: 'Monday' },
+ { label: '周二', value: 'Tuesday' },
+ { label: '周三', value: 'Wednesday' },
+ { label: '周四', value: 'Thursday' },
+ { label: '周五', value: 'Friday' },
+ ],
+ [
+ { label: '上午', value: 'Morning' },
+ { label: '下午', value: 'Afternoon' },
+ { label: '晚上', value: 'Evening' },
+ ],
+]
+
+const cascadeData = [
+ [
+ {
+ value: 1,
+ label: '北京',
+ children: [
+ {
+ value: 1,
+ label: '朝阳区',
+ },
+ {
+ value: 2,
+ label: '海淀区',
+ },
+ {
+ value: 3,
+ label: '大兴区',
+ },
+ {
+ value: 4,
+ label: '东城区',
+ },
+ {
+ value: 5,
+ label: '西城区',
+ },
+ {
+ value: 6,
+ label: '丰台区',
+ },
+ ],
+ },
+ {
+ value: 2,
+ label: '上海',
+ children: [
+ {
+ value: 1,
+ label: '黄埔区',
+ },
+ {
+ value: 2,
+ label: '长宁区',
+ },
+ {
+ value: 3,
+ label: '普陀区',
+ },
+ {
+ value: 4,
+ label: '杨浦区',
+ },
+ {
+ value: 5,
+ label: '浦东新区',
+ },
+ ],
+ },
+ ],
+]
+
+test('should match base', () => {
+ const { container } = render(
+
+ )
+ expect(container).toMatchSnapshot()
+ expect(screen.getByText('南京市')).toBeInTheDocument()
+})
+
+test('should render tiled', () => {
+ const { container } = render(
+
+ )
+ expect(container).toMatchSnapshot()
+})
+
+test('should render with Multi Column', () => {
+ const { container } = render(
+
+ )
+ const columns = container.querySelectorAll('.nut-pickerview-list')
+ expect(columns.length).toBe(2)
+
+ // 检查列内容
+ const firstColumn = columns[0]
+ expect(firstColumn.textContent).toContain('周一')
+ expect(firstColumn.textContent).toContain('周二')
+
+ const secondColumn = columns[1]
+ expect(secondColumn.textContent).toContain('上午')
+ expect(secondColumn.textContent).toContain('下午')
+ expect(container).toMatchSnapshot()
+})
+
+test('should match onchange', async () => {
+ const PenderContent = () => {
+ const [value, setValue] = useState([])
+ const [options, setInnerOptions] = useState([])
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setInnerOptions(listData)
+ setValue([1])
+ }, 1000)
+
+ return () => clearTimeout(timer) // 清理定时器
+ }, [])
+
+ return (
+ {
+ if (value[0] === 1) {
+ setValue([3])
+ }
+ }}
+ />
+ )
+ }
+
+ const { container } = render()
+
+ await waitFor(() => {
+ expect(container).toMatchSnapshot()
+ })
+})
+
+test('should match cascade', () => {
+ const { container } = render(
+ `${item.label} | 测试`}
+ options={cascadeData}
+ onChange={() => {}}
+ />
+ )
+ expect(container).toMatchSnapshot()
+})
+test('should match stopMomentum', async () => {
+ const PenderContent = () => {
+ function useRefs() {
+ const refs = React.useRef([])
+
+ const setRefs = React.useCallback(
+ (index: number) => (el: HTMLDivElement) => {
+ if (el) refs.current[index] = el
+ },
+ []
+ )
+
+ const reset = React.useCallback(() => {
+ refs.current = []
+ }, [])
+
+ return [refs.current, setRefs as any, reset]
+ }
+
+ const [refs, setRefs] = useRefs()
+ const first = useRef(true)
+
+ return (
+ {
+ if (!first.current) {
+ refs[0].stopMomentum()
+ } else {
+ first.current = false
+ }
+ }}
+ />
+ )
+ }
+ render()
+})
diff --git a/src/packages/pickerview/demo.taro.tsx b/src/packages/pickerview/demo.taro.tsx
new file mode 100644
index 0000000000..8f34000096
--- /dev/null
+++ b/src/packages/pickerview/demo.taro.tsx
@@ -0,0 +1,67 @@
+import React from 'react'
+import Taro from '@tarojs/taro'
+import { ScrollView, View } from '@tarojs/components'
+import { useTranslate } from '@/sites/assets/locale/taro'
+import Header from '@/sites/components/header'
+import Demo1 from './demos/taro/demo1'
+import Demo2 from './demos/taro/demo2'
+import Demo3 from './demos/taro/demo3'
+import Demo4 from './demos/taro/demo4'
+import Demo5 from './demos/taro/demo5'
+import Demo6 from './demos/taro/demo6'
+import Demo7 from './demos/taro/demo7'
+
+const PickerViewDemo = () => {
+ const [translated] = useTranslate({
+ 'zh-CN': {
+ title: '基础用法',
+ adjustHeight: '自适应高度',
+ multiColumn: '多列',
+ controlled: '受控',
+ tiled: '平铺',
+ cascade: '级联',
+ asynchronous: '异步数据',
+ },
+ 'en-US': {
+ title: 'Basic Usage',
+ adjustHeight: 'Adjust Height',
+ multiColumn: 'Multi Column',
+ controlled: 'Controlled',
+ tiled: 'Tiled',
+ cascade: 'Cascade',
+ asynchronous: 'Asynchronous',
+ },
+ 'zh-TW': {
+ title: '基礎用法',
+ adjustHeight: '自適應高度',
+ multiColumn: '多列',
+ controlled: '受控',
+ tiled: '平鋪',
+ cascade: '級聯',
+ asynchronous: '異步數據',
+ },
+ })
+ return (
+ <>
+
+
+ {translated.title}
+
+ {translated.controlled}
+
+ {translated.adjustHeight}
+
+ {translated.multiColumn}
+
+ {translated.tiled}
+
+ {translated.cascade}
+
+ {translated.asynchronous}
+
+
+ >
+ )
+}
+
+export default PickerViewDemo
diff --git a/src/packages/pickerview/demo.tsx b/src/packages/pickerview/demo.tsx
new file mode 100644
index 0000000000..8d95022679
--- /dev/null
+++ b/src/packages/pickerview/demo.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import { useTranslate } from '@/sites/assets/locale'
+import Demo1 from './demos/h5/demo1'
+import Demo2 from './demos/h5/demo2'
+import Demo3 from './demos/h5/demo3'
+import Demo4 from './demos/h5/demo4'
+import Demo5 from './demos/h5/demo5'
+import Demo6 from './demos/h5/demo6'
+import Demo7 from './demos/h5/demo7'
+
+const PickerViewDemo = () => {
+ const [translated] = useTranslate({
+ 'zh-CN': {
+ title: '基础用法',
+ adjustHeight: '自适应高度',
+ multiColumn: '多列',
+ controlled: '受控',
+ tiled: '平铺',
+ cascade: '级联',
+ asynchronous: '异步数据',
+ },
+ 'en-US': {
+ title: 'Basic Usage',
+ adjustHeight: 'Adjust Height',
+ multiColumn: 'Multi Column',
+ controlled: 'Controlled',
+ tiled: 'Tiled',
+ cascade: 'Cascade',
+ asynchronous: 'Asynchronous',
+ },
+ 'zh-TW': {
+ title: '基礎用法',
+ adjustHeight: '自適應高度',
+ multiColumn: '多列',
+ controlled: '受控',
+ tiled: '平鋪',
+ cascade: '級聯',
+ asynchronous: '異步數據',
+ },
+ })
+ return (
+
+
{translated.title}
+
+ {translated.controlled}
+
+ {translated.adjustHeight}
+
+ {translated.multiColumn}
+
+ {translated.tiled}
+
+ {translated.cascade}
+
+ {translated.asynchronous}
+
+
+ )
+}
+
+export default PickerViewDemo
diff --git a/src/packages/pickerview/demos/h5/demo1.tsx b/src/packages/pickerview/demos/h5/demo1.tsx
new file mode 100644
index 0000000000..dbadb8c5a7
--- /dev/null
+++ b/src/packages/pickerview/demos/h5/demo1.tsx
@@ -0,0 +1,33 @@
+import React from 'react'
+import { PickerView, Cell } from '@nutui/nutui-react'
+
+const Demo1 = () => {
+ const listData = [
+ [
+ { value: 1, label: '南京市' },
+ { value: 2, label: '无锡市' },
+ { value: 3, label: '海北藏族自治区' },
+ { value: 4, label: '北京市' },
+ { value: 5, label: '连云港市' },
+ { value: 8, label: '大庆市' },
+ { value: 9, label: '绥化市' },
+ { value: 10, label: '潍坊市' },
+ { value: 12, label: '乌鲁木齐市' },
+ ],
+ ]
+
+ return (
+ <>
+
+ {
+ console.log('onChange', value, selectedOptions)
+ }}
+ />
+ |
+ >
+ )
+}
+export default Demo1
diff --git a/src/packages/pickerview/demos/h5/demo2.tsx b/src/packages/pickerview/demos/h5/demo2.tsx
new file mode 100644
index 0000000000..894b633b01
--- /dev/null
+++ b/src/packages/pickerview/demos/h5/demo2.tsx
@@ -0,0 +1,34 @@
+import React from 'react'
+import { PickerView, Cell } from '@nutui/nutui-react'
+
+const Demo2 = () => {
+ const listData = [
+ [
+ { value: 1, label: '南京市' },
+ { value: 2, label: '无锡市' },
+ { value: 3, label: '海北藏族自治区' },
+ { value: 4, label: '北京市' },
+ { value: 5, label: '连云港市' },
+ { value: 8, label: '大庆市' },
+ { value: 9, label: '绥化市' },
+ { value: 10, label: '潍坊市' },
+ { value: 12, label: '乌鲁木齐市' },
+ ],
+ ]
+
+ return (
+ <>
+
+ {
+ console.log('onChange', value, selectedOptions)
+ }}
+ />
+ |
+ >
+ )
+}
+export default Demo2
diff --git a/src/packages/pickerview/demos/h5/demo3.tsx b/src/packages/pickerview/demos/h5/demo3.tsx
new file mode 100644
index 0000000000..d23f719786
--- /dev/null
+++ b/src/packages/pickerview/demos/h5/demo3.tsx
@@ -0,0 +1,39 @@
+import React, { useState } from 'react'
+import { PickerView, Cell } from '@nutui/nutui-react'
+import isEqual from 'react-fast-compare'
+
+const Demo3 = () => {
+ const listData = [
+ [
+ { label: '周一', value: 'Monday' },
+ { label: '周二', value: 'Tuesday' },
+ { label: '周三', value: 'Wednesday' },
+ { label: '周四', value: 'Thursday' },
+ { label: '周五', value: 'Friday' },
+ ],
+ [
+ { label: '上午', value: 'Morning' },
+ { label: '下午', value: 'Afternoon' },
+ { label: '晚上', value: 'Evening' },
+ ],
+ ]
+ const [value, setValue] = useState(['Tuesday', 'Evening'])
+
+ return (
+ <>
+
+ {
+ console.log('onChange', value, selectedOptions)
+ if (isEqual(value, ['Tuesday', 'Afternoon'])) {
+ setValue(['Monday', 'Evening'])
+ }
+ }}
+ />
+ |
+ >
+ )
+}
+export default Demo3
diff --git a/src/packages/pickerview/demos/h5/demo4.tsx b/src/packages/pickerview/demos/h5/demo4.tsx
new file mode 100644
index 0000000000..cfa2ab063e
--- /dev/null
+++ b/src/packages/pickerview/demos/h5/demo4.tsx
@@ -0,0 +1,38 @@
+import React, { useState } from 'react'
+import { PickerView, Cell } from '@nutui/nutui-react'
+
+const Demo4 = () => {
+ const listData = [
+ [
+ { value: 1, label: '南京市' },
+ { value: 2, label: '无锡市' },
+ { value: 3, label: '海北藏族自治区' },
+ { value: 4, label: '北京市' },
+ { value: 5, label: '连云港市' },
+ { value: 8, label: '大庆市' },
+ { value: 9, label: '绥化市' },
+ { value: 10, label: '潍坊市' },
+ { value: 12, label: '乌鲁木齐市' },
+ ],
+ ]
+
+ const [value, setValue] = useState([2])
+
+ return (
+ <>
+
+ {
+ console.log('onChange', value, selectedOptions)
+ if (value[0] === 3) {
+ setValue([1])
+ }
+ }}
+ />
+ |
+ >
+ )
+}
+export default Demo4
diff --git a/src/packages/pickerview/demos/h5/demo5.tsx b/src/packages/pickerview/demos/h5/demo5.tsx
new file mode 100644
index 0000000000..e9795b79f4
--- /dev/null
+++ b/src/packages/pickerview/demos/h5/demo5.tsx
@@ -0,0 +1,35 @@
+import React from 'react'
+import { PickerView, Cell } from '@nutui/nutui-react'
+
+const Demo5 = () => {
+ const listData = [
+ [
+ { value: 1, label: '南京市' },
+ { value: 2, label: '无锡市' },
+ { value: 3, label: '海北藏族自治区' },
+ { value: 4, label: '北京市' },
+ { value: 5, label: '连云港市' },
+ { value: 8, label: '大庆市' },
+ { value: 9, label: '绥化市' },
+ { value: 10, label: '潍坊市' },
+ { value: 12, label: '乌鲁木齐市' },
+ ],
+ ]
+
+ return (
+ <>
+
+ {
+ console.log('onChange', value, selectedOptions)
+ }}
+ />
+ |
+ >
+ )
+}
+export default Demo5
diff --git a/src/packages/pickerview/demos/h5/demo6.tsx b/src/packages/pickerview/demos/h5/demo6.tsx
new file mode 100644
index 0000000000..44aa46d8ae
--- /dev/null
+++ b/src/packages/pickerview/demos/h5/demo6.tsx
@@ -0,0 +1,80 @@
+import React from 'react'
+import { PickerView, Cell } from '@nutui/nutui-react'
+
+const Demo6 = () => {
+ const listData = [
+ [
+ {
+ value: 1,
+ label: '北京',
+ children: [
+ {
+ value: 1,
+ label: '朝阳区',
+ },
+ {
+ value: 2,
+ label: '海淀区',
+ },
+ {
+ value: 3,
+ label: '大兴区',
+ },
+ {
+ value: 4,
+ label: '东城区',
+ },
+ {
+ value: 5,
+ label: '西城区',
+ },
+ {
+ value: 6,
+ label: '丰台区',
+ },
+ ],
+ },
+ {
+ value: 2,
+ label: '上海',
+ children: [
+ {
+ value: 1,
+ label: '黄埔区',
+ },
+ {
+ value: 2,
+ label: '长宁区',
+ },
+ {
+ value: 3,
+ label: '普陀区',
+ },
+ {
+ value: 4,
+ label: '杨浦区',
+ },
+ {
+ value: 5,
+ label: '浦东新区',
+ },
+ ],
+ },
+ ],
+ ]
+
+ return (
+ <>
+
+ {
+ console.log('onChange', value, selectedOptions)
+ }}
+ />
+ |
+ >
+ )
+}
+export default Demo6
diff --git a/src/packages/pickerview/demos/h5/demo7.tsx b/src/packages/pickerview/demos/h5/demo7.tsx
new file mode 100644
index 0000000000..d245a189c5
--- /dev/null
+++ b/src/packages/pickerview/demos/h5/demo7.tsx
@@ -0,0 +1,38 @@
+import React, { useEffect, useState } from 'react'
+import { PickerView, Cell, PickerOptions } from '@nutui/nutui-react'
+
+const Demo7 = () => {
+ const [columnsList, setColumnsList] = useState([] as PickerOptions[])
+
+ useEffect(() => {
+ setTimeout(() => {
+ setColumnsList([
+ [
+ { value: 1, label: '南京市' },
+ { value: 2, label: '无锡市' },
+ { value: 3, label: '海北藏族自治区' },
+ { value: 4, label: '北京市' },
+ { value: 5, label: '连云港市' },
+ { value: 8, label: '大庆市' },
+ { value: 9, label: '绥化市' },
+ { value: 10, label: '潍坊市' },
+ { value: 12, label: '乌鲁木齐市' },
+ ],
+ ])
+ }, 3000)
+ }, [])
+ return (
+ <>
+
+ {
+ console.log('onChange', value, selectedOptions)
+ }}
+ />
+ |
+ >
+ )
+}
+export default Demo7
diff --git a/src/packages/pickerview/demos/taro/demo1.tsx b/src/packages/pickerview/demos/taro/demo1.tsx
new file mode 100644
index 0000000000..44c4bc6f3b
--- /dev/null
+++ b/src/packages/pickerview/demos/taro/demo1.tsx
@@ -0,0 +1,33 @@
+import React from 'react'
+import { PickerView, Cell } from '@nutui/nutui-react-taro'
+
+const Demo1 = () => {
+ const listData = [
+ [
+ { value: 1, label: '南京市' },
+ { value: 2, label: '无锡市' },
+ { value: 3, label: '海北藏族自治区' },
+ { value: 4, label: '北京市' },
+ { value: 5, label: '连云港市' },
+ { value: 8, label: '大庆市' },
+ { value: 9, label: '绥化市' },
+ { value: 10, label: '潍坊市' },
+ { value: 12, label: '乌鲁木齐市' },
+ ],
+ ]
+
+ return (
+ <>
+
+ {
+ console.log('onChange', value, selectedOptions)
+ }}
+ />
+ |
+ >
+ )
+}
+export default Demo1
diff --git a/src/packages/pickerview/demos/taro/demo2.tsx b/src/packages/pickerview/demos/taro/demo2.tsx
new file mode 100644
index 0000000000..838e0ea546
--- /dev/null
+++ b/src/packages/pickerview/demos/taro/demo2.tsx
@@ -0,0 +1,34 @@
+import React from 'react'
+import { PickerView, Cell } from '@nutui/nutui-react-taro'
+
+const Demo2 = () => {
+ const listData = [
+ [
+ { value: 1, label: '南京市' },
+ { value: 2, label: '无锡市' },
+ { value: 3, label: '海北藏族自治区' },
+ { value: 4, label: '北京市' },
+ { value: 5, label: '连云港市' },
+ { value: 8, label: '大庆市' },
+ { value: 9, label: '绥化市' },
+ { value: 10, label: '潍坊市' },
+ { value: 12, label: '乌鲁木齐市' },
+ ],
+ ]
+
+ return (
+ <>
+
+ {
+ console.log('onChange', value, selectedOptions)
+ }}
+ />
+ |
+ >
+ )
+}
+export default Demo2
diff --git a/src/packages/pickerview/demos/taro/demo3.tsx b/src/packages/pickerview/demos/taro/demo3.tsx
new file mode 100644
index 0000000000..9c7b9cd1f9
--- /dev/null
+++ b/src/packages/pickerview/demos/taro/demo3.tsx
@@ -0,0 +1,39 @@
+import React, { useState } from 'react'
+import { PickerView, Cell } from '@nutui/nutui-react-taro'
+import isEqual from 'react-fast-compare'
+
+const Demo3 = () => {
+ const listData = [
+ [
+ { label: '周一', value: 'Monday' },
+ { label: '周二', value: 'Tuesday' },
+ { label: '周三', value: 'Wednesday' },
+ { label: '周四', value: 'Thursday' },
+ { label: '周五', value: 'Friday' },
+ ],
+ [
+ { label: '上午', value: 'Morning' },
+ { label: '下午', value: 'Afternoon' },
+ { label: '晚上', value: 'Evening' },
+ ],
+ ]
+ const [value, setValue] = useState(['Tuesday', 'Evening'])
+
+ return (
+ <>
+
+ {
+ console.log('onChange', value, selectedOptions)
+ if (isEqual(value, ['Tuesday', 'Afternoon'])) {
+ setValue(['Monday', 'Evening'])
+ }
+ }}
+ />
+ |
+ >
+ )
+}
+export default Demo3
diff --git a/src/packages/pickerview/demos/taro/demo4.tsx b/src/packages/pickerview/demos/taro/demo4.tsx
new file mode 100644
index 0000000000..e6c5666a3e
--- /dev/null
+++ b/src/packages/pickerview/demos/taro/demo4.tsx
@@ -0,0 +1,38 @@
+import React, { useState } from 'react'
+import { PickerView, Cell } from '@nutui/nutui-react-taro'
+
+const Demo4 = () => {
+ const listData = [
+ [
+ { value: 1, label: '南京市' },
+ { value: 2, label: '无锡市' },
+ { value: 3, label: '海北藏族自治区' },
+ { value: 4, label: '北京市' },
+ { value: 5, label: '连云港市' },
+ { value: 8, label: '大庆市' },
+ { value: 9, label: '绥化市' },
+ { value: 10, label: '潍坊市' },
+ { value: 12, label: '乌鲁木齐市' },
+ ],
+ ]
+
+ const [value, setValue] = useState([2])
+
+ return (
+ <>
+
+ {
+ console.log('onChange', value, selectedOptions)
+ if (value[0] === 3) {
+ setValue([1])
+ }
+ }}
+ />
+ |
+ >
+ )
+}
+export default Demo4
diff --git a/src/packages/pickerview/demos/taro/demo5.tsx b/src/packages/pickerview/demos/taro/demo5.tsx
new file mode 100644
index 0000000000..f313af7bb5
--- /dev/null
+++ b/src/packages/pickerview/demos/taro/demo5.tsx
@@ -0,0 +1,35 @@
+import React from 'react'
+import { PickerView, Cell } from '@nutui/nutui-react-taro'
+
+const Demo5 = () => {
+ const listData = [
+ [
+ { value: 1, label: '南京市' },
+ { value: 2, label: '无锡市' },
+ { value: 3, label: '海北藏族自治区' },
+ { value: 4, label: '北京市' },
+ { value: 5, label: '连云港市' },
+ { value: 8, label: '大庆市' },
+ { value: 9, label: '绥化市' },
+ { value: 10, label: '潍坊市' },
+ { value: 12, label: '乌鲁木齐市' },
+ ],
+ ]
+
+ return (
+ <>
+
+ {
+ console.log('onChange', value, selectedOptions)
+ }}
+ />
+ |
+ >
+ )
+}
+export default Demo5
diff --git a/src/packages/pickerview/demos/taro/demo6.tsx b/src/packages/pickerview/demos/taro/demo6.tsx
new file mode 100644
index 0000000000..9cf8ae0ac2
--- /dev/null
+++ b/src/packages/pickerview/demos/taro/demo6.tsx
@@ -0,0 +1,80 @@
+import React from 'react'
+import { PickerView, Cell } from '@nutui/nutui-react-taro'
+
+const Demo6 = () => {
+ const listData = [
+ [
+ {
+ value: 1,
+ label: '北京',
+ children: [
+ {
+ value: 1,
+ label: '朝阳区',
+ },
+ {
+ value: 2,
+ label: '海淀区',
+ },
+ {
+ value: 3,
+ label: '大兴区',
+ },
+ {
+ value: 4,
+ label: '东城区',
+ },
+ {
+ value: 5,
+ label: '西城区',
+ },
+ {
+ value: 6,
+ label: '丰台区',
+ },
+ ],
+ },
+ {
+ value: 2,
+ label: '上海',
+ children: [
+ {
+ value: 1,
+ label: '黄埔区',
+ },
+ {
+ value: 2,
+ label: '长宁区',
+ },
+ {
+ value: 3,
+ label: '普陀区',
+ },
+ {
+ value: 4,
+ label: '杨浦区',
+ },
+ {
+ value: 5,
+ label: '浦东新区',
+ },
+ ],
+ },
+ ],
+ ]
+
+ return (
+ <>
+
+ {
+ console.log('onChange', value, selectedOptions)
+ }}
+ />
+ |
+ >
+ )
+}
+export default Demo6
diff --git a/src/packages/pickerview/demos/taro/demo7.tsx b/src/packages/pickerview/demos/taro/demo7.tsx
new file mode 100644
index 0000000000..a32defecfc
--- /dev/null
+++ b/src/packages/pickerview/demos/taro/demo7.tsx
@@ -0,0 +1,38 @@
+import React, { useEffect, useState } from 'react'
+import { PickerView, Cell, PickerOptions } from '@nutui/nutui-react-taro'
+
+const Demo7 = () => {
+ const [columnsList, setColumnsList] = useState([] as PickerOptions[])
+
+ useEffect(() => {
+ setTimeout(() => {
+ setColumnsList([
+ [
+ { value: 1, label: '南京市' },
+ { value: 2, label: '无锡市' },
+ { value: 3, label: '海北藏族自治区' },
+ { value: 4, label: '北京市' },
+ { value: 5, label: '连云港市' },
+ { value: 8, label: '大庆市' },
+ { value: 9, label: '绥化市' },
+ { value: 10, label: '潍坊市' },
+ { value: 12, label: '乌鲁木齐市' },
+ ],
+ ])
+ }, 3000)
+ }, [])
+ return (
+ <>
+
+ {
+ console.log('onChange', value, selectedOptions)
+ }}
+ />
+ |
+ >
+ )
+}
+export default Demo7
diff --git a/src/packages/pickerview/doc.en-US.md b/src/packages/pickerview/doc.en-US.md
new file mode 100644
index 0000000000..42006732bd
--- /dev/null
+++ b/src/packages/pickerview/doc.en-US.md
@@ -0,0 +1,94 @@
+# PickerView
+
+The PickerView is the content area of the Picker.
+
+## Import
+
+```tsx
+import { PickerView } from '@nutui/nutui-react'
+```
+
+## Demo
+
+### Basic Usage
+
+:::demo
+
+
+
+:::
+
+### Controlled
+
+:::demo
+
+
+
+:::
+
+### Adjust Height
+
+:::demo
+
+
+
+:::
+
+### Multi Column
+
+:::demo
+
+
+
+:::
+
+### Tiled
+
+:::demo
+
+
+
+:::
+
+### Cascade
+
+:::demo
+
+
+
+:::
+
+## PickerView
+
+### Props
+
+| Property | Description | Type | Default |
+| --- | --- | --- | --- |
+| options | Tabular data | `PickerOptions[]` | `[]` |
+| value | Selected value, controlled | `PickerValue[]` | `[]` |
+| defaultValue | Default value | `PickerValue[]` | `[]` |
+| threeDimensional | Whether to enable 3D effect | `boolean` | `true` |
+| duration | The duration of inertial rolling during rapid sliding, in ms | `string` \| `number` | `1000` |
+| onChange | Called when the value of each column changes | `({value, index, selectedOptions}) => void` | `-` |
+
+### PickerOptionItem
+
+| Property | Description | Type | Default |
+| --- | --- | --- | --- |
+| label | Text of column | `string` \| `number` | `-` |
+| value | Value of column | `string` \| `number` | `-` |
+| children | Cascader Option | `PickerOptionItem[]` | `-` |
+
+## Theming
+
+### CSS Variables
+
+The component provides the following CSS variables, which can be used to customize styles. Please refer to [ConfigProvider component](#/en-US/component/configprovider).
+
+| Name | Description | Default |
+| --- | --- | --- |
+| \--nutui-picker-item-height | Height of each data item on the panel | `36px` |
+| \--nutui-picker-item-text-color | The color of each piece of data in the panel | `$color-title` |
+| \--nutui-picker-item-text-font-size | The font size of each piece of data in the panel | `$font-size-base` |
+| \--nutui-picker-item-active-line-border | The border value currently selected by the panel | `1px solid $color-border` |
+| \--nut-picker-mask-background | Panel shade gradient value | `linear-gradient(180deg, var(--nutui-white-12), var(--nutui-white-7)),linear-gradient(0deg, var(--nutui-white-12), var(--nutui-white-7))` |
diff --git a/src/packages/pickerview/doc.md b/src/packages/pickerview/doc.md
new file mode 100644
index 0000000000..ef3cb7dc18
--- /dev/null
+++ b/src/packages/pickerview/doc.md
@@ -0,0 +1,94 @@
+# PickerView 选择器视图
+
+PickerView 是 Picker 的内容区域。
+
+## 引入
+
+```tsx
+import { PickerView } from '@nutui/nutui-react'
+```
+
+## 示例代码
+
+### 基础用法
+
+:::demo
+
+
+
+:::
+
+### 受控
+
+:::demo
+
+
+
+:::
+
+### 自定义高度
+
+:::demo
+
+
+
+:::
+
+### 多列
+
+:::demo
+
+
+
+:::
+
+### 平铺
+
+:::demo
+
+
+
+:::
+
+### 级联
+
+:::demo
+
+
+
+:::
+
+## PickerView
+
+### Props
+
+| 属性 | 说明 | 类型 | 默认值 |
+| --- | --- | --- | --- |
+| options | 列表数据 | `PickerOptions[]` | `[]` |
+| value | 选中值,受控 | `PickerValue[]` | `[]` |
+| defaultValue | 默认选中 | `PickerValue[]` | `[]` |
+| threeDimensional | 是否开启3D效果 | `boolean` | `true` |
+| duration | 快速滑动时惯性滚动的时长,单位 ms | `string` \| `number` | `1000` |
+| onChange | 每一列值变更时调用 | `({value, index, selectedOptions}) => void` | `-` |
+
+### PickerOptionItem
+
+| 属性 | 说明 | 类型 | 默认值 |
+| --- | --- | --- | --- |
+| label | 选项的文字内容 | `string` \| `number` | `-` |
+| value | 选项对应的值,且唯一 | `string` \| `number` | `-` |
+| children | 用于级联选项 | `PickerOptionItem[]` | `-` |
+
+## 主题定制
+
+### 样式变量
+
+组件提供了下列 CSS 变量,可用于自定义样式,使用方法请参考 [ConfigProvider 组件](#/zh-CN/component/configprovider)。
+
+| 名称 | 说明 | 默认值 |
+| --- | --- | --- |
+| \--nutui-picker-item-height | 面板每条数据高度 | `36px` |
+| \--nutui-picker-item-text-color | 面板每条数据的字色 | `$color-title` |
+| \--nutui-picker-item-text-font-size | 面板每条数据的字号 | `$font-size-base` |
+| \--nutui-picker-item-active-line-border | 面板当前选中的border值 | `1px solid $color-border` |
+| \--nut-picker-mask-background | 面板遮挡区渐变值 | `linear-gradient(180deg, var(--nutui-white-12), var(--nutui-white-7)),linear-gradient(0deg, var(--nutui-white-12), var(--nutui-white-7))` |
diff --git a/src/packages/pickerview/doc.taro.md b/src/packages/pickerview/doc.taro.md
new file mode 100644
index 0000000000..344fc9dee3
--- /dev/null
+++ b/src/packages/pickerview/doc.taro.md
@@ -0,0 +1,94 @@
+# PickerView 选择器视图
+
+PickerView 是 Picker 的内容区域。
+
+## 引入
+
+```tsx
+import { PickerView } from '@nutui/nutui-react-taro'
+```
+
+## 示例代码
+
+### 基础用法
+
+:::demo
+
+
+
+:::
+
+### 受控
+
+:::demo
+
+
+
+:::
+
+### 自定义高度
+
+:::demo
+
+
+
+:::
+
+### 多列
+
+:::demo
+
+
+
+:::
+
+### 平铺
+
+:::demo
+
+
+
+:::
+
+### 级联
+
+:::demo
+
+
+
+:::
+
+## PickerView
+
+### Props
+
+| 属性 | 说明 | 类型 | 默认值 |
+| --- | --- | --- | --- |
+| options | 列表数据 | `PickerOptions[]` | `[]` |
+| value | 选中值,受控 | `PickerValue[]` | `[]` |
+| defaultValue | 默认选中 | `PickerValue[]` | `[]` |
+| threeDimensional | 是否开启3D效果 | `boolean` | `true` |
+| duration | 快速滑动时惯性滚动的时长,单位 ms | `string` \| `number` | `1000` |
+| onChange | 每一列值变更时调用 | `({value, index, selectedOptions}) => void` | `-` |
+
+### PickerOptionItem
+
+| 属性 | 说明 | 类型 | 默认值 |
+| --- | --- | --- | --- |
+| label | 选项的文字内容 | `string` \| `number` | `-` |
+| value | 选项对应的值,且唯一 | `string` \| `number` | `-` |
+| children | 用于级联选项 | `PickerOptionItem[]` | `-` |
+
+## 主题定制
+
+### 样式变量
+
+组件提供了下列 CSS 变量,可用于自定义样式,使用方法请参考 [ConfigProvider 组件](#/zh-CN/component/configprovider)。
+
+| 名称 | 说明 | 默认值 |
+| --- | --- | --- |
+| \--nutui-picker-item-height | 面板每条数据高度 | `36px` |
+| \--nutui-picker-item-text-color | 面板每条数据的字色 | `$color-title` |
+| \--nutui-picker-item-text-font-size | 面板每条数据的字号 | `$font-size-base` |
+| \--nutui-picker-item-active-line-border | 面板当前选中的border值 | `1px solid $color-border` |
+| \--nut-picker-mask-background | 面板遮挡区渐变值 | `linear-gradient(180deg, var(--nutui-white-12), var(--nutui-white-7)),linear-gradient(0deg, var(--nutui-white-12), var(--nutui-white-7))` |
diff --git a/src/packages/pickerview/doc.zh-TW.md b/src/packages/pickerview/doc.zh-TW.md
new file mode 100644
index 0000000000..073f5b8e4f
--- /dev/null
+++ b/src/packages/pickerview/doc.zh-TW.md
@@ -0,0 +1,94 @@
+# PickerView 選擇器視圖
+
+PickerView 是 Picker 的內容區域。
+
+## 引入
+
+```tsx
+import { PickerView } from '@nutui/nutui-react-taro'
+```
+
+## 示例代碼
+
+### 基礎用法
+
+:::demo
+
+
+
+:::
+
+### 受控
+
+:::demo
+
+
+
+:::
+
+### 自定義高度
+
+:::demo
+
+
+
+:::
+
+### 多列
+
+:::demo
+
+
+
+:::
+
+### 平鋪
+
+:::demo
+
+
+
+:::
+
+### 級聯
+
+:::demo
+
+
+
+:::
+
+## PickerView
+
+### Props
+
+| 屬性 | 說明 | 類型 | 默認值 |
+| --- | --- | --- | --- |
+| options | 列錶數據 | `PickerOptions[]` | `[]` |
+| value | 選中值,受控 | `PickerValue[]` | `[]` |
+| defaultValue | 默認選中 | `PickerValue[]` | `[]` |
+| threeDimensional | 是否開啟3D效果 | `boolean` | `true` |
+| duration | 快速滑動時慣性滾動的時長,單位 ms | `string` \| `number` | `1000` |
+| onChange | 每一列值變更時調用 | `({value, index, selectedOptions}) => void` | `-` |
+
+### PickerOptionItem
+
+| 屬性 | 說明 | 類型 | 默認值 |
+| --- | --- | --- | --- |
+| label | 選項的文字內容 | `string` \| `number` | `-` |
+| value | 選項對應的值,且唯一 | `string` \| `number` | `-` |
+| children | 用於級聯選項 | `PickerOptionItem[]` | `-` |
+
+## 主題定制
+
+### 樣式變量
+
+組件提供了下列 CSS 變量,可用於自定義樣式,使用方法請參考 [ConfigProvider 組件](#/zh-CN/component/configprovider)。
+
+| 名稱 | 說明 | 默認值 |
+| --- | --- | --- |
+| \--nutui-picker-item-height | 面闆每條數據高度 | `36px` |
+| \--nutui-picker-item-text-color | 面闆每條數據的字色 | `$color-title` |
+| \--nutui-picker-item-text-font-size | 面闆每條數據的字號 | `$font-size-base` |
+| \--nutui-picker-item-active-line-border | 面闆當前選中的border值 | `1px solid $color-border` |
+| \--nut-picker-mask-background | 面闆遮擋區漸變值 | `linear-gradient(180deg, var(--nutui-white-12), var(--nutui-white-7)),linear-gradient(0deg, var(--nutui-white-12), var(--nutui-white-7))` |
diff --git a/src/packages/pickerview/index.taro.ts b/src/packages/pickerview/index.taro.ts
new file mode 100644
index 0000000000..1bd5f2a397
--- /dev/null
+++ b/src/packages/pickerview/index.taro.ts
@@ -0,0 +1,11 @@
+import PickerView from './pickerview.taro'
+
+export type {
+ PickerViewProps,
+ PickerOptionItem,
+ PickerRollerProps,
+ PickerValue,
+ PickerOptions,
+ PickerOnChangeCallbackParameter,
+} from './types'
+export default PickerView
diff --git a/src/packages/pickerview/index.ts b/src/packages/pickerview/index.ts
new file mode 100644
index 0000000000..15c781a272
--- /dev/null
+++ b/src/packages/pickerview/index.ts
@@ -0,0 +1,11 @@
+import PickerView from './pickerview'
+
+export type {
+ PickerViewProps,
+ PickerOptionItem,
+ PickerRollerProps,
+ PickerValue,
+ PickerOptions,
+ PickerOnChangeCallbackParameter,
+} from './types'
+export default PickerView
diff --git a/src/packages/pickerview/pickerroller.taro.tsx b/src/packages/pickerview/pickerroller.taro.tsx
new file mode 100644
index 0000000000..10c38d145f
--- /dev/null
+++ b/src/packages/pickerview/pickerroller.taro.tsx
@@ -0,0 +1,259 @@
+import React, {
+ useState,
+ useEffect,
+ useRef,
+ ForwardRefRenderFunction,
+ useImperativeHandle,
+} from 'react'
+import { View } from '@tarojs/components'
+import { useTouch } from '@/hooks/use-touch'
+import { passiveSupported } from '@/utils/supports-passive'
+import { PickerRollerProps, PickerOptionItem } from './types'
+import { web } from '@/utils/platform-taro'
+import { preventDefault } from '@/utils'
+import { momentum, useStyles } from './utils'
+
+const InternalPickerRoller: ForwardRefRenderFunction<
+ { stopMomentum: () => void; moving: boolean },
+ Partial
+> = (props, ref) => {
+ const {
+ keyIndex = 0,
+ options = [],
+ threeDimensional = true,
+ duration = 1000,
+ onSelect,
+ renderLabel = (item: PickerOptionItem) => item.label,
+ } = props
+
+ const DEFAULT_DURATION = 200
+ const INERTIA_TIME = 300
+ const INERTIA_DISTANCE = 15
+ const ROTATION = 20
+ const touch = useTouch()
+ const [currentIndex, setCurrentIndex] = useState(1)
+ const lineSpacing = useRef(36)
+ const [touchTime, setTouchTime] = useState(0)
+ const [touchDeg, setTouchDeg] = useState('0deg')
+ const isMoving = useRef(false)
+ const rollerRef = useRef(null)
+ const pickerRollerRef = useRef(null)
+ const [startTime, setStartTime] = useState(0)
+ const [startY, setStartY] = useState(0)
+ const transformY = useRef(0)
+ const [scrollDistance, setScrollDistance] = useState(0)
+
+ const { touchRollerStyle, touchTiledStyle, rollerStyle } = useStyles(
+ touchTime,
+ touchDeg,
+ scrollDistance,
+ lineSpacing,
+ ROTATION
+ )
+
+ const isItemHidden = (index: number) =>
+ index >= currentIndex + 8 || index <= currentIndex - 8
+
+ const applyTransform = (
+ type: string,
+ deg: string,
+ time = DEFAULT_DURATION,
+ translateY = 0
+ ) => {
+ setTouchTime(type !== 'end' ? 0 : time)
+ setTouchDeg(deg)
+ setScrollDistance(translateY)
+ }
+
+ const handleMove = (move: number, type?: string, time?: number) => {
+ let updatedMove = move + transformY.current
+ if (type === 'end') {
+ updatedMove = Math.max(
+ Math.min(updatedMove, 0),
+ -(options.length - 1) * lineSpacing.current
+ )
+
+ // 滚动距离为lineSpacing.current的倍数值
+ const endMove =
+ Math.round(updatedMove / lineSpacing.current) * lineSpacing.current
+ const deg = `${(Math.abs(Math.round(endMove / lineSpacing.current)) + 1) * ROTATION}deg`
+ applyTransform(type, deg, time, endMove)
+ setCurrentIndex(Math.abs(Math.round(endMove / lineSpacing.current)) + 1)
+ } else {
+ const currentDeg = (-updatedMove / lineSpacing.current + 1) * ROTATION
+ const deg = Math.min(
+ Math.max(currentDeg, 0),
+ (options.length + 1) * ROTATION
+ )
+ if (deg >= 0 && deg < (options.length + 1) * ROTATION) {
+ applyTransform('', `${deg}deg`, undefined, updatedMove)
+ setCurrentIndex(
+ Math.abs(Math.round(updatedMove / lineSpacing.current)) + 1
+ )
+ }
+ }
+ }
+
+ const selectValue = (move: number) => {
+ onSelect?.(options?.[Math.round(-move / lineSpacing.current)], keyIndex)
+ }
+
+ const handleTouchStart = (event: React.TouchEvent) => {
+ touch.start(event)
+ setStartY(touch.deltaY.current)
+ setStartTime(Date.now())
+ transformY.current = scrollDistance
+ }
+
+ const handleTouchMove = (event: React.TouchEvent) => {
+ touch.move(event)
+ if ((touch as any).isVertical) {
+ isMoving.current = true
+ preventDefault(event, true)
+ }
+ const move = touch.deltaY.current - startY
+ handleMove(move)
+ }
+
+ const handleTouchEnd = () => {
+ if (!isMoving.current) return
+ const move = touch.deltaY.current - startY
+ const moveTime = Date.now() - startTime
+ if (moveTime <= INERTIA_TIME && Math.abs(move) > INERTIA_DISTANCE) {
+ const distance = momentum(move, moveTime)
+ handleMove(distance, 'end', +duration)
+ } else {
+ handleMove(move, 'end')
+ }
+ setTimeout(() => {
+ touch.reset()
+ }, 0)
+ }
+
+ const updateStatus = (shouldSelect?: boolean, value?: string | number) => {
+ const selectedValue = value || props.value
+ const index = options.findIndex((item) => item.value === selectedValue)
+ setCurrentIndex(index === -1 ? 1 : index + 1)
+ const move = index * lineSpacing.current
+ shouldSelect && selectValue(-move)
+ handleMove(-move)
+ }
+
+ const stopMomentumScroll = () => {
+ isMoving.current = false
+ setTouchTime(0)
+ selectValue(scrollDistance)
+ }
+
+ // lineSpacing.current CSS variable
+ useEffect(() => {
+ const element = pickerRollerRef.current
+ if (element && web()) {
+ const computedStyle = getComputedStyle(element)
+ const currentLineSpacing = computedStyle.getPropertyValue(
+ '--nutui-picker-item-height'
+ )
+ !!currentLineSpacing &&
+ (lineSpacing.current = parseFloat(currentLineSpacing))
+ }
+ }, [pickerRollerRef.current])
+
+ useEffect(() => {
+ isMoving.current = false
+ setScrollDistance(0)
+ transformY.current = 0
+ updateStatus(false)
+ }, [options, props.value])
+
+ useImperativeHandle(ref, () => ({
+ stopMomentum: stopMomentumScroll,
+ moving: isMoving.current,
+ }))
+
+ useEffect(() => {
+ const eventOptions = passiveSupported
+ ? { passive: false, once: true }
+ : false
+ pickerRollerRef.current?.addEventListener(
+ 'touchstart',
+ handleTouchStart,
+ eventOptions
+ )
+ pickerRollerRef.current?.addEventListener(
+ 'touchmove',
+ handleTouchMove,
+ eventOptions
+ )
+ pickerRollerRef.current?.addEventListener(
+ 'touchend',
+ handleTouchEnd,
+ eventOptions
+ )
+ return () => {
+ pickerRollerRef.current?.removeEventListener(
+ 'touchstart',
+ handleTouchStart,
+ eventOptions
+ )
+ pickerRollerRef.current?.removeEventListener(
+ 'touchmove',
+ handleTouchMove,
+ eventOptions
+ )
+ pickerRollerRef.current?.removeEventListener(
+ 'touchend',
+ handleTouchEnd,
+ eventOptions
+ )
+ }
+ }, [
+ pickerRollerRef.current,
+ handleTouchStart,
+ handleTouchMove,
+ handleTouchEnd,
+ ])
+
+ return (
+
+
+ {/* 3D 效果 */}
+ {threeDimensional &&
+ options.map((item, index) => (
+
+ {renderLabel(item)}
+
+ ))}
+ {/* Tiled */}
+ {!threeDimensional &&
+ options.map((item, index) => {
+ return (
+
+ {renderLabel(item)}
+
+ )
+ })}
+
+
+ )
+}
+
+const PickerRoller = React.forwardRef<
+ { stopMomentum: () => void; moving: boolean },
+ Partial
+>(InternalPickerRoller)
+
+export default PickerRoller
diff --git a/src/packages/pickerview/pickerroller.tsx b/src/packages/pickerview/pickerroller.tsx
new file mode 100644
index 0000000000..deb1512a76
--- /dev/null
+++ b/src/packages/pickerview/pickerroller.tsx
@@ -0,0 +1,244 @@
+import React, {
+ useState,
+ useEffect,
+ useRef,
+ ForwardRefRenderFunction,
+ useImperativeHandle,
+} from 'react'
+import { useTouch } from '@/hooks/use-touch'
+import { passiveSupported } from '@/utils/supports-passive'
+import { PickerRollerProps, PickerOptionItem } from './types'
+import { preventDefault } from '@/utils'
+import { momentum, useStyles } from './utils'
+
+const InternalPickerRoller: ForwardRefRenderFunction<
+ { stopMomentum: () => void; moving: boolean },
+ Partial
+> = (props, ref) => {
+ const {
+ keyIndex = 0,
+ options = [],
+ threeDimensional = true,
+ duration = 1000,
+ onSelect,
+ renderLabel = (item: PickerOptionItem) => item.label,
+ } = props
+
+ const DEFAULT_DURATION = 200
+ const INERTIA_TIME = 300
+ const INERTIA_DISTANCE = 15
+ const ROTATION = 20
+ const touch = useTouch()
+ const [currentIndex, setCurrentIndex] = useState(1)
+ const lineSpacing = useRef(36)
+ const [touchTime, setTouchTime] = useState(0)
+ const [touchDeg, setTouchDeg] = useState('0deg')
+ const isMoving = useRef(false)
+ const rollerRef = useRef(null)
+ const pickerRollerRef = useRef(null)
+ const [startTime, setStartTime] = useState(0)
+ const [startY, setStartY] = useState(0)
+ const transformY = useRef(0)
+ const [scrollDistance, setScrollDistance] = useState(0)
+
+ const { touchRollerStyle, touchTiledStyle, rollerStyle } = useStyles(
+ touchTime,
+ touchDeg,
+ scrollDistance,
+ lineSpacing,
+ ROTATION
+ )
+
+ const isItemHidden = (index: number) =>
+ index >= currentIndex + 8 || index <= currentIndex - 8
+
+ const applyTransform = (
+ type: string,
+ deg: string,
+ time = DEFAULT_DURATION,
+ translateY = 0
+ ) => {
+ setTouchTime(type !== 'end' ? 0 : time)
+ setTouchDeg(deg)
+ setScrollDistance(translateY)
+ }
+
+ const handleMove = (move: number, type?: string, time?: number) => {
+ let updatedMove = move + transformY.current
+ if (type === 'end') {
+ updatedMove = Math.max(
+ Math.min(updatedMove, 0),
+ -(options.length - 1) * lineSpacing.current
+ )
+
+ // 滚动距离为lineSpacing.current的倍数值
+ const endMove =
+ Math.round(updatedMove / lineSpacing.current) * lineSpacing.current
+ const deg = `${(Math.abs(Math.round(endMove / lineSpacing.current)) + 1) * ROTATION}deg`
+ applyTransform(type, deg, time, endMove)
+ setCurrentIndex(Math.abs(Math.round(endMove / lineSpacing.current)) + 1)
+ } else {
+ const currentDeg = (-updatedMove / lineSpacing.current + 1) * ROTATION
+ const deg = Math.min(
+ Math.max(currentDeg, 0),
+ (options.length + 1) * ROTATION
+ )
+ if (deg >= 0 && deg < (options.length + 1) * ROTATION) {
+ applyTransform('', `${deg}deg`, undefined, updatedMove)
+ setCurrentIndex(
+ Math.abs(Math.round(updatedMove / lineSpacing.current)) + 1
+ )
+ }
+ }
+ }
+
+ const selectValue = (move: number) => {
+ onSelect?.(options?.[Math.round(-move / lineSpacing.current)], keyIndex)
+ }
+
+ const handleTouchStart = (event: React.TouchEvent) => {
+ touch.start(event)
+ setStartY(touch.deltaY.current)
+ setStartTime(Date.now())
+ transformY.current = scrollDistance
+ }
+
+ const handleTouchMove = (event: React.TouchEvent) => {
+ touch.move(event)
+ if ((touch as any).isVertical) {
+ isMoving.current = true
+ preventDefault(event, true)
+ }
+ const move = touch.deltaY.current - startY
+ handleMove(move)
+ }
+
+ const handleTouchEnd = () => {
+ if (!isMoving.current) return
+ const move = touch.deltaY.current - startY
+ const moveTime = Date.now() - startTime
+ if (moveTime <= INERTIA_TIME && Math.abs(move) > INERTIA_DISTANCE) {
+ const distance = momentum(move, moveTime)
+ handleMove(distance, 'end', +duration)
+ } else {
+ handleMove(move, 'end')
+ }
+ setTimeout(() => {
+ touch.reset()
+ }, 0)
+ }
+
+ const updateStatus = (shouldSelect?: boolean, value?: string | number) => {
+ const selectedValue = value || props.value
+ const index = options.findIndex((item) => item.value === selectedValue)
+ setCurrentIndex(index === -1 ? 1 : index + 1)
+ const move = index * lineSpacing.current
+ shouldSelect && selectValue(-move)
+ handleMove(-move)
+ }
+
+ const stopMomentumScroll = () => {
+ isMoving.current = false
+ setTouchTime(0)
+ selectValue(scrollDistance)
+ }
+
+ // lineSpacing.current CSS variable
+ useEffect(() => {
+ const element = pickerRollerRef.current
+ if (element) {
+ const computedStyle = getComputedStyle(element)
+ const currentLineSpacing = computedStyle.getPropertyValue(
+ '--nutui-picker-item-height'
+ )
+ !!currentLineSpacing &&
+ (lineSpacing.current = parseFloat(currentLineSpacing))
+ }
+ }, [pickerRollerRef.current])
+
+ useEffect(() => {
+ isMoving.current = false
+ setScrollDistance(0)
+ transformY.current = 0
+ updateStatus(false)
+ }, [options, props.value])
+
+ useImperativeHandle(ref, () => ({
+ stopMomentum: stopMomentumScroll,
+ moving: isMoving.current,
+ }))
+
+ useEffect(() => {
+ const options = passiveSupported ? { passive: false } : false
+ pickerRollerRef.current?.addEventListener(
+ 'touchstart',
+ handleTouchStart,
+ options
+ )
+ pickerRollerRef.current?.addEventListener(
+ 'touchmove',
+ handleTouchMove,
+ options
+ )
+ pickerRollerRef.current?.addEventListener(
+ 'touchend',
+ handleTouchEnd,
+ options
+ )
+ return () => {
+ pickerRollerRef.current?.removeEventListener(
+ 'touchstart',
+ handleTouchStart
+ )
+ pickerRollerRef.current?.removeEventListener('touchmove', handleTouchMove)
+ pickerRollerRef.current?.removeEventListener('touchend', handleTouchEnd)
+ }
+ }, [
+ pickerRollerRef.current,
+ handleTouchStart,
+ handleTouchMove,
+ handleTouchEnd,
+ ])
+
+ return (
+
+
+ {/* 3D */}
+ {threeDimensional &&
+ options.map((item, index) => (
+
+ {renderLabel(item)}
+
+ ))}
+ {/* Tiled */}
+ {!threeDimensional &&
+ options.map((item, index) => (
+
+ {renderLabel(item)}
+
+ ))}
+
+
+ )
+}
+
+const PickerRoller = React.forwardRef<
+ { stopMomentum: () => void; moving: boolean },
+ Partial
+>(InternalPickerRoller)
+
+export default PickerRoller
diff --git a/src/packages/pickerview/pickerview.scss b/src/packages/pickerview/pickerview.scss
new file mode 100644
index 0000000000..1face81104
--- /dev/null
+++ b/src/packages/pickerview/pickerview.scss
@@ -0,0 +1,79 @@
+.nut-pickerview {
+ --nutui-picker-item-height: 36px;
+ position: relative;
+ display: flex;
+ width: 100%;
+ height: $picker-list-height;
+ $pickerview-top: calc(($picker-list-height - $picker-item-height) / 2);
+ overflow: hidden;
+
+ &-mask,
+ &-indicator {
+ position: absolute;
+ left: 0;
+ right: 0;
+ z-index: 3;
+ pointer-events: none;
+ }
+
+ &-mask {
+ top: 0;
+ bottom: 0;
+ background-image: $picker-mask-background;
+ background-position: top, bottom;
+ background-size: 100% $pickerview-top;
+ background-repeat: no-repeat;
+ transform: translateZ(0);
+ }
+
+ &-indicator {
+ top: $pickerview-top;
+ height: $picker-item-height;
+ border: $picker-item-active-line-border;
+ border-left: 0;
+ border-right: 0;
+ box-sizing: border-box;
+ }
+
+ &-list {
+ position: relative;
+ flex: 1;
+ height: $picker-list-height;
+ overflow: hidden;
+ touch-action: none;
+ }
+
+ &-roller {
+ position: absolute;
+ top: $pickerview-top;
+ width: 100%;
+ height: $picker-item-height;
+ z-index: 1;
+ transform-style: preserve-3d;
+ }
+
+ &-roller-item {
+ position: absolute;
+ top: 0;
+ backface-visibility: hidden;
+ -moz-backface-visibility: hidden;
+ -webkit-backface-visibility: hidden;
+ &-hidden {
+ visibility: hidden;
+ opacity: 0;
+ }
+ }
+
+ &-roller-item,
+ &-roller-item-tiled {
+ width: 100%;
+ height: $picker-item-height;
+ line-height: $picker-item-height;
+ color: $picker-item-text-color;
+ font-size: $picker-item-text-font-size;
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
diff --git a/src/packages/pickerview/pickerview.taro.tsx b/src/packages/pickerview/pickerview.taro.tsx
new file mode 100644
index 0000000000..8a52e52e97
--- /dev/null
+++ b/src/packages/pickerview/pickerview.taro.tsx
@@ -0,0 +1,225 @@
+import React, {
+ ForwardRefRenderFunction,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import classNames from 'classnames'
+import { View } from '@tarojs/components'
+import isEqual from 'react-fast-compare'
+import { ComponentDefaults } from '@/utils/typings'
+import { usePropsValue } from '@/hooks/use-props-value'
+import {
+ PickerViewProps,
+ PickerOptionItem,
+ PickerValue,
+ PickerOptions,
+} from './types'
+import PickerRoller from './pickerroller.taro'
+
+const defaultProps = {
+ ...ComponentDefaults,
+ options: [],
+ defaultValue: [],
+ value: undefined,
+ renderLabel: (item: PickerOptionItem) => item.label,
+} as PickerViewProps
+
+const InternalPickerView: ForwardRefRenderFunction<
+ unknown,
+ Partial
+> = (props, ref) => {
+ const {
+ options,
+ defaultValue = [],
+ value,
+ duration,
+ threeDimensional,
+ renderLabel,
+ className,
+ style,
+ onChange,
+ } = { ...defaultProps, ...props }
+ const classPrefix = 'nut-pickerview'
+ const cls = classNames(classPrefix, className)
+
+ const [selectedValue] = usePropsValue({
+ value,
+ defaultValue: [...defaultValue],
+ finalValue: [...defaultValue],
+ })
+
+ const [innerValue, setInnerValue] = useState(selectedValue)
+ const [innerOptions, setInnerOptions] = useState([] as PickerOptions[])
+ const changeIndex = useRef(0)
+
+ /**
+ * 数据类型:级联、多列
+ */
+ const columnsType = useMemo(() => {
+ const firstColumn = (props.options as PickerOptions[])[0] || []
+ if (
+ Array.isArray(firstColumn) &&
+ firstColumn.length > 0 &&
+ 'children' in firstColumn[0]
+ ) {
+ return 'cascade'
+ }
+ return 'multiple'
+ }, [props.options])
+
+ const formatCascadeOptions = (
+ options: PickerOptions,
+ value: PickerValue[]
+ ) => {
+ if (!options.length) return [] // 如果 options 为空,直接返回空数组
+
+ const formatted: PickerOptions[] = []
+ let columnOptions: PickerOptionItem = {
+ label: '',
+ value: '',
+ children: options,
+ }
+
+ let columnIndex = 0
+ while (columnOptions && columnOptions.children) {
+ const currentOptions: PickerOptions = columnOptions.children
+ formatted.push(currentOptions)
+
+ const currentValue = value?.[columnIndex]
+ if (currentValue === 0) {
+ // 如果 currentValue 为 0,返回第一个 children
+ columnOptions = currentOptions[0]
+ } else if (currentValue) {
+ // 如果 currentValue 存在,查找匹配的项
+ const index = currentOptions.findIndex(
+ (columnItem) => columnItem.value === currentValue
+ )
+ columnOptions = currentOptions[index === -1 ? 0 : index] // 如果未找到,默认取第一个
+ } else {
+ break // 如果 currentValue 不存在,终止循环
+ }
+
+ columnIndex++
+ }
+ return formatted
+ }
+
+ const formatOptions = useMemo(() => {
+ if (columnsType === 'cascade') {
+ return formatCascadeOptions(
+ props?.options?.[0] as PickerOptions,
+ innerValue
+ )
+ }
+ return props.options
+ }, [innerValue, options, columnsType])
+
+ useEffect(() => {
+ const options = props.options
+ if (Array.isArray(options) && options.length && options !== innerOptions) {
+ setInnerOptions(formatOptions as PickerOptions[])
+ }
+ }, [props.options, innerValue])
+
+ useEffect(() => {
+ if (selectedValue !== innerValue) {
+ setInnerValue(selectedValue)
+ }
+ }, [selectedValue])
+
+ const handleSelect = useCallback(
+ (option: PickerOptionItem, index: number) => {
+ const newValue = option?.value
+ if (!newValue || innerValue[index] === newValue) return
+ changeIndex.current = index
+ if (columnsType === 'multiple') {
+ setInnerValue((prev) => {
+ const next = [...prev]
+ next[index] = newValue
+ return next
+ })
+ } else {
+ const startIndex = index
+ const values: PickerValue[] = []
+ values[index] = option.value
+ while (option?.children?.[0]) {
+ values[index + 1] = option.children[0].value
+ index++
+ option = option.children[0]
+ }
+ // 当前改变列的下一列 children 值为空
+ if (option?.children?.length) {
+ values[index + 1] = ''
+ }
+ const combineResult = [
+ ...innerValue.slice(0, startIndex),
+ ...values.splice(startIndex),
+ ]
+ setInnerValue([...combineResult])
+ const optionFirst = props?.options?.[0] as PickerOptionItem[]
+ if (
+ !isEqual(
+ formatCascadeOptions(optionFirst, combineResult),
+ innerOptions
+ )
+ ) {
+ setInnerOptions(formatCascadeOptions(optionFirst, combineResult))
+ }
+ }
+ },
+ [innerValue, props.options, columnsType, innerOptions]
+ )
+
+ const selectedOptions = useMemo(() => {
+ return innerOptions
+ .map((columnOptions, index) => {
+ const selectedOption = columnOptions.find(
+ (item) => item.value === innerValue[index]
+ )
+ return selectedOption
+ // return selectedOption || columnOptions[0]
+ })
+ .filter(Boolean) as PickerOptionItem[]
+ }, [innerOptions, innerValue])
+
+ useEffect(() => {
+ onChange?.({
+ value: innerValue,
+ index: changeIndex.current,
+ selectedOptions,
+ })
+ }, [innerValue, selectedOptions, onChange])
+
+ return (
+
+ {innerOptions.map((item, index) => (
+
+ ))}
+ {innerOptions?.length ? (
+ <>
+
+
+ >
+ ) : null}
+
+ )
+}
+
+const PickerView = React.forwardRef>(
+ InternalPickerView
+)
+
+export default PickerView
diff --git a/src/packages/pickerview/pickerview.tsx b/src/packages/pickerview/pickerview.tsx
new file mode 100644
index 0000000000..0fef411bea
--- /dev/null
+++ b/src/packages/pickerview/pickerview.tsx
@@ -0,0 +1,224 @@
+import React, {
+ ForwardRefRenderFunction,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import classNames from 'classnames'
+import isEqual from 'react-fast-compare'
+import { ComponentDefaults } from '@/utils/typings'
+import { usePropsValue } from '@/hooks/use-props-value'
+import {
+ PickerViewProps,
+ PickerOptionItem,
+ PickerValue,
+ PickerOptions,
+} from './types'
+import PickerRoller from './pickerroller'
+
+const defaultProps = {
+ ...ComponentDefaults,
+ options: [],
+ defaultValue: [],
+ value: undefined,
+ renderLabel: (item: PickerOptionItem) => item.label,
+} as PickerViewProps
+
+const InternalPickerView: ForwardRefRenderFunction<
+ unknown,
+ Partial
+> = (props, ref) => {
+ const {
+ options,
+ defaultValue = [],
+ value,
+ duration,
+ threeDimensional,
+ renderLabel,
+ className,
+ style,
+ onChange,
+ } = { ...defaultProps, ...props }
+ const classPrefix = 'nut-pickerview'
+ const cls = classNames(classPrefix, className)
+
+ const [selectedValue] = usePropsValue({
+ value,
+ defaultValue: [...defaultValue],
+ finalValue: [...defaultValue],
+ })
+
+ const [innerValue, setInnerValue] = useState(selectedValue)
+ const [innerOptions, setInnerOptions] = useState([] as PickerOptions[])
+ const changeIndex = useRef(0)
+
+ /**
+ * 数据类型:级联、多列
+ */
+ const columnsType = useMemo(() => {
+ const firstColumn = (props.options as PickerOptions[])[0] || []
+ if (
+ Array.isArray(firstColumn) &&
+ firstColumn.length > 0 &&
+ 'children' in firstColumn[0]
+ ) {
+ return 'cascade'
+ }
+ return 'multiple'
+ }, [props.options])
+
+ const formatCascadeOptions = (
+ options: PickerOptions,
+ value: PickerValue[]
+ ) => {
+ if (!options.length) return [] // 如果 options 为空,直接返回空数组
+
+ const formatted: PickerOptions[] = []
+ let columnOptions: PickerOptionItem = {
+ label: '',
+ value: '',
+ children: options,
+ }
+
+ let columnIndex = 0
+ while (columnOptions && columnOptions.children) {
+ const currentOptions: PickerOptions = columnOptions.children
+ formatted.push(currentOptions)
+
+ const currentValue = value?.[columnIndex]
+ if (currentValue === 0) {
+ // 如果 currentValue 为 0,返回第一个 children
+ columnOptions = currentOptions[0]
+ } else if (currentValue) {
+ // 如果 currentValue 存在,查找匹配的项
+ const index = currentOptions.findIndex(
+ (columnItem) => columnItem.value === currentValue
+ )
+ columnOptions = currentOptions[index === -1 ? 0 : index] // 如果未找到,默认取第一个
+ } else {
+ break // 如果 currentValue 不存在,终止循环
+ }
+
+ columnIndex++
+ }
+ return formatted
+ }
+
+ const formatOptions = useMemo(() => {
+ if (columnsType === 'cascade') {
+ return formatCascadeOptions(
+ props?.options?.[0] as PickerOptions,
+ innerValue
+ )
+ }
+ return props.options
+ }, [innerValue, options, columnsType])
+
+ useEffect(() => {
+ const options = props.options
+ if (Array.isArray(options) && options.length && options !== innerOptions) {
+ setInnerOptions(formatOptions as PickerOptions[])
+ }
+ }, [props.options, innerValue])
+
+ useEffect(() => {
+ if (selectedValue !== innerValue) {
+ setInnerValue(selectedValue)
+ }
+ }, [selectedValue])
+
+ const handleSelect = useCallback(
+ (option: PickerOptionItem, index: number) => {
+ const newValue = option?.value
+ if (!newValue || innerValue[index] === newValue) return
+ changeIndex.current = index
+ if (columnsType === 'multiple') {
+ setInnerValue((prev) => {
+ const next = [...prev]
+ next[index] = newValue
+ return next
+ })
+ } else {
+ const startIndex = index
+ const values: PickerValue[] = []
+ values[index] = option.value
+ while (option?.children?.[0]) {
+ values[index + 1] = option.children[0].value
+ index++
+ option = option.children[0]
+ }
+ // 当前改变列的下一列 children 值为空
+ if (option?.children?.length) {
+ values[index + 1] = ''
+ }
+ const combineResult = [
+ ...innerValue.slice(0, startIndex),
+ ...values.splice(startIndex),
+ ]
+ setInnerValue([...combineResult])
+ const optionFirst = props?.options?.[0] as PickerOptionItem[]
+ if (
+ !isEqual(
+ formatCascadeOptions(optionFirst, combineResult),
+ innerOptions
+ )
+ ) {
+ setInnerOptions(formatCascadeOptions(optionFirst, combineResult))
+ }
+ }
+ },
+ [innerValue, props.options, columnsType, innerOptions]
+ )
+
+ const selectedOptions = useMemo(() => {
+ return innerOptions
+ .map((columnOptions, index) => {
+ const selectedOption = columnOptions.find(
+ (item) => item.value === innerValue[index]
+ )
+ return selectedOption
+ // return selectedOption || columnOptions[0]
+ })
+ .filter(Boolean) as PickerOptionItem[]
+ }, [innerOptions, innerValue])
+
+ useEffect(() => {
+ onChange?.({
+ value: innerValue,
+ index: changeIndex.current,
+ selectedOptions,
+ })
+ }, [innerValue, selectedOptions, onChange])
+
+ return (
+
+ {innerOptions.map((item, index) => (
+
+ ))}
+ {innerOptions?.length ? (
+ <>
+
+
+ >
+ ) : null}
+
+ )
+}
+
+const PickerView = React.forwardRef>(
+ InternalPickerView
+)
+
+export default PickerView
diff --git a/src/packages/pickerview/types.ts b/src/packages/pickerview/types.ts
new file mode 100644
index 0000000000..e25d83faf5
--- /dev/null
+++ b/src/packages/pickerview/types.ts
@@ -0,0 +1,38 @@
+import { BasicComponent } from '@/utils/typings'
+
+export type PickerValue = string | number | null
+
+export interface PickerOptionItem {
+ label: string | number
+ value: string | number
+ children?: PickerOptionItem[]
+}
+
+export type PickerOptions = PickerOptionItem[]
+
+export interface PickerRollerProps {
+ options: PickerOptionItem[]
+ keyIndex: number
+ value: PickerValue
+ threeDimensional?: boolean
+ duration?: number | string
+ onSelect: (option: PickerOptionItem, index: number) => void
+ renderLabel: (item: PickerOptionItem) => React.ReactNode
+}
+
+export interface PickerOnChangeCallbackParameter {
+ value: PickerValue[]
+ index: number
+ selectedOptions: PickerOptionItem[]
+}
+
+export interface PickerViewProps extends BasicComponent {
+ setRefs?: (ref: any) => any
+ options: PickerOptions[]
+ value?: PickerValue[]
+ defaultValue?: PickerValue[]
+ threeDimensional?: boolean
+ duration?: number | string
+ renderLabel: (item: PickerOptionItem) => React.ReactNode
+ onChange?: (arg0: PickerOnChangeCallbackParameter) => void
+}
diff --git a/src/packages/pickerview/utils.ts b/src/packages/pickerview/utils.ts
new file mode 100644
index 0000000000..2d948b7361
--- /dev/null
+++ b/src/packages/pickerview/utils.ts
@@ -0,0 +1,31 @@
+export const momentum = (distance: number, duration: number) => {
+ const speed = Math.abs(distance / duration)
+ return (speed / 0.003) * (distance < 0 ? -1 : 1)
+}
+
+export const useStyles = (
+ touchTime: number,
+ touchDeg: string,
+ scrollDistance: number,
+ lineSpacing: React.MutableRefObject,
+ rotation: number
+) => {
+ const getTransitionStyle = (transformValue: string) => ({
+ transition: `transform ${touchTime}ms cubic-bezier(0.17, 0.89, 0.45, 1)`,
+ transform: transformValue,
+ })
+
+ const touchRollerStyle = () =>
+ getTransitionStyle(`rotate3d(1, 0, 0, ${touchDeg})`)
+
+ const touchTiledStyle = () =>
+ getTransitionStyle(`translate3d(0, ${scrollDistance}px, 0)`)
+
+ const rollerStyle = (index: number) => ({
+ transform: `rotate3d(1, 0, 0, ${-rotation * (index + 1)}deg) translate3d(0px, 0px, ${Math.round(
+ lineSpacing.current * 3.2
+ )}px)`,
+ })
+
+ return { touchRollerStyle, touchTiledStyle, rollerStyle }
+}
diff --git a/src/styles/variables.scss b/src/styles/variables.scss
index 99b31976c7..51a884d520 100644
--- a/src/styles/variables.scss
+++ b/src/styles/variables.scss
@@ -559,8 +559,10 @@ $picker-title-ok-font-size: var(
--nutui-picker-title-ok-font-size,
$font-size-base
) !default;
-$picker-list-height: var(--nutui-picker-list-height, 252px) !default;
+
+// picker-view(✅)
$picker-item-height: var(--nutui-picker-item-height, 36px) !default;
+$picker-list-height: calc($picker-item-height * 7) !default;
$picker-item-text-color: var(
--nutui-picker-item-text-color,
$color-title