cl-tabs.uvue 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. <template>
  2. <view
  3. class="cl-tabs"
  4. :class="[
  5. {
  6. 'cl-tabs--line': showLine,
  7. 'cl-tabs--slider': showSlider,
  8. 'cl-tabs--fill': fill,
  9. 'cl-tabs--disabled': disabled,
  10. 'is-dark': isDark
  11. },
  12. pt.className
  13. ]"
  14. :style="{
  15. height: parseRpx(height!)
  16. }"
  17. >
  18. <scroll-view
  19. class="cl-tabs__scrollbar"
  20. :scroll-with-animation="true"
  21. :scroll-x="true"
  22. direction="horizontal"
  23. :scroll-left="scrollLeft"
  24. :show-scrollbar="false"
  25. >
  26. <view class="cl-tabs__inner">
  27. <view
  28. class="cl-tabs__item"
  29. v-for="(item, index) in list"
  30. :key="index"
  31. :class="[pt.item?.className]"
  32. :style="{
  33. padding: `0 ${parseRpx(gutter)}`
  34. }"
  35. @tap="change(index)"
  36. >
  37. <slot name="item" :item="item" :active="item.isActive">
  38. <cl-text
  39. :pt="{
  40. className: parseClass([
  41. [
  42. item.isActive && color == '' && unColor == '',
  43. showSlider ? 'text-white' : 'text-primary-500'
  44. ],
  45. [item.disabled, 'opacity-30'],
  46. pt.text?.className
  47. ])
  48. }"
  49. :style="getTextStyle(item)"
  50. >{{ item.label }}</cl-text
  51. >
  52. </slot>
  53. </view>
  54. <template v-if="lineLeft > 0">
  55. <view
  56. class="cl-tabs__line"
  57. :class="[pt.line?.className]"
  58. :style="lineStyle"
  59. v-if="showLine"
  60. ></view>
  61. <view
  62. class="cl-tabs__slider"
  63. :class="[pt.slider?.className]"
  64. :style="sliderStyle"
  65. v-if="showSlider"
  66. ></view>
  67. </template>
  68. </view>
  69. </scroll-view>
  70. </view>
  71. </template>
  72. <script lang="ts" setup>
  73. import { type PropType, computed, getCurrentInstance, nextTick, onMounted, ref, watch } from "vue";
  74. import { isDark, isEmpty, isNull, parseClass, parsePt, parseRpx } from "@/cool";
  75. import type { ClTabsItem, PassThroughProps } from "../../types";
  76. // 定义标签类型
  77. type Item = {
  78. label: string;
  79. value: string | number;
  80. disabled: boolean;
  81. isActive: boolean;
  82. };
  83. defineOptions({
  84. name: "cl-tabs"
  85. });
  86. defineSlots<{
  87. item(props: { item: Item; active: boolean }): any;
  88. }>();
  89. const props = defineProps({
  90. // 透传属性对象,允许外部自定义样式和属性
  91. pt: {
  92. type: Object,
  93. default: () => ({})
  94. },
  95. // v-model绑定值,表示当前选中的tab
  96. modelValue: {
  97. type: [String, Number] as PropType<string | number>,
  98. default: ""
  99. },
  100. // 标签高度
  101. height: {
  102. type: [String, Number] as PropType<string | number>,
  103. default: 80
  104. },
  105. // 标签列表
  106. list: {
  107. type: Array as PropType<ClTabsItem[]>,
  108. default: () => []
  109. },
  110. // 是否填充标签
  111. fill: {
  112. type: Boolean,
  113. default: false
  114. },
  115. // 标签间隔
  116. gutter: {
  117. type: Number,
  118. default: 30
  119. },
  120. // 选中标签的颜色
  121. color: {
  122. type: String,
  123. default: ""
  124. },
  125. // 未选中标签的颜色
  126. unColor: {
  127. type: String,
  128. default: ""
  129. },
  130. // 是否显示下划线
  131. showLine: {
  132. type: Boolean,
  133. default: true
  134. },
  135. // 是否显示滑块
  136. showSlider: {
  137. type: Boolean,
  138. default: false
  139. },
  140. // 是否禁用
  141. disabled: {
  142. type: Boolean,
  143. default: false
  144. }
  145. });
  146. // 定义事件发射器
  147. const emit = defineEmits(["update:modelValue", "change"]);
  148. // 获取当前组件实例的proxy对象
  149. const { proxy } = getCurrentInstance()!;
  150. // 定义透传类型,便于类型推断和扩展
  151. type PassThrough = {
  152. // 额外类名
  153. className?: string;
  154. // 文本的透传属性
  155. text?: PassThroughProps;
  156. // 单个item的透传属性
  157. item?: PassThroughProps;
  158. // 下划线的透传属性
  159. line?: PassThroughProps;
  160. // 滑块的透传属性
  161. slider?: PassThroughProps;
  162. };
  163. // 计算透传属性,便于样式和属性扩展
  164. const pt = computed(() => parsePt<PassThrough>(props.pt));
  165. // 当前选中的标签值
  166. const active = ref(props.modelValue);
  167. // 计算标签列表,增加isActive和disabled属性,便于渲染和状态判断
  168. const list = computed(() =>
  169. props.list.map((e) => {
  170. return {
  171. label: e.label,
  172. value: e.value,
  173. // 如果未传disabled则默认为false
  174. disabled: e.disabled ?? false,
  175. // 判断当前标签是否为激活状态
  176. isActive: e.value == active.value
  177. } as Item;
  178. })
  179. );
  180. // 切换标签时触发,参数为索引
  181. async function change(index: number) {
  182. // 如果整个Tabs被禁用,则不响应点击
  183. if (props.disabled) {
  184. return false;
  185. }
  186. // 获取当前点击标签的值
  187. const { value, disabled } = list.value[index];
  188. // 如果标签被禁用,则不响应点击
  189. if (disabled) {
  190. return false;
  191. }
  192. // 触发v-model的更新
  193. emit("update:modelValue", value);
  194. // 触发change事件
  195. emit("change", value);
  196. }
  197. // 获取当前选中标签的下标,未找到则返回0
  198. function getIndex() {
  199. const index = list.value.findIndex((e) => e.isActive);
  200. return index == -1 ? 0 : index;
  201. }
  202. // 根据激活状态获取标签颜色
  203. function getColor(isActive: boolean) {
  204. let color: string;
  205. // 选中时取props.color,否则取props.unColor
  206. if (isActive) {
  207. color = props.color;
  208. } else {
  209. color = props.unColor;
  210. }
  211. return isEmpty(color) ? null : color;
  212. }
  213. // tab区域宽度
  214. const tabWidth = ref(0);
  215. // tab区域左侧偏移
  216. const tabLeft = ref(0);
  217. // 下划线左侧偏移
  218. const lineLeft = ref(0);
  219. // 滑块左侧偏移
  220. const sliderLeft = ref(0);
  221. // 滑块宽度
  222. const sliderWidth = ref(0);
  223. // 滚动条左侧偏移
  224. const scrollLeft = ref(0);
  225. // 单个标签的位置信息类型,包含left和width
  226. type ItemRect = {
  227. left: number;
  228. width: number;
  229. };
  230. // 所有标签的位置信息,响应式数组
  231. const itemRects = ref<ItemRect[]>([]);
  232. // 计算下划线样式
  233. const lineStyle = computed(() => {
  234. const style = {};
  235. style["transform"] = `translateX(${lineLeft.value}px)`;
  236. // 获取选中颜色
  237. const bgColor = getColor(true);
  238. if (bgColor != null) {
  239. style["backgroundColor"] = bgColor;
  240. }
  241. return style;
  242. });
  243. // 计算滑块样式
  244. const sliderStyle = computed(() => {
  245. const style = {};
  246. style["transform"] = `translateX(${sliderLeft.value}px)`;
  247. style["width"] = sliderWidth.value + "px";
  248. // 获取选中颜色
  249. const bgColor = getColor(true);
  250. if (bgColor != null) {
  251. style["backgroundColor"] = bgColor;
  252. }
  253. return style;
  254. });
  255. // 获取文本样式
  256. function getTextStyle(item: Item) {
  257. const style = {};
  258. // 获取选中颜色
  259. const color = getColor(item.isActive);
  260. if (color != null) {
  261. style["color"] = color;
  262. }
  263. return style;
  264. }
  265. // 更新下划线、滑块、滚动条等位置
  266. function updatePosition() {
  267. nextTick(() => {
  268. if (!isEmpty(itemRects.value)) {
  269. // 获取当前选中标签的位置信息
  270. const item = itemRects.value[getIndex()];
  271. // 如果标签存在
  272. if (!isNull(item)) {
  273. // 计算滚动条偏移,使选中项居中
  274. let x = item.left - (tabWidth.value - item.width) / 2 - tabLeft.value;
  275. // 防止滚动条偏移为负
  276. if (x < 0) {
  277. x = 0;
  278. }
  279. // 设置滚动条偏移
  280. scrollLeft.value = x;
  281. // 设置下划线偏移,使下划线居中于选中项
  282. lineLeft.value = item.left + item.width / 2 - 16 / 2 - tabLeft.value;
  283. // 设置滑块左侧偏移
  284. sliderLeft.value = item.left - tabLeft.value;
  285. // 设置滑块宽度
  286. sliderWidth.value = item.width;
  287. }
  288. }
  289. });
  290. }
  291. // 获取所有标签的位置信息,便于后续计算
  292. function getRects() {
  293. // 创建选择器查询
  294. uni.createSelectorQuery()
  295. // 作用域限定为当前组件
  296. .in(proxy)
  297. // 选择所有标签元素
  298. .selectAll(".cl-tabs__item")
  299. // 获取rect和size信息
  300. .fields({ rect: true, size: true }, () => {})
  301. // 执行查询
  302. .exec((nodes) => {
  303. // 解析查询结果,生成ItemRect数组
  304. itemRects.value = (nodes[0] as NodeInfo[]).map((e) => {
  305. return {
  306. left: e.left ?? 0,
  307. width: e.width ?? 0
  308. } as ItemRect;
  309. });
  310. // 更新下划线、滑块等位置
  311. updatePosition();
  312. });
  313. }
  314. // 刷新tab区域的宽度和位置信息
  315. function refresh() {
  316. nextTick(() => {
  317. // 创建选择器查询
  318. uni.createSelectorQuery()
  319. // 作用域限定为当前组件
  320. .in(proxy)
  321. // 选择tab容器
  322. .select(".cl-tabs")
  323. // 获取容器的left和width
  324. .boundingClientRect((node) => {
  325. // 设置tab左侧偏移
  326. tabLeft.value = (node as NodeInfo).left ?? 0;
  327. // 设置tab宽度
  328. tabWidth.value = (node as NodeInfo).width ?? 0;
  329. // 获取所有标签的位置信息
  330. getRects();
  331. })
  332. .exec();
  333. });
  334. }
  335. onMounted(() => {
  336. // 监听modelValue变化,更新active和位置
  337. watch(
  338. computed(() => props.modelValue!),
  339. (val: string | number) => {
  340. // 更新当前选中标签
  341. active.value = val;
  342. // 更新下划线、滑块等位置
  343. updatePosition();
  344. },
  345. {
  346. // 立即执行一次
  347. immediate: true
  348. }
  349. );
  350. // 监听标签列表变化,刷新布局
  351. watch(
  352. computed(() => props.list),
  353. () => {
  354. refresh();
  355. },
  356. {
  357. immediate: true
  358. }
  359. );
  360. });
  361. </script>
  362. <style lang="scss" scoped>
  363. .cl-tabs {
  364. &__scrollbar {
  365. @apply flex flex-row w-full h-full;
  366. }
  367. &__inner {
  368. @apply flex flex-row relative;
  369. }
  370. &__item {
  371. @apply flex flex-row items-center justify-center h-full relative z-10;
  372. }
  373. &__line {
  374. @apply bg-primary-500 rounded-md absolute;
  375. height: 4rpx;
  376. width: 16px;
  377. bottom: 2rpx;
  378. left: 0;
  379. transition-property: transform;
  380. transition-duration: 0.3s;
  381. }
  382. &__slider {
  383. @apply bg-primary-500 rounded-lg absolute h-full w-full;
  384. top: 0;
  385. left: 0;
  386. transition-property: transform;
  387. transition-duration: 0.3s;
  388. }
  389. &--slider {
  390. @apply bg-surface-50 rounded-lg;
  391. &.is-dark {
  392. @apply bg-surface-700;
  393. }
  394. }
  395. &--fill {
  396. .cl-tabs__inner {
  397. @apply w-full;
  398. }
  399. .cl-tabs__item {
  400. flex: 1;
  401. }
  402. .cl-tabs__item-label {
  403. @apply text-center;
  404. }
  405. }
  406. &--disabled {
  407. @apply opacity-50;
  408. }
  409. }
  410. </style>