IndexList.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. <template>
  2. <FixedVirtualList
  3. :data="groupedData.list"
  4. dataKey="id"
  5. :containerStyle="innerStyle"
  6. :itemSize="itemHeight"
  7. :bufferSize="2"
  8. :scrollTop="scrollCurrentValue"
  9. @scroll="handleScroll"
  10. v-bind="$attrs"
  11. >
  12. <template #list="{ visibleItems, className, itemStyle }">
  13. <slot
  14. name="list"
  15. :visibleItems="visibleItems"
  16. :className="className"
  17. :itemStyle="itemStyle"
  18. :onItemPress="onItemPress"
  19. >
  20. <view
  21. v-for="(row, index) in visibleItems"
  22. :key="row.key"
  23. :style="itemStyle"
  24. :class="className"
  25. >
  26. <slot v-if="row.item.isHeader" name="header" :header="row.item.header" :id="row.item.id">
  27. <text
  28. :id="row.item.id"
  29. :style="{
  30. ...themeStyles.header.value,
  31. ...headerStyle,
  32. }"
  33. >
  34. {{ row.item.header }}
  35. </text>
  36. </slot>
  37. <slot v-else name="item" :item="row.item.data" :index="index" :id="row.item.id">
  38. <SimpleListItem
  39. :id="row.item.id"
  40. :item="row.item.data"
  41. :index="index"
  42. :dataDisplayProp="dataDisplayProp"
  43. :colorProp="colorProp"
  44. :disabledProp="disabledProp"
  45. :showCheck="mode !== 'select'"
  46. :checked="checkedList.indexOf(row.item) >= 0"
  47. @click="onItemPress(row.item, index)"
  48. >
  49. <template v-if="$slots.itemContent" #itemContent>
  50. <slot name="itemContent" :item="row.item.data" :index="index" :id="row.item.id" />
  51. </template>
  52. </SimpleListItem>
  53. </slot>
  54. </view>
  55. </slot>
  56. </template>
  57. <template #empty>
  58. <slot name="empty">
  59. <Empty :description="emptyText" />
  60. </slot>
  61. </template>
  62. <template #inner>
  63. <IndexBar
  64. :activeIndex="activeIndex"
  65. :data="groupedData.index"
  66. @drag="handleDrag"
  67. />
  68. </template>
  69. </FixedVirtualList>
  70. </template>
  71. <script setup lang="ts" generic="T">
  72. import { computed, nextTick, provide, ref, type Ref } from 'vue';
  73. import { useTheme, type TextStyle, type ViewStyle } from '../theme/ThemeDefine';
  74. import { DynamicColor, DynamicSize, DynamicSize2, DynamicVar } from '../theme/ThemeTools';
  75. import type { CheckBoxDefaultButtonProps } from '../form/CheckBoxDefaultButton.vue';
  76. import type { SimpleListContext } from './SimpleList.vue';
  77. import Empty from '../feedback/Empty.vue';
  78. import FixedVirtualList from './FixedVirtualList.vue';
  79. import SimpleListItem from './SimpleListItem.vue';
  80. import IndexBar from '../nav/IndexBar.vue';
  81. export interface IndexedListGrouperedData<T> {
  82. isHeader?: boolean,
  83. id: string,
  84. header?: string,
  85. headerIndex?: number,
  86. data?: T
  87. }
  88. export interface SimpleListProps<T> {
  89. /**
  90. * 分组标题的自定义样式
  91. */
  92. groupStyle?: ViewStyle;
  93. /**
  94. * 分组标题文字的自定义样式
  95. */
  96. groupTextStyle?: TextStyle;
  97. /**
  98. * 条目的自定义样式
  99. */
  100. itemStyle?: ViewStyle;
  101. /**
  102. * 条目文字的自定义样式
  103. */
  104. textStyle?: TextStyle;
  105. /**
  106. * 选中的条目的自定义样式
  107. */
  108. checkedItemStyle?: ViewStyle;
  109. /**
  110. * 选中的条目文字的自定义样式
  111. */
  112. checkedTextStyle?: TextStyle;
  113. /**
  114. * 空数据时显示的文字
  115. */
  116. emptyText?: string,
  117. /**
  118. * 源数据
  119. */
  120. data: T[],
  121. /**
  122. * 数据项的唯一键名,用于vue循环优化
  123. */
  124. dataKey?: string,
  125. /**
  126. * 显示数据的prop,如果为空,则尝试直接把数据当 string 显示。
  127. */
  128. dataDisplayProp?: string,
  129. /**
  130. * 控制数据条目颜色的字段名称,为空则使用默认颜色。
  131. */
  132. colorProp?: string,
  133. /**
  134. * 控制是否禁用数据条目的字段名称,为空则不禁用。
  135. */
  136. disabledProp?: string,
  137. /**
  138. * 每个条目的高度px
  139. * @default 40
  140. */
  141. itemHeight?: number;
  142. /**
  143. * 分组标题的高度px
  144. * @default 30
  145. */
  146. headerHeight?: number;
  147. /**
  148. * 对数据进行分组。每个条目会调用一次这个回调,你需要在此回调中返回此条目所在分组的名字。
  149. */
  150. groupDataBy: (item: T) => string,
  151. /**
  152. * 对组进行排序
  153. */
  154. sortGroup?: (headers: string[]) => string[],
  155. /**
  156. * 列表的选择模式
  157. *
  158. * * select 点击选择模式
  159. * * single-check 单选选择模式,条目右边有选择框
  160. * * mulit-check 多选选择模式,条目右边有选择框
  161. * @default 'select'
  162. */
  163. mode?: 'select'|'single-check'|'mulit-check',
  164. /**
  165. * 当列表显示选择框时,选择框的自定义属性
  166. * @default {
  167. * borderColor: 'border.cell',
  168. * checkColor: 'white',
  169. * color: 'primary',
  170. * size: 20,
  171. * iconSize: mode === 'single-check' ? 10 : 16,
  172. * type: mode === 'single-check' ? 'radio' : 'icon',
  173. * disabled: false,
  174. * }
  175. */
  176. checkProps?: CheckBoxDefaultButtonProps,
  177. /**
  178. * 当用使用选择框模式时,默认选中条目
  179. * @default []
  180. */
  181. defaultSelect?: any[],
  182. innerStyle?: ViewStyle;
  183. }
  184. const props = withDefaults(defineProps<SimpleListProps<T>>(), {
  185. mode: 'select',
  186. virtual: false,
  187. itemHeight: 40,
  188. headerHeight: 30,
  189. });
  190. const emit = defineEmits([ 'itemClick', 'selectedItemChanged' ]);
  191. const themeContext = useTheme();
  192. const themeStyles = themeContext.useThemeStyles({
  193. list: {
  194. backgroundColor: DynamicColor('IndexedListBackgroundColor', 'white'),
  195. },
  196. header: {
  197. display: 'flex',
  198. flexDirection: 'row',
  199. alignItems: 'center',
  200. backgroundColor: DynamicColor('IndexedListHeaderBackgroundColor', 'background.page'),
  201. padding: DynamicSize2('IndexedListHeaderPaddingVertical', 'IndexedListHeaderPaddingHorizontal', 0, 30),
  202. fontSize: DynamicSize('IndexedListHeaderFontSize', 24),
  203. color: DynamicColor('IndexedListHeaderColor', 'text.second'),
  204. position: 'sticky',
  205. },
  206. item: {
  207. display: 'flex',
  208. flexDirection: 'row',
  209. alignItems: 'center',
  210. paddingLeft: DynamicSize('IndexedListItemPaddingHorizontal', 30),
  211. paddingRight: DynamicSize('IndexedListItemPaddingHorizontal', 30),
  212. fontSize: DynamicSize('IndexedListItemFontSize', 28),
  213. borderTopWidth: DynamicSize('IndexedListItemBorderTopWidth', 0),
  214. borderBottomStyle: 'solid',
  215. borderBottomWidth: DynamicSize('IndexedListItemBorderBottomWidth', 2),
  216. borderColor: DynamicColor('IndexedListItemBorderColor', 'border.cell'),
  217. color: DynamicColor('IndexedListItemColor', 'text.content'),
  218. backgroundColor: DynamicColor('IndexedListItemBackgroundColor', 'white'),
  219. },
  220. itemText: {
  221. },
  222. itemTextChecked: {
  223. fontSize: DynamicSize('IndexedListItemCheckedTextFontSize', 28),
  224. fontWeight: DynamicVar('IndexedListItemCheckedTextFontWeight', 'bold'),
  225. color: DynamicColor('IndexedListItemCheckedTextColor', 'primary'),
  226. },
  227. });
  228. let flushLock = false;
  229. const activeIndex = ref(-1);
  230. const scrollCurrentValue = ref(0);
  231. function handleScroll(e: any) {
  232. if (scrollCurrentValue.value != e.detail.scrollTop) {
  233. scrollCurrentValue.value = e.detail.scrollTop;
  234. if (!flushLock) {
  235. const index = Math.floor(scrollCurrentValue.value / props.itemHeight);
  236. for (let i = index; i >= 0; i--) {
  237. if (groupedData.value.list[i].isHeader) {
  238. activeIndex.value = groupedData.value.list[i].headerIndex || -1;
  239. break;
  240. }
  241. }
  242. }
  243. }
  244. }
  245. function handleDrag(v: number) {
  246. flushLock = true;
  247. nextTick(() => {
  248. activeIndex.value = v;
  249. const index = groupedData.value.list.findIndex(p => p.header === groupedData.value.index[v])
  250. if (index >= 0)
  251. scrollCurrentValue.value = index * props.itemHeight;
  252. nextTick(() => {
  253. flushLock = false;
  254. });
  255. });
  256. }
  257. const headerStyle = computed(() => ({
  258. ...themeStyles.header.value,
  259. height: `${props.headerHeight}px`,
  260. } as ViewStyle));
  261. const itemStyle = computed(() => ({
  262. ...themeStyles.item.value,
  263. height: `${props.itemHeight}px`,
  264. ...props.itemStyle,
  265. } as ViewStyle));
  266. const textStyle = computed(() => ({
  267. ...themeStyles.itemText.value,
  268. ...props.textStyle,
  269. } as ViewStyle));
  270. const checkedItemStyle = computed(() => ({
  271. ...themeStyles.item.value,
  272. height: themeContext.resolveSize(props.itemHeight),
  273. ...props.checkedItemStyle,
  274. } as ViewStyle));
  275. const checkedTextStyle = computed(() => ({
  276. ...themeStyles.itemText.value,
  277. ...themeStyles.itemTextChecked.value,
  278. ...props.checkedTextStyle,
  279. } as ViewStyle));
  280. const checkProps = computed(() => ({
  281. borderColor: 'border.cell',
  282. checkColor: 'white',
  283. color: 'primary',
  284. size: 40,
  285. iconSize: props.mode === 'single-check' ? 20 : 32,
  286. type: props.mode === 'single-check' ? 'radio' : 'icon',
  287. disabled: false,
  288. ...props.checkProps,
  289. } as CheckBoxDefaultButtonProps));
  290. const pressedColor = computed(() => {
  291. return themeContext.resolveThemeColor('SimpleListItemPressedColor', 'pressed.white')!;
  292. });
  293. const groupedData = computed(() => {
  294. const map = new Map<string, T[]>();
  295. //归组数据
  296. props.data.forEach((model) => {
  297. const groupKey = props.groupDataBy(model);
  298. let arr = map.get(groupKey);
  299. if (!arr){
  300. arr = [];
  301. map.set(groupKey, arr);
  302. }
  303. arr.push(model);
  304. });
  305. //填充数据
  306. const arr = [] as IndexedListGrouperedData<T>[];
  307. const arrIndex = [] as string[];
  308. //排序分组
  309. const mapKeys = Array.from(map.keys());
  310. const groups = props.sortGroup ? props.sortGroup(mapKeys) : mapKeys;
  311. let i = 0, j = 0;
  312. for (const k of groups) {
  313. const v = map.get(k);
  314. if (!v) continue;
  315. arr.push({
  316. id: `indexListNav${k}`,
  317. isHeader: true,
  318. headerIndex: j,
  319. header: k,
  320. });
  321. v.forEach((c) => {
  322. arr.push({
  323. id: `indexListItem${i++}`,
  324. data: c,
  325. });
  326. });
  327. arrIndex.push(k);
  328. j++;
  329. }
  330. return {
  331. list: arr,
  332. index: arrIndex,
  333. };
  334. });
  335. provide<SimpleListContext>('SimpleListContext', {
  336. itemStyle,
  337. textStyle,
  338. checkedItemStyle,
  339. checkedTextStyle,
  340. checkProps,
  341. pressedColor,
  342. });
  343. const checkedList = ref(props.defaultSelect || []) as Ref<IndexedListGrouperedData<T>[]>;
  344. function onItemPress(item: IndexedListGrouperedData<T>, index: number) {
  345. if (props.mode === 'single-check') {
  346. checkedList.value = ([ item ]);
  347. emit('selectedItemChanged', [ item.data ]);
  348. } else if (props.mode === 'mulit-check') {
  349. checkedList.value = ((prev) => {
  350. let arr : IndexedListGrouperedData<T>[];
  351. if (prev.indexOf(item) >= 0)
  352. arr = prev.filter(k => k !== item);
  353. else
  354. arr = prev.concat([ item ]);
  355. setTimeout(() => {
  356. emit('selectedItemChanged', arr.map(p => p.data));
  357. }, 100);
  358. return arr;
  359. })(checkedList.value);
  360. }
  361. emit('itemClick', item.data, index);
  362. }
  363. </script>