Tabs.vue 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. <template>
  2. <scroll-view
  3. :scroll-x="true"
  4. :scroll-with-animation="true"
  5. :show-scrollbar="false"
  6. :scroll-left="currentScrollPos"
  7. :style="{
  8. width: theme.resolveSize(props.width),
  9. height: theme.resolveSize(props.height),
  10. ...innerStyle,
  11. }"
  12. >
  13. <view
  14. class="nana-tabs"
  15. :style="{
  16. minWidth: theme.resolveSize(props.width),
  17. height: theme.resolveSize(props.height),
  18. }"
  19. >
  20. <Touchable
  21. v-for="(tab, index) in filteredTabs"
  22. :ref="(ref) => tabsRefs[index] = ref"
  23. :key="index"
  24. direction="row"
  25. :pressedColor="themedUnderlayColor"
  26. :innerStyle="itemStyle"
  27. :width="itemWidthArr[index].width > 0 ?
  28. theme.resolveSize(itemWidthArr[index].width - tabPaddingHorizontal * 2) :
  29. undefined"
  30. :touchable="!tab.disabled"
  31. :flexShrink="0"
  32. :padding="[ 0, tabPaddingHorizontal ]"
  33. center
  34. innerClass="tab-item"
  35. @click="onTabClick(index)"
  36. >
  37. <slot name="tab"
  38. :tab="tab"
  39. :index="index"
  40. :width="itemWidthArr[index].width"
  41. :active="currentIndex == index"
  42. >
  43. <Badge
  44. content="0"
  45. v-bind="tab.badgeProps"
  46. >
  47. <text
  48. class="tab-item-text"
  49. :style="{
  50. color: tab.disabled ? themedDisableTextColor : (currentIndex == index ? themedActiveTextColor : themedTextColor),
  51. whiteSpace: props.noWrap ? 'nowrap' : 'normal',
  52. ...(currentIndex == index ? activeTextStyle : textStyle),
  53. }"
  54. >
  55. {{ tab.text }}
  56. </text>
  57. </Badge>
  58. </slot>
  59. </Touchable>
  60. <view
  61. v-if="showIndicator"
  62. :style="{
  63. backgroundColor: theme.resolveThemeColor(activeTextColor),
  64. ...indicatorStyle,
  65. width: `${currentIndicatorWidth}px`,
  66. transform: `translateX(${currentIndicatorPos}px)`,
  67. }"
  68. :class="[
  69. 'tab-indicator',
  70. indicatorAnim ? 'anim' : '',
  71. ]"
  72. />
  73. </view>
  74. </scroll-view>
  75. </template>
  76. <script setup lang="ts">
  77. import { computed, nextTick, onMounted, ref, watch } from 'vue';
  78. import { propGetThemeVar, useTheme, type TextStyle, type ViewStyle } from '../theme/ThemeDefine';
  79. import type { BadgeProps } from '../display/Badge.vue';
  80. import Badge from '../display/Badge.vue';
  81. import Touchable from '../feedback/Touchable.vue';
  82. export interface TabsItemData {
  83. /**
  84. * 标签显示文字
  85. */
  86. text: string,
  87. /**
  88. * 自定义数据
  89. */
  90. data?: any,
  91. /**
  92. * 是否显示。
  93. * @default true
  94. */
  95. visible?: boolean;
  96. /**
  97. * 是否禁用选择。
  98. * @default false
  99. */
  100. disabled?: boolean,
  101. /**
  102. * 红点标记的自定义属性。同 Badge 组件。
  103. */
  104. badgeProps?: BadgeProps,
  105. /**
  106. * 标签宽度。此设置优先级最高, 会覆盖 Tabs 组件的设置。
  107. * 如果设置为-1,则根据文字宽度自动调整。
  108. * 如果设置为 undefined ,则使用 defaultItemWidth 。
  109. * @default undefined
  110. */
  111. width?: number,
  112. /**
  113. * 指示器宽度,默认值从 Tabs 组件继承。
  114. */
  115. indicatorWidth?: number,
  116. /**
  117. * 懒加载?,仅在TabsPage组件中有效
  118. */
  119. lazy?: boolean,
  120. }
  121. export interface TabsProps {
  122. /**
  123. * 标签数据
  124. */
  125. tabs: TabsItemData[];
  126. /**
  127. * 当前选中的标签位置
  128. */
  129. currentIndex: number;
  130. /**
  131. * 整个组件的高度
  132. * @default 90rpx
  133. */
  134. height?: number,
  135. /**
  136. * 整个组件的宽度
  137. * @default deviceWidth
  138. */
  139. width?: number,
  140. /**
  141. * 是否自动根据容器宽度调整标签宽度
  142. * @default true
  143. */
  144. autoItemWidth?: boolean,
  145. /**
  146. * 指示器切换标签时是否有动画效果
  147. * @default true
  148. */
  149. indicatorAnim?: boolean,
  150. /**
  151. * 是否在用户切换标签后自动滚动至当前选中的条目
  152. * @default true
  153. */
  154. autoScroll?: boolean,
  155. /**
  156. * 是否禁止标签文字换行
  157. * @default true
  158. */
  159. noWrap?: boolean,
  160. /**
  161. * 标签宽度,在 autoItemWidth 为 false 时有效。
  162. * 如果设置为-1,则根据文字宽度自动调整。
  163. * @default 100 (rpx)
  164. */
  165. defaultItemWidth?: number,
  166. /**
  167. * 默认的指示器宽度。
  168. * @default itemWidth / 4
  169. */
  170. defaultIndicatorWidth?: number,
  171. /**
  172. * 标签自定义样式
  173. */
  174. itemStyle?: ViewStyle,
  175. /**
  176. * 标签正常状态的文字颜色
  177. * @default text
  178. */
  179. textColor?: string,
  180. /**
  181. * 标签禁用状态的文字颜色
  182. * @default grey
  183. */
  184. disableTextColor?: string,
  185. /**
  186. * 标签正常状态的文字样式
  187. */
  188. textStyle?: TextStyle,
  189. /**
  190. * 标签激活状态的文字颜色
  191. * @default primary
  192. */
  193. activeTextColor?: string,
  194. /**
  195. * 标签激活状态的文字样式
  196. */
  197. activeTextStyle?: TextStyle,
  198. /**
  199. * 标签按下颜色
  200. * @default pressed.white
  201. */
  202. underlayColor?: string,
  203. /**
  204. * 指示器自定义样式
  205. */
  206. indicatorStyle?: ViewStyle,
  207. /**
  208. * 显示指示器
  209. * @default true
  210. */
  211. showIndicator?: boolean,
  212. innerStyle?: ViewStyle,
  213. }
  214. const emit = defineEmits([ 'update:currentIndex', 'click' ])
  215. const props = withDefaults(defineProps<TabsProps>(), {
  216. height: () => propGetThemeVar('TabsDefaultHeight', 90),
  217. width: () => propGetThemeVar('TabsDefaultWidth', 750),
  218. autoItemWidth: true,
  219. indicatorAnim: true,
  220. showIndicator: true,
  221. autoScroll: true,
  222. noWrap: true,
  223. defaultItemWidth: () => propGetThemeVar('TabsDefaultItemWidth', 120),
  224. defaultIndicatorWidth: () => propGetThemeVar('TabsDefaultIndicatorWidth', 100),
  225. textColor: () => propGetThemeVar('TabsTextColor', 'text.title'),
  226. disableTextColor: () => propGetThemeVar('TabsDisableTextColor', 'grey'),
  227. activeTextColor: () => propGetThemeVar('TabsActiveTextColor', 'primary'),
  228. underlayColor: () => propGetThemeVar('TabsUnderlayColor', 'pressed.white'),
  229. });
  230. const theme = useTheme();
  231. const tabPaddingHorizontal = computed(() => theme.getVar('TabsItemPaddingHorizontal', 10));
  232. const themedUnderlayColor = computed(() => theme.resolveThemeColor(props.underlayColor));
  233. const themedActiveTextColor = computed(() => theme.resolveThemeColor(props.activeTextColor));
  234. const themedTextColor = computed(() => theme.resolveThemeColor(props.textColor));
  235. const themedDisableTextColor = computed(() => theme.resolveThemeColor(props.disableTextColor));
  236. const mersuredTabs = ref<(number|undefined)[]>([]);
  237. const tabsRefs = ref<any[]>([]);
  238. const filteredTabs = computed(() => props.tabs.filter(tab => tab.visible !== false));
  239. const itemWidthArr = computed(() => {
  240. const result : {
  241. width: number,
  242. indicatorWidth: number,
  243. }[] = [];
  244. for (let i = 0; i <= filteredTabs.value.length; i++) {
  245. if (i === filteredTabs.value.length) {
  246. result.push({
  247. width: 0,
  248. indicatorWidth: 0,
  249. });
  250. nextTick(() => measureTab(i))
  251. } else {
  252. const itemWidth = filteredTabs.value[i].width ||
  253. (
  254. props.autoItemWidth ?
  255. (props.width / filteredTabs.value.length) :
  256. props.defaultItemWidth
  257. );
  258. const itemIndicatorWidth = filteredTabs.value[i].indicatorWidth || props.defaultIndicatorWidth || itemWidth / 4;
  259. result.push({
  260. width: itemWidth,
  261. indicatorWidth: itemIndicatorWidth,
  262. });
  263. if (itemWidth <= 0)
  264. nextTick(() => measureTab(i))
  265. }
  266. }
  267. return result;
  268. });
  269. const currentCanMeasure = ref(true);
  270. const currentScrollPos = ref(0);
  271. const currentIndicatorPos = ref(0);
  272. const currentIndicatorWidth = ref(0);
  273. function measureTab(index: number) {
  274. if (!currentCanMeasure.value)
  275. return;
  276. tabsRefs.value[index]?.measure().then((res: any) => {
  277. if (res[0])
  278. mersuredTabs.value[index] = res[0].width;
  279. else
  280. mersuredTabs.value[index] = uni.upx2px(props.defaultItemWidth);
  281. })
  282. }
  283. function loadPos() {
  284. const currentIndex = props.currentIndex;
  285. const current = itemWidthArr.value[currentIndex];
  286. if (!current)
  287. return;
  288. const itemWidth = current.width >= 0 ? uni.upx2px(current.width) : mersuredTabs.value[currentIndex] || 0;
  289. const targetWidth = uni.upx2px(current.indicatorWidth);
  290. let scrollLeft = 0;
  291. let targetLeft = 0;
  292. for (let i = currentIndex - 1; i >= 0; i--) {
  293. const width = itemWidthArr.value[i].width > 0 ? uni.upx2px(itemWidthArr.value[i].width) : mersuredTabs.value[i] || 0;
  294. targetLeft += width;
  295. scrollLeft += width;
  296. }
  297. if (targetWidth < itemWidth)
  298. targetLeft += itemWidth / 2 - targetWidth / 2;
  299. currentIndicatorPos.value = targetLeft;
  300. currentIndicatorWidth.value = targetWidth;
  301. currentScrollPos.value = scrollLeft + (currentIndex == itemWidthArr.value.length - 1 ? itemWidth : 0);
  302. }
  303. watch(mersuredTabs, loadPos, { deep: true });
  304. watch(() => props.currentIndex, loadPos);
  305. onMounted(() => {
  306. nextTick(() => {
  307. currentCanMeasure.value = true;
  308. measureTab(0);
  309. setTimeout(() => {
  310. loadPos();
  311. }, 200);
  312. });
  313. })
  314. function onTabClick(index: number) {
  315. emit('click', props.tabs[index]);
  316. if (index !== props.currentIndex) {
  317. emit('update:currentIndex', index);
  318. }
  319. }
  320. </script>
  321. <style lang="scss">
  322. .nana-tabs {
  323. display: flex;
  324. flex-direction: row;
  325. position: relative;
  326. flex-shrink: 0;
  327. flex-grow: 0;
  328. height: auto;
  329. .tab-item {
  330. position: relative;
  331. padding-top: 20rpx;
  332. padding-bottom: 30rpx;
  333. box-sizing: content-box;
  334. }
  335. .tab-item-text {
  336. font-size: 15px;
  337. }
  338. .tab-indicator {
  339. position: absolute;
  340. border-radius: 3rpx;
  341. left: 0;
  342. bottom: 4rpx;
  343. z-index: 10;
  344. height: 6rpx;
  345. &.anim {
  346. transition: all 0.25s ease-in-out;
  347. }
  348. }
  349. }
  350. </style>