react-native-x-components 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/dist/XActionSheet/index.d.ts +47 -0
- package/dist/XActionSheet/index.js +100 -0
- package/dist/XAnimatedSearchPanel/index.d.ts +33 -0
- package/dist/XAnimatedSearchPanel/index.js +295 -0
- package/dist/XAnimatedView/index.d.ts +77 -0
- package/dist/XAnimatedView/index.js +227 -0
- package/dist/XButton/index.d.ts +44 -0
- package/dist/XButton/index.js +126 -0
- package/dist/XCalendar/XCalendarPopup.d.ts +38 -0
- package/dist/XCalendar/XCalendarPopup.js +76 -0
- package/dist/XCalendar/index.d.ts +50 -0
- package/dist/XCalendar/index.js +241 -0
- package/dist/XCarousel/index.d.ts +72 -0
- package/dist/XCarousel/index.js +183 -0
- package/dist/XCascadeSelect/index.d.ts +39 -0
- package/dist/XCascadeSelect/index.js +147 -0
- package/dist/XChart/index.d.ts +78 -0
- package/dist/XChart/index.js +162 -0
- package/dist/XCheckbox/index.d.ts +61 -0
- package/dist/XCheckbox/index.js +128 -0
- package/dist/XConfirmForm/index.d.ts +69 -0
- package/dist/XConfirmForm/index.js +113 -0
- package/dist/XDivider/index.d.ts +33 -0
- package/dist/XDivider/index.js +61 -0
- package/dist/XDropdownMenu/index.d.ts +66 -0
- package/dist/XDropdownMenu/index.js +258 -0
- package/dist/XElevator/index.d.ts +52 -0
- package/dist/XElevator/index.js +138 -0
- package/dist/XForm/FormStore.d.ts +82 -0
- package/dist/XForm/FormStore.js +279 -0
- package/dist/XForm/index.d.ts +116 -0
- package/dist/XForm/index.js +212 -0
- package/dist/XForm/types.d.ts +82 -0
- package/dist/XForm/types.js +1 -0
- package/dist/XForm/useForm.d.ts +2 -0
- package/dist/XForm/useForm.js +19 -0
- package/dist/XForm/utils.d.ts +42 -0
- package/dist/XForm/utils.js +131 -0
- package/dist/XForm/validate.d.ts +20 -0
- package/dist/XForm/validate.js +118 -0
- package/dist/XFormPro/components/FieldTrigger.d.ts +14 -0
- package/dist/XFormPro/components/FieldTrigger.js +38 -0
- package/dist/XFormPro/components/XDateField.d.ts +22 -0
- package/dist/XFormPro/components/XDateField.js +22 -0
- package/dist/XFormPro/components/XSelectField.d.ts +16 -0
- package/dist/XFormPro/components/XSelectField.js +19 -0
- package/dist/XFormPro/index.d.ts +44 -0
- package/dist/XFormPro/index.js +111 -0
- package/dist/XFormPro/renderers.d.ts +24 -0
- package/dist/XFormPro/renderers.js +53 -0
- package/dist/XFormPro/types.d.ts +142 -0
- package/dist/XFormPro/types.js +1 -0
- package/dist/XImage/index.d.ts +13 -0
- package/dist/XImage/index.js +32 -0
- package/dist/XInput/index.d.ts +43 -0
- package/dist/XInput/index.js +136 -0
- package/dist/XLicensePlate/index.d.ts +40 -0
- package/dist/XLicensePlate/index.js +172 -0
- package/dist/XLocale/index.d.ts +23 -0
- package/dist/XLocale/index.js +174 -0
- package/dist/XModalForm/index.d.ts +32 -0
- package/dist/XModalForm/index.js +85 -0
- package/dist/XMultiSelect/index.d.ts +31 -0
- package/dist/XMultiSelect/index.js +131 -0
- package/dist/XNumberKeyboard/index.d.ts +57 -0
- package/dist/XNumberKeyboard/index.js +142 -0
- package/dist/XPicker/index.d.ts +35 -0
- package/dist/XPicker/index.js +96 -0
- package/dist/XPickerDate/index.d.ts +58 -0
- package/dist/XPickerDate/index.js +234 -0
- package/dist/XPopupProvider/index.d.ts +23 -0
- package/dist/XPopupProvider/index.js +6 -0
- package/dist/XProgress/index.d.ts +49 -0
- package/dist/XProgress/index.js +196 -0
- package/dist/XProviders/XImagePreview/index.d.ts +44 -0
- package/dist/XProviders/XImagePreview/index.js +164 -0
- package/dist/XProviders/XLoadingModal/index.d.ts +46 -0
- package/dist/XProviders/XLoadingModal/index.js +119 -0
- package/dist/XProviders/XToast/index.d.ts +50 -0
- package/dist/XProviders/XToast/index.js +183 -0
- package/dist/XProviders/index.d.ts +31 -0
- package/dist/XProviders/index.js +28 -0
- package/dist/XProviders/usageExample.d.ts +19 -0
- package/dist/XProviders/usageExample.js +83 -0
- package/dist/XPullView/index.d.ts +76 -0
- package/dist/XPullView/index.js +220 -0
- package/dist/XRadio/index.d.ts +83 -0
- package/dist/XRadio/index.js +186 -0
- package/dist/XRecord/index.d.ts +37 -0
- package/dist/XRecord/index.js +160 -0
- package/dist/XSignature/XSignatureSkia.d.ts +39 -0
- package/dist/XSignature/XSignatureSkia.js +184 -0
- package/dist/XSignature/core.d.ts +69 -0
- package/dist/XSignature/core.js +108 -0
- package/dist/XSignature/index.d.ts +33 -0
- package/dist/XSignature/index.js +88 -0
- package/dist/XTabs/index.d.ts +65 -0
- package/dist/XTabs/index.js +210 -0
- package/dist/XTag/index.d.ts +61 -0
- package/dist/XTag/index.js +153 -0
- package/dist/XTopView/index.d.ts +25 -0
- package/dist/XTopView/index.js +8 -0
- package/dist/XTopViewStore/index.d.ts +45 -0
- package/dist/XTopViewStore/index.js +49 -0
- package/dist/XUpload/XUploadImage.d.ts +37 -0
- package/dist/XUpload/XUploadImage.js +142 -0
- package/dist/XUpload/XUploadVideo.d.ts +24 -0
- package/dist/XUpload/XUploadVideo.js +83 -0
- package/dist/XUpload/adapters.d.ts +50 -0
- package/dist/XUpload/adapters.js +110 -0
- package/dist/XUpload/helpers.d.ts +35 -0
- package/dist/XUpload/helpers.js +85 -0
- package/dist/XUpload/index.d.ts +12 -0
- package/dist/XUpload/index.js +5 -0
- package/dist/XUpload/provider.d.ts +26 -0
- package/dist/XUpload/provider.js +43 -0
- package/dist/XUpload/types.d.ts +31 -0
- package/dist/XUpload/types.js +4 -0
- package/dist/XWheel/index.d.ts +49 -0
- package/dist/XWheel/index.js +263 -0
- package/dist/index.d.ts +113 -0
- package/dist/index.js +127 -0
- package/dist/theme.d.ts +164 -0
- package/dist/theme.js +161 -0
- package/package.json +78 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* XCalendar —— 日历(duxui Calendar 的 TS + XTheme 重写版)
|
|
5
|
+
* ============================================================================
|
|
6
|
+
*
|
|
7
|
+
* 【与 duxui Calendar 的关系】
|
|
8
|
+
* 逻辑照抄 duxui:纯 View 网格自绘(不依赖任何日历库)、单选/周选/范围
|
|
9
|
+
* 三种模式统一按"范围"处理、min/max + disabledDate 拦截。
|
|
10
|
+
* 重写点:JSX → TS、静态色值 → useXTheme() token、文案 → i18n(中英)、
|
|
11
|
+
* 周起始默认周一(duxui 同款),多选支持 antd 式 multiple。
|
|
12
|
+
*
|
|
13
|
+
* 【模式与取值】
|
|
14
|
+
* mode='day' value: 'YYYY-MM-DD' (multiple 时: string[])
|
|
15
|
+
* mode='week' value: ['YYYY-MM-DD','YYYY-MM-DD'] 所在周一起始
|
|
16
|
+
* mode='scope' value: ['YYYY-MM-DD','YYYY-MM-DD'] 任意范围
|
|
17
|
+
*
|
|
18
|
+
* 【弹窗用法】配合 XCalendarPopup(底部 XPullView + 取消/确定工具栏,
|
|
19
|
+
* 样式对齐本项目 XPickerDate)。
|
|
20
|
+
* ============================================================================
|
|
21
|
+
*/
|
|
22
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
23
|
+
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
24
|
+
import dayjs from 'dayjs';
|
|
25
|
+
import { useXTheme } from '../theme';
|
|
26
|
+
import { useXLocale, xMonthTitle, X_WEEKDAYS } from '../XLocale';
|
|
27
|
+
/** 值 → 有序范围 [start, end](day 取自身;数组取 [0]/[1]) */
|
|
28
|
+
function valueToScope(value, mode) {
|
|
29
|
+
if (!value)
|
|
30
|
+
return [null, null];
|
|
31
|
+
if (mode === 'day' && !Array.isArray(value)) {
|
|
32
|
+
const d = dayjs(value);
|
|
33
|
+
return [d.isValid() ? d : null, d.isValid() ? d : null];
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(value) && value.length >= 2) {
|
|
36
|
+
const s = dayjs(value[0]);
|
|
37
|
+
const e = dayjs(value[1]);
|
|
38
|
+
if (s.isValid() && e.isValid())
|
|
39
|
+
return s.isAfter(e) ? [e, s] : [s, e];
|
|
40
|
+
}
|
|
41
|
+
return [null, null];
|
|
42
|
+
}
|
|
43
|
+
/** day 多选集合 */
|
|
44
|
+
function valueToSet(value, multiple) {
|
|
45
|
+
if (!multiple)
|
|
46
|
+
return new Set();
|
|
47
|
+
if (Array.isArray(value))
|
|
48
|
+
return new Set(value);
|
|
49
|
+
if (typeof value === 'string' && value)
|
|
50
|
+
return new Set([value]);
|
|
51
|
+
return new Set();
|
|
52
|
+
}
|
|
53
|
+
export function XCalendar({ mode = 'day', value, defaultValue, multiple = false, onChange, min, max, disabledDate, weekStartsOn = 1, onMonthChange, style, }) {
|
|
54
|
+
const t = useXTheme();
|
|
55
|
+
const { locale } = useXLocale();
|
|
56
|
+
/** 非受控内部值 */
|
|
57
|
+
const [innerValue, setInnerValue] = useState(defaultValue);
|
|
58
|
+
const currentValue = value !== undefined ? value : innerValue;
|
|
59
|
+
/** 当前展示月份(日历允许翻月,与选中值解耦) */
|
|
60
|
+
const [viewMonth, setViewMonth] = useState(() => {
|
|
61
|
+
const scope = valueToScope(value ?? defaultValue, mode);
|
|
62
|
+
return (scope[0] ?? dayjs()).startOf('month');
|
|
63
|
+
});
|
|
64
|
+
/** scope 模式的"待第二次点击"暂存起点 */
|
|
65
|
+
const [pendingStart, setPendingStart] = useState(null);
|
|
66
|
+
/** 受控值变化 → 展示月份跟随(弹窗每次打开重置场景) */
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (value !== undefined) {
|
|
69
|
+
const scope = valueToScope(value, mode);
|
|
70
|
+
if (scope[0]) {
|
|
71
|
+
setViewMonth(prev => (prev.isSame(scope[0], 'month') ? prev : scope[0].startOf('month')));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}, [value, mode]);
|
|
75
|
+
/** 是否禁用:min/max/disabledDate 三重拦截 */
|
|
76
|
+
const isDisabled = useCallback((d) => {
|
|
77
|
+
if (min && d.isBefore(dayjs(min).startOf('day')))
|
|
78
|
+
return true;
|
|
79
|
+
if (max && d.isAfter(dayjs(max).endOf('day')))
|
|
80
|
+
return true;
|
|
81
|
+
return disabledDate ? disabledDate(d) : false;
|
|
82
|
+
}, [min, max, disabledDate]);
|
|
83
|
+
/** 上/下月(越 min/max 月份边界时按钮禁用) */
|
|
84
|
+
const shiftMonth = useCallback((delta) => {
|
|
85
|
+
const next = viewMonth.add(delta, 'month');
|
|
86
|
+
if (min && next.endOf('month').isBefore(dayjs(min).startOf('day')))
|
|
87
|
+
return;
|
|
88
|
+
if (max && next.startOf('month').isAfter(dayjs(max).endOf('day')))
|
|
89
|
+
return;
|
|
90
|
+
setViewMonth(next);
|
|
91
|
+
onMonthChange?.(next.year(), next.month() + 1);
|
|
92
|
+
}, [viewMonth, min, max, onMonthChange]);
|
|
93
|
+
/** 42 格数据:6 行 × 7 列,非当月补位 */
|
|
94
|
+
const cells = useMemo(() => {
|
|
95
|
+
const first = viewMonth.startOf('month');
|
|
96
|
+
// 当月 1 号距离周起始的偏移(0~6)
|
|
97
|
+
const offset = (first.day() - weekStartsOn + 7) % 7;
|
|
98
|
+
const gridStart = first.subtract(offset, 'day');
|
|
99
|
+
return Array.from({ length: 42 }, (_, i) => gridStart.add(i, 'day'));
|
|
100
|
+
}, [viewMonth, weekStartsOn]);
|
|
101
|
+
/** 星期表头(本地化 + 周起始对齐) */
|
|
102
|
+
const weekHeaders = useMemo(() => {
|
|
103
|
+
const all = X_WEEKDAYS[locale] ?? X_WEEKDAYS['zh-CN'];
|
|
104
|
+
return weekStartsOn === 1 ? all : [all[6], ...all.slice(0, 6)];
|
|
105
|
+
}, [locale, weekStartsOn]);
|
|
106
|
+
/** 选中范围(渲染用) */
|
|
107
|
+
const [selStart, selEnd] = useMemo(() => valueToScope(currentValue, mode), [currentValue, mode]);
|
|
108
|
+
/** 多选集合 */
|
|
109
|
+
const selectedSet = useMemo(() => valueToSet(currentValue, multiple && mode === 'day'), [currentValue, multiple, mode]);
|
|
110
|
+
/** 点击一天 */
|
|
111
|
+
const handleDayPress = useCallback((d) => {
|
|
112
|
+
if (isDisabled(d))
|
|
113
|
+
return;
|
|
114
|
+
const key = d.format('YYYY-MM-DD');
|
|
115
|
+
if (mode === 'day') {
|
|
116
|
+
if (multiple) {
|
|
117
|
+
const base = valueToSet(currentValue, true);
|
|
118
|
+
base.has(key) ? base.delete(key) : base.add(key);
|
|
119
|
+
const next = Array.from(base);
|
|
120
|
+
setInnerValue(next);
|
|
121
|
+
onChange?.(next);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
setInnerValue(key);
|
|
125
|
+
onChange?.(key);
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (mode === 'week') {
|
|
130
|
+
// 整周范围:按周起始推导
|
|
131
|
+
const offset = (d.day() - weekStartsOn + 7) % 7;
|
|
132
|
+
const start = d.subtract(offset, 'day');
|
|
133
|
+
const end = start.add(6, 'day');
|
|
134
|
+
const next = [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
|
135
|
+
setInnerValue(next);
|
|
136
|
+
onChange?.(next);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// scope:两段式选择
|
|
140
|
+
if (!pendingStart) {
|
|
141
|
+
setPendingStart(d);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const [start, end] = pendingStart.isAfter(d) ? [d, pendingStart] : [pendingStart, d];
|
|
145
|
+
const next = [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
|
146
|
+
setPendingStart(null);
|
|
147
|
+
setInnerValue(next);
|
|
148
|
+
onChange?.(next);
|
|
149
|
+
}, [mode, multiple, currentValue, onChange, weekStartsOn, pendingStart, isDisabled]);
|
|
150
|
+
/** 单元格样式计算 */
|
|
151
|
+
const cellState = useCallback((d) => {
|
|
152
|
+
const key = d.format('YYYY-MM-DD');
|
|
153
|
+
const disabled = isDisabled(d);
|
|
154
|
+
const isToday = d.isSame(dayjs(), 'day');
|
|
155
|
+
let selected = false;
|
|
156
|
+
let inRange = false;
|
|
157
|
+
if (mode === 'day') {
|
|
158
|
+
selected = multiple ? selectedSet.has(key) : key === selStart?.format('YYYY-MM-DD');
|
|
159
|
+
}
|
|
160
|
+
else if (selStart && selEnd) {
|
|
161
|
+
// week/scope:起止实心、中间浅底(week 模式每次选中天然成对)
|
|
162
|
+
selected = key === selStart.format('YYYY-MM-DD') || key === selEnd.format('YYYY-MM-DD');
|
|
163
|
+
inRange = d.isAfter(selStart, 'day') && d.isBefore(selEnd, 'day');
|
|
164
|
+
}
|
|
165
|
+
else if (mode === 'scope' && pendingStart) {
|
|
166
|
+
selected = key === pendingStart.format('YYYY-MM-DD');
|
|
167
|
+
}
|
|
168
|
+
return { selected, inRange, disabled, isToday };
|
|
169
|
+
}, [mode, multiple, selectedSet, selStart, selEnd, pendingStart, isDisabled]);
|
|
170
|
+
const renderCell = (d, index) => {
|
|
171
|
+
const inMonth = d.isSame(viewMonth, 'month');
|
|
172
|
+
const { selected, inRange, disabled, isToday } = cellState(d);
|
|
173
|
+
const textColor = disabled
|
|
174
|
+
? t.colorTextQuaternary
|
|
175
|
+
: selected
|
|
176
|
+
? t.colorTextLightSolid
|
|
177
|
+
: inRange
|
|
178
|
+
? t.colorPrimary
|
|
179
|
+
: t.colorText;
|
|
180
|
+
return (_jsx(Pressable, { onPress: () => handleDayPress(d), disabled: disabled, style: [styles.cell, (inRange || selected) && { backgroundColor: inRange ? t.colorPrimaryBg : 'transparent' }], children: _jsx(View, { style: [
|
|
181
|
+
styles.cellInner,
|
|
182
|
+
selected && { backgroundColor: t.colorPrimary, borderRadius: t.borderRadius },
|
|
183
|
+
!selected && isToday && { borderWidth: StyleSheet.hairlineWidth, borderColor: t.colorPrimary, borderRadius: t.borderRadius },
|
|
184
|
+
], children: _jsx(Text, { style: [styles.dayText, { color: textColor }, !inMonth && styles.dim], allowFontScaling: false, children: d.date() }) }) }, index));
|
|
185
|
+
};
|
|
186
|
+
/** 月份切换按钮通用样式 */
|
|
187
|
+
const arrowColor = t.colorTextSecondary;
|
|
188
|
+
const canPrev = !(min && viewMonth.subtract(1, 'month').endOf('month').isBefore(dayjs(min).startOf('day')));
|
|
189
|
+
const canNext = !(max && viewMonth.add(1, 'month').startOf('month').isAfter(dayjs(max).endOf('day')));
|
|
190
|
+
return (_jsxs(View, { style: style, children: [_jsxs(View, { style: styles.header, children: [_jsx(Pressable, { onPress: () => shiftMonth(-1), disabled: !canPrev, hitSlop: 8, style: styles.arrowBtn, children: _jsx(Text, { style: [styles.arrow, { color: canPrev ? arrowColor : t.colorTextQuaternary }], allowFontScaling: false, children: "\u2039" }) }), _jsx(Text, { style: [styles.monthTitle, { color: t.colorText }], allowFontScaling: false, children: xMonthTitle(locale, viewMonth.year(), viewMonth.month() + 1) }), _jsx(Pressable, { onPress: () => shiftMonth(1), disabled: !canNext, hitSlop: 8, style: styles.arrowBtn, children: _jsx(Text, { style: [styles.arrow, { color: canNext ? arrowColor : t.colorTextQuaternary }], allowFontScaling: false, children: "\u203A" }) })] }), _jsx(View, { style: styles.weekRow, children: weekHeaders.map((label, i) => (_jsx(View, { style: styles.cell, children: _jsx(Text, { style: [styles.weekText, { color: t.colorTextTertiary }], allowFontScaling: false, children: label }) }, i))) }), [0, 1, 2, 3, 4, 5].map(row => (_jsx(View, { style: styles.weekRow, children: cells.slice(row * 7, row * 7 + 7).map(renderCell) }, row)))] }));
|
|
191
|
+
}
|
|
192
|
+
const styles = StyleSheet.create({
|
|
193
|
+
header: {
|
|
194
|
+
flexDirection: 'row',
|
|
195
|
+
alignItems: 'center',
|
|
196
|
+
justifyContent: 'space-between',
|
|
197
|
+
paddingVertical: 8,
|
|
198
|
+
},
|
|
199
|
+
monthTitle: {
|
|
200
|
+
fontSize: 16,
|
|
201
|
+
fontWeight: '600',
|
|
202
|
+
},
|
|
203
|
+
arrowBtn: {
|
|
204
|
+
width: 36,
|
|
205
|
+
height: 36,
|
|
206
|
+
alignItems: 'center',
|
|
207
|
+
justifyContent: 'center',
|
|
208
|
+
},
|
|
209
|
+
arrow: {
|
|
210
|
+
fontSize: 22,
|
|
211
|
+
lineHeight: 26,
|
|
212
|
+
},
|
|
213
|
+
weekRow: {
|
|
214
|
+
flexDirection: 'row',
|
|
215
|
+
},
|
|
216
|
+
weekText: {
|
|
217
|
+
fontSize: 12,
|
|
218
|
+
textAlign: 'center',
|
|
219
|
+
},
|
|
220
|
+
cell: {
|
|
221
|
+
flex: 1,
|
|
222
|
+
aspectRatio: 1.15,
|
|
223
|
+
alignItems: 'center',
|
|
224
|
+
justifyContent: 'center',
|
|
225
|
+
},
|
|
226
|
+
cellInner: {
|
|
227
|
+
minWidth: 30,
|
|
228
|
+
height: 30,
|
|
229
|
+
alignItems: 'center',
|
|
230
|
+
justifyContent: 'center',
|
|
231
|
+
borderRadius: 6,
|
|
232
|
+
},
|
|
233
|
+
dayText: {
|
|
234
|
+
fontSize: 15,
|
|
235
|
+
textAlign: 'center',
|
|
236
|
+
},
|
|
237
|
+
dim: {
|
|
238
|
+
opacity: 0.35,
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
export default XCalendar;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* XCarousel —— 轮播图(duxui Swiper 的 RN 原生重写版)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 【与 duxui Swiper 的关系】
|
|
7
|
+
* duxui 的 Swiper 直接封装 Taro <Swiper>(RN 端是 Taro 适配层),
|
|
8
|
+
* 本项目脱离 Taro 运行时,这里用原生 ScrollView pagingEnabled 自实现,
|
|
9
|
+
* 并补齐 duxui 没有的「无限循环」(克隆首尾页 + 落点归位)。
|
|
10
|
+
*
|
|
11
|
+
* 【实现要点】
|
|
12
|
+
* 1. 无限循环:pages = [克隆尾页, ...data, 克隆首页],初始停在真实首页;
|
|
13
|
+
* onMomentumScrollEnd 检测到落在克隆页时,立即无动画滚回真实页,
|
|
14
|
+
* 视觉上完全无感;
|
|
15
|
+
* 2. 自动播放:setInterval 定时 scrollTo 下一页(animated),当前下标以
|
|
16
|
+
* currentRef 为单一数据源(momentum 回调同步回写);手指拖动期间
|
|
17
|
+
* (onScrollBeginDrag)暂停,松手后恢复,避免"抢手感";
|
|
18
|
+
* 3. 指示点:自绘 View(duxui 的 dot/dotColor/dotSelectColor/dotDistance 同名 props);
|
|
19
|
+
* 4. 容器宽度用 onLayout 实测(不依赖 Dimensions,适配分屏/旋转),
|
|
20
|
+
* 首次测量完成后做一次初始落位。
|
|
21
|
+
*
|
|
22
|
+
* 【对标 duxui API】autoplay/interval/circular/vertical/dot/dotColor/
|
|
23
|
+
* dotSelectColor/dotDistance/defaultCurrent 全保留;差异:RN 端用
|
|
24
|
+
* renderItem 数据驱动(duxui 是 SwiperItem 子组件声明式),更贴合列表习惯。
|
|
25
|
+
* ============================================================================
|
|
26
|
+
*/
|
|
27
|
+
import React from 'react';
|
|
28
|
+
import { StyleProp, ViewStyle } from 'react-native';
|
|
29
|
+
/** 单条数据:优先级 render > image > color(title 叠加在色块上,便于无图演示) */
|
|
30
|
+
export interface XCarouselItem {
|
|
31
|
+
/** 网络图/本地图 */
|
|
32
|
+
image?: {
|
|
33
|
+
uri: string;
|
|
34
|
+
} | number;
|
|
35
|
+
/** 无图时用纯色块占位 */
|
|
36
|
+
color?: string;
|
|
37
|
+
/** 色块上的标题文字(可选) */
|
|
38
|
+
title?: string;
|
|
39
|
+
/** 完全自定义渲染 */
|
|
40
|
+
render?: () => React.ReactNode;
|
|
41
|
+
}
|
|
42
|
+
export interface XCarouselProps {
|
|
43
|
+
/** 数据源 */
|
|
44
|
+
data: XCarouselItem[];
|
|
45
|
+
/** 自定义渲染每页 */
|
|
46
|
+
renderItem?: (item: XCarouselItem, index: number) => React.ReactNode;
|
|
47
|
+
/** 自动播放,默认 true(data.length <= 1 时自动关闭) */
|
|
48
|
+
autoplay?: boolean;
|
|
49
|
+
/** 自动播放间隔 ms,默认 3000 */
|
|
50
|
+
interval?: number;
|
|
51
|
+
/** 无限循环,默认 true */
|
|
52
|
+
circular?: boolean;
|
|
53
|
+
/** 纵向轮播,默认 false */
|
|
54
|
+
vertical?: boolean;
|
|
55
|
+
/** 是否显示指示点,默认 true */
|
|
56
|
+
dot?: boolean;
|
|
57
|
+
/** 指示点颜色(未选中),默认白 40% */
|
|
58
|
+
dotColor?: string;
|
|
59
|
+
/** 指示点选中色,默认白色 */
|
|
60
|
+
dotSelectColor?: string;
|
|
61
|
+
/** 指示点间距,默认 6 */
|
|
62
|
+
dotDistance?: number;
|
|
63
|
+
/** 初始页(真实数据下标),默认 0 */
|
|
64
|
+
defaultCurrent?: number;
|
|
65
|
+
/** 高度,默认 180 */
|
|
66
|
+
height?: number;
|
|
67
|
+
/** 页面切换回调(真实数据下标) */
|
|
68
|
+
onIndexChange?: (index: number) => void;
|
|
69
|
+
style?: StyleProp<ViewStyle>;
|
|
70
|
+
}
|
|
71
|
+
export declare function XCarousel({ data, renderItem, autoplay, interval, circular, vertical, dot, dotColor, dotSelectColor, dotDistance, defaultCurrent, height, onIndexChange, style, }: XCarouselProps): React.JSX.Element;
|
|
72
|
+
export default XCarousel;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* XCarousel —— 轮播图(duxui Swiper 的 RN 原生重写版)
|
|
5
|
+
* ============================================================================
|
|
6
|
+
*
|
|
7
|
+
* 【与 duxui Swiper 的关系】
|
|
8
|
+
* duxui 的 Swiper 直接封装 Taro <Swiper>(RN 端是 Taro 适配层),
|
|
9
|
+
* 本项目脱离 Taro 运行时,这里用原生 ScrollView pagingEnabled 自实现,
|
|
10
|
+
* 并补齐 duxui 没有的「无限循环」(克隆首尾页 + 落点归位)。
|
|
11
|
+
*
|
|
12
|
+
* 【实现要点】
|
|
13
|
+
* 1. 无限循环:pages = [克隆尾页, ...data, 克隆首页],初始停在真实首页;
|
|
14
|
+
* onMomentumScrollEnd 检测到落在克隆页时,立即无动画滚回真实页,
|
|
15
|
+
* 视觉上完全无感;
|
|
16
|
+
* 2. 自动播放:setInterval 定时 scrollTo 下一页(animated),当前下标以
|
|
17
|
+
* currentRef 为单一数据源(momentum 回调同步回写);手指拖动期间
|
|
18
|
+
* (onScrollBeginDrag)暂停,松手后恢复,避免"抢手感";
|
|
19
|
+
* 3. 指示点:自绘 View(duxui 的 dot/dotColor/dotSelectColor/dotDistance 同名 props);
|
|
20
|
+
* 4. 容器宽度用 onLayout 实测(不依赖 Dimensions,适配分屏/旋转),
|
|
21
|
+
* 首次测量完成后做一次初始落位。
|
|
22
|
+
*
|
|
23
|
+
* 【对标 duxui API】autoplay/interval/circular/vertical/dot/dotColor/
|
|
24
|
+
* dotSelectColor/dotDistance/defaultCurrent 全保留;差异:RN 端用
|
|
25
|
+
* renderItem 数据驱动(duxui 是 SwiperItem 子组件声明式),更贴合列表习惯。
|
|
26
|
+
* ============================================================================
|
|
27
|
+
*/
|
|
28
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
29
|
+
import { Dimensions, Image, ScrollView, StyleSheet, Text, View, } from 'react-native';
|
|
30
|
+
import { useXTheme } from '../theme';
|
|
31
|
+
/** 取一次屏幕宽做初始兜底(onLayout 首帧后立刻被真实宽度覆盖) */
|
|
32
|
+
const INITIAL_WIDTH = Dimensions.get('window').width;
|
|
33
|
+
export function XCarousel({ data, renderItem, autoplay = true, interval = 3000, circular = true, vertical = false, dot = true, dotColor, dotSelectColor, dotDistance = 6, defaultCurrent = 0, height = 180, onIndexChange, style, }) {
|
|
34
|
+
const t = useXTheme();
|
|
35
|
+
const listRef = useRef(null);
|
|
36
|
+
/** 容器宽度(onLayout 实测) */
|
|
37
|
+
const [width, setWidth] = useState(INITIAL_WIDTH);
|
|
38
|
+
/** 当前真实数据下标(渲染指示点用) */
|
|
39
|
+
const [current, setCurrent] = useState(defaultCurrent);
|
|
40
|
+
/** 当前真实下标的 ref(自动播放的单一数据源,避免 setState 副作用) */
|
|
41
|
+
const currentRef = useRef(defaultCurrent);
|
|
42
|
+
/** 手指拖动中:暂停自动播放 */
|
|
43
|
+
const draggingRef = useRef(false);
|
|
44
|
+
/** 是否已完成初始落位 */
|
|
45
|
+
const positionedRef = useRef(false);
|
|
46
|
+
const count = data.length;
|
|
47
|
+
const single = count <= 1;
|
|
48
|
+
/**
|
|
49
|
+
* 循环模式:首尾各克隆一页。
|
|
50
|
+
* pages[0] = data[count-1],pages[count+1] = data[0],
|
|
51
|
+
* 真实页在 [1, count],初始落位 pages 下标 = defaultCurrent + 1。
|
|
52
|
+
*/
|
|
53
|
+
const pages = useMemo(() => {
|
|
54
|
+
if (single || !circular)
|
|
55
|
+
return data;
|
|
56
|
+
return [data[count - 1], ...data, data[0]];
|
|
57
|
+
}, [data, circular, single, count]);
|
|
58
|
+
/** 真实下标 → pages 下标(含克隆偏移) */
|
|
59
|
+
const toPageIndex = useCallback((realIndex) => realIndex + 1, []);
|
|
60
|
+
/** pages 下标 → 真实下标(克隆页归一化到 0 / count-1) */
|
|
61
|
+
const toRealIndex = useCallback((pageIndex) => {
|
|
62
|
+
if (pageIndex <= 0)
|
|
63
|
+
return count - 1;
|
|
64
|
+
if (pageIndex >= count + 1)
|
|
65
|
+
return 0;
|
|
66
|
+
return pageIndex - 1;
|
|
67
|
+
}, [count]);
|
|
68
|
+
/** 滚动到指定 pages 下标 */
|
|
69
|
+
const scrollToPage = useCallback((pageIndex, animated) => {
|
|
70
|
+
listRef.current?.scrollTo(vertical ? { y: pageIndex * height, animated } : { x: pageIndex * width, animated });
|
|
71
|
+
}, [vertical, height, width]);
|
|
72
|
+
/** 真实下标上报 + 指示点同步(ref 与 state 同步更新) */
|
|
73
|
+
const reportReal = useCallback((realIndex) => {
|
|
74
|
+
currentRef.current = realIndex;
|
|
75
|
+
setCurrent(prev => (prev === realIndex ? prev : realIndex));
|
|
76
|
+
onIndexChange?.(realIndex);
|
|
77
|
+
}, [onIndexChange]);
|
|
78
|
+
/** 首次测得真实宽度后做初始落位(落在 defaultCurrent 对应真实页) */
|
|
79
|
+
const handleLayout = useCallback((e) => {
|
|
80
|
+
const w = e.nativeEvent.layout.width;
|
|
81
|
+
setWidth(prev => {
|
|
82
|
+
positionedRef.current = true;
|
|
83
|
+
const pageIndex = toPageIndex(currentRef.current);
|
|
84
|
+
// 无动画跳到初始页;用实测宽度计算,rAF 等 ScrollView 完成布局
|
|
85
|
+
requestAnimationFrame(() => listRef.current?.scrollTo(vertical ? { y: pageIndex * height, animated: false } : { x: pageIndex * w, animated: false }));
|
|
86
|
+
return prev === w ? prev : w;
|
|
87
|
+
});
|
|
88
|
+
}, [toPageIndex, vertical, height]);
|
|
89
|
+
/**
|
|
90
|
+
* onMomentumScrollEnd 统一处理:
|
|
91
|
+
* 1. 由 pages 下标算真实下标并上报;
|
|
92
|
+
* 2. 落在克隆页时立即无动画归位(用户无感)。
|
|
93
|
+
*/
|
|
94
|
+
const handleMomentumEnd = useCallback((e) => {
|
|
95
|
+
const offset = vertical ? e.nativeEvent.contentOffset.y : e.nativeEvent.contentOffset.x;
|
|
96
|
+
const size = vertical ? height : width;
|
|
97
|
+
if (size <= 0)
|
|
98
|
+
return;
|
|
99
|
+
const pageIndex = Math.round(offset / size);
|
|
100
|
+
if (!single && circular) {
|
|
101
|
+
if (pageIndex === 0) {
|
|
102
|
+
// 落在"尾页克隆"→ 归位到真实最后一页
|
|
103
|
+
reportReal(count - 1);
|
|
104
|
+
scrollToPage(1, false);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (pageIndex === count + 1) {
|
|
108
|
+
// 落在"首页克隆"→ 归位到真实第一页
|
|
109
|
+
reportReal(0);
|
|
110
|
+
scrollToPage(count, false);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
reportReal(toRealIndex(pageIndex));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const realIndex = Math.min(Math.max(pageIndex, 0), count - 1);
|
|
117
|
+
reportReal(realIndex);
|
|
118
|
+
}, [vertical, height, width, single, circular, count, reportReal, scrollToPage, toRealIndex]);
|
|
119
|
+
/** 自动播放:拖动暂停;interval 到点滚向下一页(末页循环回首) */
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!autoplay || single)
|
|
122
|
+
return;
|
|
123
|
+
const timer = setInterval(() => {
|
|
124
|
+
if (draggingRef.current)
|
|
125
|
+
return;
|
|
126
|
+
const nextReal = (currentRef.current + 1) % count;
|
|
127
|
+
scrollToPage(toPageIndex(nextReal), true);
|
|
128
|
+
}, interval);
|
|
129
|
+
return () => clearInterval(timer);
|
|
130
|
+
}, [autoplay, single, interval, count, scrollToPage, toPageIndex]);
|
|
131
|
+
/** 指示点最终颜色(默认适配图片场景:白点;可覆盖) */
|
|
132
|
+
const inactiveDot = dotColor ?? 'rgba(255, 255, 255, 0.4)';
|
|
133
|
+
const activeDot = dotSelectColor ?? t.colorTextLightSolid;
|
|
134
|
+
/** 单页渲染 */
|
|
135
|
+
const renderPage = (item, realIndex) => {
|
|
136
|
+
if (renderItem)
|
|
137
|
+
return renderItem(item, realIndex);
|
|
138
|
+
if (item.render)
|
|
139
|
+
return item.render();
|
|
140
|
+
if (item.image) {
|
|
141
|
+
return _jsx(Image, { source: item.image, style: styles.image, resizeMode: 'cover' });
|
|
142
|
+
}
|
|
143
|
+
return (_jsx(View, { style: [styles.colorPage, { backgroundColor: item.color ?? t.colorPrimary }], children: item.title ? _jsx(Text, { style: styles.title, children: item.title }) : null }));
|
|
144
|
+
};
|
|
145
|
+
return (_jsxs(View, { style: [styles.container, { height, borderRadius: t.borderRadiusXL }, style], onLayout: handleLayout, children: [_jsx(ScrollView, { ref: listRef, horizontal: !vertical, pagingEnabled: true, showsHorizontalScrollIndicator: false, showsVerticalScrollIndicator: false, onScrollBeginDrag: () => (draggingRef.current = true), onScrollEndDrag: () => (draggingRef.current = false), onMomentumScrollEnd: handleMomentumEnd, children: pages.map((item, pageIndex) => (_jsx(View, { style: { width, height }, children: renderPage(item, Math.min(Math.max(toRealIndex(pageIndex), 0), count - 1)) }, pageIndex))) }), dot && count > 1 && (_jsx(View, { style: styles.dots, pointerEvents: 'none', children: data.map((_, i) => (_jsx(View, { style: [
|
|
146
|
+
styles.dot,
|
|
147
|
+
{ marginHorizontal: dotDistance / 2, backgroundColor: i === current ? activeDot : inactiveDot },
|
|
148
|
+
] }, i))) }))] }));
|
|
149
|
+
}
|
|
150
|
+
const styles = StyleSheet.create({
|
|
151
|
+
container: {
|
|
152
|
+
overflow: 'hidden',
|
|
153
|
+
backgroundColor: '#ddd',
|
|
154
|
+
},
|
|
155
|
+
image: {
|
|
156
|
+
width: '100%',
|
|
157
|
+
height: '100%',
|
|
158
|
+
},
|
|
159
|
+
colorPage: {
|
|
160
|
+
flex: 1,
|
|
161
|
+
alignItems: 'center',
|
|
162
|
+
justifyContent: 'center',
|
|
163
|
+
},
|
|
164
|
+
title: {
|
|
165
|
+
color: '#fff',
|
|
166
|
+
fontSize: 20,
|
|
167
|
+
fontWeight: '600',
|
|
168
|
+
},
|
|
169
|
+
dots: {
|
|
170
|
+
position: 'absolute',
|
|
171
|
+
left: 0,
|
|
172
|
+
right: 0,
|
|
173
|
+
bottom: 10,
|
|
174
|
+
flexDirection: 'row',
|
|
175
|
+
justifyContent: 'center',
|
|
176
|
+
},
|
|
177
|
+
dot: {
|
|
178
|
+
width: 6,
|
|
179
|
+
height: 6,
|
|
180
|
+
borderRadius: 3,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
export default XCarousel;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* XCascadeSelect —— 级联选择器(antd Cascader 的 RN 自实现版)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 数据结构(antd Cascader 同款):
|
|
7
|
+
* options = [{label, value, children: [{label, value, children: ...}]}]
|
|
8
|
+
* value = ['hunan', 'changsha', 'yuelu'](各级 value 组成的路径数组)
|
|
9
|
+
* 展示 = 湖南 / 长沙 / 岳麓(路径上各级 label 用 " / " 连接)
|
|
10
|
+
*
|
|
11
|
+
* 交互形态:点击触发区弹出底部面板(XPullView),多列滚轮(XWheel)联动——
|
|
12
|
+
* 改动第 i 列会截断更深的路径,后续列选项自动跟着变。
|
|
13
|
+
* 路径必须走到叶子(没有 children 的选项)才能点确定。
|
|
14
|
+
*
|
|
15
|
+
* 用法(Form.Item 注入 value/onChange 即可作表单控件):
|
|
16
|
+
* <XCascadeSelect options={AREA_DATA} placeholder='请选择省市区' />
|
|
17
|
+
*/
|
|
18
|
+
import React from 'react';
|
|
19
|
+
export interface XCascadeOption {
|
|
20
|
+
label: string;
|
|
21
|
+
value: any;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
children?: XCascadeOption[];
|
|
24
|
+
}
|
|
25
|
+
export interface XCascadeSelectProps {
|
|
26
|
+
/** 路径数组:每级选中的 value(如 ['hunan','changsha','yuelu']) */
|
|
27
|
+
value?: any[];
|
|
28
|
+
onChange?: (path: any[]) => void;
|
|
29
|
+
options: XCascadeOption[];
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
/** 弹层标题,默认"请选择" */
|
|
32
|
+
title?: string;
|
|
33
|
+
/** 自定义触发器(不传则渲染默认的输入框样式触发区) */
|
|
34
|
+
children?: React.ReactNode;
|
|
35
|
+
}
|
|
36
|
+
/** 沿路径取各级选项(找不到即截断) */
|
|
37
|
+
export declare function getCascadePathOptions(options: XCascadeOption[], path: any[]): XCascadeOption[];
|
|
38
|
+
export declare function XCascadeSelect({ value, onChange, options, placeholder, title, children }: XCascadeSelectProps): React.JSX.Element;
|
|
39
|
+
export default XCascadeSelect;
|