Tabs.vue 9.4 KB

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