Tabs.vue 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  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. onlyJump?: boolean,
  101. /**
  102. * 是否禁用选择。
  103. * @default false
  104. */
  105. disabled?: boolean,
  106. /**
  107. * 红点标记的自定义属性。同 Badge 组件。
  108. */
  109. badgeProps?: BadgeProps,
  110. /**
  111. * 标签宽度。此设置优先级最高, 会覆盖 Tabs 组件的设置。
  112. * 如果设置为-1,则根据文字宽度自动调整。
  113. * 如果设置为 undefined ,则使用 defaultItemWidth 。
  114. * @default undefined
  115. */
  116. width?: number,
  117. /**
  118. * 指示器宽度,默认值从 Tabs 组件继承。
  119. */
  120. indicatorWidth?: number,
  121. /**
  122. * 懒加载?,仅在TabsPage组件中有效
  123. */
  124. lazy?: boolean,
  125. }
  126. export interface TabsProps {
  127. /**
  128. * 标签数据
  129. */
  130. tabs: TabsItemData[];
  131. /**
  132. * 当前选中的标签位置
  133. */
  134. currentIndex: number;
  135. /**
  136. * 整个组件的高度
  137. * @default 90rpx
  138. */
  139. height?: number,
  140. /**
  141. * 整个组件的宽度
  142. * @default deviceWidth
  143. */
  144. width?: number,
  145. /**
  146. * 是否自动根据容器宽度调整标签宽度
  147. * @default true
  148. */
  149. autoItemWidth?: boolean,
  150. /**
  151. * 指示器切换标签时是否有动画效果
  152. * @default true
  153. */
  154. indicatorAnim?: boolean,
  155. /**
  156. * 是否在用户切换标签后自动滚动至当前选中的条目
  157. * @default true
  158. */
  159. autoScroll?: boolean,
  160. /**
  161. * 是否禁止标签文字换行
  162. * @default true
  163. */
  164. noWrap?: boolean,
  165. /**
  166. * 标签宽度,在 autoItemWidth 为 false 时有效。
  167. * 如果设置为-1,则根据文字宽度自动调整。
  168. * @default 100 (rpx)
  169. */
  170. defaultItemWidth?: number,
  171. /**
  172. * 默认的指示器宽度。
  173. * @default itemWidth / 4
  174. */
  175. defaultIndicatorWidth?: number,
  176. /**
  177. * 标签自定义样式
  178. */
  179. itemStyle?: ViewStyle,
  180. /**
  181. * 标签正常状态的文字颜色
  182. * @default text
  183. */
  184. textColor?: string,
  185. /**
  186. * 标签禁用状态的文字颜色
  187. * @default grey
  188. */
  189. disableTextColor?: string,
  190. /**
  191. * 标签正常状态的文字样式
  192. */
  193. textStyle?: TextStyle,
  194. /**
  195. * 标签激活状态的文字颜色
  196. * @default primary
  197. */
  198. activeTextColor?: string,
  199. /**
  200. * 标签激活状态的文字样式
  201. */
  202. activeTextStyle?: TextStyle,
  203. /**
  204. * 标签按下颜色
  205. * @default pressed.white
  206. */
  207. underlayColor?: string,
  208. /**
  209. * 指示器自定义样式
  210. */
  211. indicatorStyle?: ViewStyle,
  212. /**
  213. * 显示指示器
  214. * @default true
  215. */
  216. showIndicator?: boolean,
  217. innerStyle?: ViewStyle,
  218. }
  219. const emit = defineEmits([ 'update:currentIndex', 'click' ])
  220. const props = withDefaults(defineProps<TabsProps>(), {
  221. height: () => propGetThemeVar('TabsDefaultHeight', 90),
  222. width: () => propGetThemeVar('TabsDefaultWidth', 750),
  223. autoItemWidth: true,
  224. indicatorAnim: true,
  225. showIndicator: true,
  226. autoScroll: true,
  227. noWrap: true,
  228. defaultItemWidth: () => propGetThemeVar('TabsDefaultItemWidth', 120),
  229. defaultIndicatorWidth: () => propGetThemeVar('TabsDefaultIndicatorWidth', 100),
  230. textColor: () => propGetThemeVar('TabsTextColor', 'text.title'),
  231. disableTextColor: () => propGetThemeVar('TabsDisableTextColor', 'grey'),
  232. activeTextColor: () => propGetThemeVar('TabsActiveTextColor', 'primary'),
  233. underlayColor: () => propGetThemeVar('TabsUnderlayColor', 'pressed.white'),
  234. });
  235. const theme = useTheme();
  236. const tabPaddingHorizontal = computed(() => theme.getVar('TabsItemPaddingHorizontal', 10));
  237. const themedUnderlayColor = computed(() => theme.resolveThemeColor(props.underlayColor));
  238. const themedActiveTextColor = computed(() => theme.resolveThemeColor(props.activeTextColor));
  239. const themedTextColor = computed(() => theme.resolveThemeColor(props.textColor));
  240. const themedDisableTextColor = computed(() => theme.resolveThemeColor(props.disableTextColor));
  241. const mersuredTabs = ref<(number|undefined)[]>([]);
  242. const tabsRefs = ref<any[]>([]);
  243. const filteredTabs = computed(() => props.tabs.filter(tab => tab.visible !== false));
  244. const itemWidthArr = computed(() => {
  245. const result : {
  246. width: number,
  247. indicatorWidth: number,
  248. }[] = [];
  249. for (let i = 0; i <= filteredTabs.value.length; i++) {
  250. if (i === filteredTabs.value.length) {
  251. result.push({
  252. width: 0,
  253. indicatorWidth: 0,
  254. });
  255. nextTick(() => measureTab(i))
  256. } else {
  257. const itemWidth = filteredTabs.value[i].width ||
  258. (
  259. props.autoItemWidth ?
  260. (props.width / filteredTabs.value.length) :
  261. props.defaultItemWidth
  262. );
  263. const itemIndicatorWidth = filteredTabs.value[i].indicatorWidth || props.defaultIndicatorWidth || itemWidth / 4;
  264. result.push({
  265. width: itemWidth,
  266. indicatorWidth: itemIndicatorWidth,
  267. });
  268. if (itemWidth <= 0)
  269. nextTick(() => measureTab(i))
  270. }
  271. }
  272. return result;
  273. });
  274. const currentCanMeasure = ref(true);
  275. const currentScrollPos = ref(0);
  276. const currentIndicatorPos = ref(0);
  277. const currentIndicatorWidth = ref(0);
  278. function measureTab(index: number) {
  279. if (!currentCanMeasure.value)
  280. return;
  281. tabsRefs.value[index]?.measure().then((res: any) => {
  282. if (res[0])
  283. mersuredTabs.value[index] = res[0].width;
  284. else
  285. mersuredTabs.value[index] = uni.upx2px(props.defaultItemWidth);
  286. })
  287. }
  288. function loadPos() {
  289. const currentIndex = props.currentIndex;
  290. const current = itemWidthArr.value[currentIndex];
  291. if (!current)
  292. return;
  293. const itemWidth = current.width >= 0 ? uni.upx2px(current.width) : mersuredTabs.value[currentIndex] || 0;
  294. const targetWidth = uni.upx2px(current.indicatorWidth);
  295. let scrollLeft = 0;
  296. let targetLeft = 0;
  297. for (let i = currentIndex - 1; i >= 0; i--) {
  298. const width = itemWidthArr.value[i].width > 0 ? uni.upx2px(itemWidthArr.value[i].width) : mersuredTabs.value[i] || 0;
  299. targetLeft += width;
  300. scrollLeft += width;
  301. }
  302. if (targetWidth < itemWidth)
  303. targetLeft += itemWidth / 2 - targetWidth / 2;
  304. currentIndicatorPos.value = targetLeft;
  305. currentIndicatorWidth.value = targetWidth;
  306. currentScrollPos.value = scrollLeft + (currentIndex == itemWidthArr.value.length - 1 ? itemWidth : 0);
  307. }
  308. watch(mersuredTabs, loadPos, { deep: true });
  309. watch(() => props.currentIndex, loadPos);
  310. onMounted(() => {
  311. nextTick(() => {
  312. currentCanMeasure.value = true;
  313. measureTab(0);
  314. setTimeout(() => {
  315. loadPos();
  316. }, 200);
  317. });
  318. })
  319. function onTabClick(index: number) {
  320. emit('click', props.tabs[index]);
  321. if (props.tabs[index].onlyJump !== true && index !== props.currentIndex) {
  322. emit('update:currentIndex', index);
  323. }
  324. }
  325. </script>
  326. <style lang="scss">
  327. .nana-tabs {
  328. display: flex;
  329. flex-direction: row;
  330. position: relative;
  331. flex-shrink: 0;
  332. flex-grow: 1;
  333. height: auto;
  334. .tab-item {
  335. position: relative;
  336. padding-top: 20rpx;
  337. padding-bottom: 30rpx;
  338. box-sizing: content-box;
  339. flex-shrink: 0;
  340. }
  341. .tab-item-text {
  342. font-size: 15px;
  343. }
  344. .tab-indicator {
  345. position: absolute;
  346. border-radius: 3rpx;
  347. left: 0;
  348. bottom: 4rpx;
  349. z-index: 10;
  350. height: 6rpx;
  351. &.anim {
  352. transition: all 0.25s ease-in-out;
  353. }
  354. }
  355. }
  356. </style>