yui-tabs(1.0.5).vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. <template>
  2. <view class="yui-tabs" :class="{'yui-tabs--visible':visible,'yui-tabs--fixed':fixed}">
  3. <!-- 标签区域 -->
  4. <view class="yui-tabs__wrap" :style="[wrapStyle,innerWrapStyle]">
  5. <!-- scrollX为true,表示允许横向滚动 -->
  6. <scroll-view v-if="scrollX" class="yui-tabs__scroll" :scroll-x="scrollX" :scroll-anchoring="true"
  7. enable-flex :scroll-into-view="scrollId" scroll-with-animation :style="[scrollStyle]">
  8. <view class="yui-tabs__nav">
  9. <view class="yui-tab" v-for="(tab,index) in tabList" :key="index" @click="handleClick(index)"
  10. :id="`tab_${index}`" :class="[tabClass(index, tab)]" :style="[tabStyle(tab)]">
  11. <view class="yui-tab__text" :class="{'yui-tab__text--ellipsis':ellipsis}">
  12. {{tab.label}}
  13. </view>
  14. </view>
  15. <view class="yui-tabs__line" :style="[lineStyle,lineAnimatedStyle]"></view>
  16. </view>
  17. </scroll-view>
  18. <view v-else class="yui-tabs__nav">
  19. <view class="yui-tab" v-for="(tab,index) in tabList" :key="index" @click="handleClick(index)"
  20. :id="`tab_${index}`" :class="[tabClass(index, tab)]" :style="[tabStyle(tab)]">
  21. <view class="yui-tab__text" :class="{'yui-tab__text--ellipsis':ellipsis}">
  22. {{tab.label}}
  23. </view>
  24. </view>
  25. <view class="yui-tabs__line" :style="[lineStyle,lineAnimatedStyle]"></view>
  26. </view>
  27. <view class="yui-tabs__extra">
  28. <slot name="extra"></slot>
  29. </view>
  30. </view>
  31. <!-- 标签内容 -->
  32. <view class="yui-tabs__content" :class="{'yui-tabs__content--animated':animated}">
  33. <view class="yui-tabs__track" :style="[trackStyle]">
  34. <view class="yui-tab__pane" v-for="(tab,index) in tabList" :key="index" :style="[paneStyle(tab)]"
  35. @touchstart="touchStart" @touchend="touchEnd($event,index)">
  36. <view v-if="tab.rendered ? true :value == index">
  37. <slot :name="tab.slot"></slot>
  38. </view>
  39. </view>
  40. </view>
  41. </view>
  42. </view>
  43. </template>
  44. <script>
  45. import {
  46. isNull,
  47. addUnit,
  48. isDef,
  49. isObject,
  50. getDirection
  51. } from "@/common/uitls.js"
  52. export default {
  53. name: "yui-tabs",
  54. emits: ['input', 'change', 'click'],
  55. // uni-app自定义v-model需要按照如下的规范,直接用value和input,否则在微信小程序上会失效
  56. model: {
  57. prop: 'value',
  58. event: 'input'
  59. },
  60. props: {
  61. color: String, //标签主题色, 默认值为"#0022AB"
  62. background: String, //标签栏背景色,默认值为"#fff"
  63. lineWidth: [Number, String], //底部条宽度,默认单位为px, 默认值为20px
  64. lineHeight: [Number, String], //底部条高度,默认单位为px,默认值为3px
  65. titleActiveColor: String, //标题选中态颜色
  66. titleInactiveColor: String, //标题默认态颜色
  67. // 标签页数据,支持字符串类型与对象类型的数组结构
  68. // 对象类型需符合{label:'标签1',slot:'slotName'}这样的格式,slot为自定义的标签内容插槽名,否则插槽名默认为"pane"+tab下标的命名
  69. tabs: {
  70. type: Array,
  71. default: () => []
  72. },
  73. // 是否开启延迟渲染(首次切换到标签时才触发内容渲染)
  74. isLazyRender: {
  75. type: Boolean,
  76. default: true,
  77. },
  78. // 是否开启切换标签内容时的转场动画
  79. animated: {
  80. type: Boolean,
  81. default: false
  82. },
  83. // 保证组件的可见性,主要用于处理选中标签的底部线条位置
  84. visible: {
  85. type: Boolean,
  86. default: true
  87. },
  88. // v-model绑定属性,绑定当前选中标签的标识符(标签的下标)
  89. value: {
  90. type: Number,
  91. default: -1
  92. },
  93. // 标签页是否滚动吸顶
  94. fixed: Boolean,
  95. // 滚动吸顶下与顶部的最小距离,默认 px
  96. offsetTop: {
  97. type: Number,
  98. default: 0
  99. },
  100. // 滚动吸顶下,标签栏的z-index值
  101. zIndex: {
  102. type: Number,
  103. default: 99
  104. },
  105. // 标签栏样式
  106. wrapStyle: {
  107. type: [Object, null],
  108. default: () => {}
  109. },
  110. // 动画时间,单位秒
  111. duration: {
  112. type: [Number, String],
  113. default: 0.3,
  114. },
  115. // 导航标签滚动阈值,标签数量超过阈值且总宽度超过标签栏宽度时开始横向滚动
  116. swipeThreshold: {
  117. type: [Number, String],
  118. default: 5
  119. },
  120. // 是否省略过长的标题文字(注意:标签数量未超过导航标签滚动阈值时才生效)
  121. ellipsis: {
  122. type: Boolean,
  123. default: true,
  124. },
  125. // 是否开启手势滑动切换
  126. swipeable: {
  127. type: Boolean,
  128. default: false,
  129. },
  130. // 滚动阈值,手指滑动页面触发切换的阈值,单位为px,表示横向滑动整个可视区域的多少px时才切换标签内容
  131. scrollThreshold: {
  132. type: [Number, String],
  133. default: 50,
  134. },
  135. },
  136. data() {
  137. return {
  138. tabList: [],
  139. scrollId: 'tab_0',
  140. extraWidth: 0, //标签栏右侧额外区域宽度
  141. trackStyle: null, //标签内容滑动轨道样式
  142. touchInfo: {
  143. inited: false, //标记左右滑动时的初始化状态
  144. startX: null, //记录touch位置的横坐标
  145. startY: null //记录touch位置的纵坐标
  146. },
  147. // 标签栏底部线条动画相关
  148. translateX: null,
  149. lineAnimated: false, //是否开启标签栏动画
  150. lineAnimatedStyle: {
  151. transform: `translateX(-100%) translateX(-50%)`,
  152. transitionDuration: `0s`
  153. }, //标签栏底部线条动画样式
  154. }
  155. },
  156. computed: {
  157. // 导航区域包裹层样式
  158. innerWrapStyle() {
  159. const style = {
  160. backgroundColor: this.background,
  161. }
  162. // 滚动吸顶下
  163. if (this.fixed) {
  164. style.top = this.offsetTop + "px"
  165. style.zIndex = this.zIndex
  166. }
  167. return style
  168. },
  169. // 滚动区域样式
  170. scrollStyle() {
  171. return {
  172. width: `calc(100% - ${this.extraWidth}px)`
  173. }
  174. },
  175. // 标签栏底部线条样式
  176. lineStyle() {
  177. const {
  178. lineWidth,
  179. lineHeight,
  180. duration
  181. } = this;
  182. const lineStyle = {
  183. width: addUnit(lineWidth),
  184. backgroundColor: this.color,
  185. }
  186. if (isDef(lineHeight)) {
  187. const height = addUnit(lineHeight);
  188. lineStyle.height = height;
  189. lineStyle.borderRadius = height;
  190. }
  191. return lineStyle
  192. },
  193. // 是否允许横向滚动
  194. scrollX() {
  195. return this.tabs.length > this.swipeThreshold
  196. },
  197. },
  198. watch: {
  199. // 监听选中标识符变化
  200. value: {
  201. handler(val, oldVal) {
  202. this.tabChange(val, oldVal) //标签切换
  203. this.changeStyle() // 样式切换
  204. }
  205. },
  206. // 监听tabs变化,重新初始化tabList
  207. tabs: {
  208. handler(val) {
  209. this.initTabList() //初始化tabList
  210. this.changeStyle() // 样式切换
  211. },
  212. deep: true
  213. },
  214. // 可见时也需要计算translateX
  215. visible: {
  216. handler() {
  217. this.lineAnimated = false //是否开启标签栏动画
  218. // this.init() //初始化操作
  219. }
  220. },
  221. // 监听translateX,设置标签栏底部线条动画
  222. translateX: {
  223. handler(val) {
  224. const transform = `translateX(${isDef(val) ? val + "px" : '-100%'}) translateX(-50%)`
  225. const duration = `${this.lineAnimated?this.duration:'0'}s`
  226. this.$set(this.lineAnimatedStyle, 'transform', transform)
  227. this.$set(this.lineAnimatedStyle, 'transitionDuration', duration)
  228. }
  229. },
  230. },
  231. created() {
  232. this.initTabList() // 初始化tabList
  233. },
  234. mounted() {
  235. this.init() //初始化操作
  236. },
  237. methods: {
  238. // 获取元素位置信息
  239. getRect(select) {
  240. return new Promise((res, rej) => {
  241. if (!select) rej('Parameter is empty');
  242. let query
  243. // #ifdef MP-ALIPAY
  244. query = uni.createSelectorQuery()
  245. // #endif
  246. // #ifndef MP-ALIPAY
  247. query = uni.createSelectorQuery().in(this)
  248. // #endif
  249. query.select(select).boundingClientRect(rect => res(rect)).exec();
  250. })
  251. },
  252. // 标签项class
  253. tabClass(index, tab) {
  254. return `yui-tab_${index} ${tab.active?'yui-tab--active':''} ${tab.disabled?'yui-tab--disabled':''}`
  255. },
  256. // 标签项style
  257. tabStyle(tab) {
  258. return {
  259. color: tab.active ? this.titleActiveColor : this.titleInactiveColor
  260. }
  261. },
  262. // 标签内容style
  263. paneStyle(tab) {
  264. if (this.animated) {
  265. return {
  266. visibility: tab.show ? 'visible' : 'hidden',
  267. height: tab.show ? 'auto' : '0px'
  268. }
  269. }
  270. return {
  271. display: tab.show ? 'block' : 'none'
  272. }
  273. },
  274. // 初始化操作
  275. async init() {
  276. //获取额外区域的宽度
  277. let rect = await this.getRect('.yui-tabs__extra')
  278. this.extraWidth = rect ? rect.width : 0
  279. //获取标签容器距离视口左侧的left值
  280. rect = await this.getRect('.yui-tabs')
  281. const parentLeft = rect ? rect.left : 0
  282. // 保存每个tab的translateX
  283. this.tabList.forEach(async (tab, index) => {
  284. const rect = await this.getRect('.yui-tab_' + index);
  285. tab.translateX = rect.left + rect.width / 2 - parentLeft
  286. if (index === this.value) this.changeStyle() // 样式切换
  287. })
  288. },
  289. // 初始化tabList
  290. initTabList() {
  291. const tabs = this.tabs.filter(o => !isNull(o))
  292. this.tabList = tabs.map((item, index) => {
  293. const isCurr = this.value == index
  294. let obj = {
  295. label: '', //标签名称
  296. slot: 'pane' + index, //标签内容的插槽名称,默认以"pane"+标签下标命名
  297. disabled: false, //是否禁用标签
  298. active: isCurr, //是否选中
  299. rendered: isCurr || !this.isLazyRender, //标记是否渲染过
  300. show: isCurr // this.animated ? true : isCurr //是否显示内容(标签内容转场动画不使用v-show控制显隐,直接显示)
  301. }
  302. if (isObject(item)) {
  303. obj.label = item.label
  304. obj.slot = isNull(item.slot) ? obj.slot : item.slot
  305. } else {
  306. obj.label = item
  307. }
  308. return obj
  309. })
  310. },
  311. // 标签点击事件
  312. handleClick(index) {
  313. if (this.tabList[index].disabled) return //禁用时不允许切换
  314. this.$emit('click', index, this.tabs[index]) // 标签点击事件
  315. if (this.value == index) return //不允许重复切换同一标签
  316. const oldValue = this.value //获取旧的index
  317. //更新v-model绑定的值
  318. this.$emit('input', index) //更新v-model绑定的值
  319. },
  320. // 标签切换
  321. tabChange(value, oldValue) {
  322. const oldTab = this.tabList[oldValue] //上一个tab
  323. const currTab = this.tabList[value] //当前tab
  324. // 设置选中态
  325. oldTab.active = false
  326. currTab.active = true
  327. currTab.rendered = true //标记渲染过
  328. oldTab.show = false //隐藏旧内容区域
  329. currTab.show = true //隐藏当前tab对应的内容区域
  330. // 触发change事件
  331. this.$emit('change', value, this.tabs[value])
  332. },
  333. // 样式切换
  334. changeStyle() {
  335. this.scrollId = `tab_${this.value-1}`; //设置scroll-into-view
  336. this.setTranslateX() //设置translateX
  337. this.changeTrackStyle(false, this.duration) //改变标签内容滑动轨道样式
  338. },
  339. // 设置translateX,用于改变标签栏底部线条位置
  340. setTranslateX() {
  341. if (this.tabList[this.value].disabled) return
  342. this.translateX = this.tabList[this.value].translateX
  343. this.$nextTick(() => {
  344. this.lineAnimated = true //是否开启标签栏动画
  345. })
  346. },
  347. // 改变标签内容滑动轨道样式
  348. changeTrackStyle(isSlide = false, duration = 0, offsetWidth = 0) {
  349. if (!this.animated) return
  350. // isSlide标记是否为左右滑动时,否则为点击标签的动画转场
  351. this.trackStyle = {
  352. 'transform': isSlide ? `translate3d(${offsetWidth}px,0,0)` : `translateX(${-100 * this.value}%)`,
  353. 'transition': `transform ${duration}s ease-in-out`
  354. }
  355. },
  356. touchStart(e) {
  357. // 禁止滑动
  358. if (!this.swipeable) return
  359. this.touchInfo.inited = true //touch开始时,将touchInfo对象设置为已初始化状态
  360. const touch = e.touches[0];
  361. // 记录touch位置的横坐标与纵坐标
  362. this.touchInfo.startX = touch.pageX
  363. this.touchInfo.startY = touch.pageY
  364. },
  365. touchEnd(e, index) {
  366. if (!this.touchInfo.inited) return
  367. const {
  368. pageX,
  369. pageY
  370. } = e.changedTouches[0];
  371. const {
  372. startX,
  373. startY
  374. } = this.touchInfo || {}
  375. // 滑动方向不为左右时阻止
  376. const direction = getDirection(startX, startY, pageX, pageY)
  377. if (direction != 3 && direction != 4) return
  378. // 横坐标偏移量
  379. const deltaX = pageX - startX
  380. // 标记是左滑还是右滑
  381. const isLeftSide = deltaX >= 0
  382. const len = this.tabList.length
  383. // 如果当前为第一页内容,则不允许左滑;最后一页内容,则不允许右滑
  384. if ((isLeftSide && index == 0) || (!isLeftSide && index == len - 1)) {
  385. return
  386. }
  387. // 移动的横坐标偏移量大于指定的滚动阈值时,则切换显示状态,否则还原
  388. if (Math.abs(deltaX) > Number(this.scrollThreshold)) {
  389. // 根据是否为左滑查找需要滑动到的标签内容页下标,切换标签内容
  390. index = index + (isLeftSide ? -1 : 1)
  391. if (index > -1 && index < len) this.handleClick(index)
  392. } else {
  393. this.changeTrackStyle(false, this.duration)
  394. }
  395. // 一次touch完成后,重置touchInfo对象尚未初始化状态
  396. this.touchInfo.inited = false
  397. },
  398. }
  399. }
  400. </script>
  401. <style lang="less" scoped>
  402. .yui-tabs {
  403. position: relative;
  404. width: 100%;
  405. // 开启粘性定位布局
  406. &--fixed {
  407. .yui-tabs__wrap {
  408. position: fixed;
  409. top: 0;
  410. right: 0;
  411. left: 0;
  412. z-index: 99;
  413. }
  414. }
  415. // 不显示滚动条
  416. ::-webkit-scrollbar {
  417. display: none;
  418. width: 0 !important;
  419. height: 0 !important;
  420. -webkit-appearance: none;
  421. background: transparent;
  422. color: transparent;
  423. }
  424. // 导航区域包裹层
  425. &__wrap {
  426. position: relative;
  427. display: flex;
  428. background-color: #fff;
  429. align-items: center;
  430. overflow: hidden;
  431. visibility: hidden;
  432. height: 0;
  433. }
  434. // 导航区域可见
  435. &--visible .yui-tabs__wrap {
  436. visibility: visible;
  437. height: auto;
  438. }
  439. // scroll-view组件样式
  440. &__scroll {
  441. position: relative;
  442. white-space: nowrap; // 使用横向滚动时,需要给<scroll-view>添加white-space: nowrap;样式
  443. width: 100%;
  444. height: 80rpx;
  445. }
  446. // 导航区域
  447. &__nav {
  448. position: relative;
  449. box-sizing: content-box;
  450. user-select: none;
  451. height: 80rpx;
  452. flex: 1;
  453. display: flex;
  454. // 导航标签
  455. .yui-tab {
  456. display: inline-block;
  457. line-height: 80rpx;
  458. font-size: 28rpx;
  459. color: #333;
  460. text-align: center;
  461. padding: 0 8rpx;
  462. flex: 1;
  463. cursor: pointer;
  464. -webkit-tap-highlight-color: transparent;
  465. &--active {
  466. color: #212121;
  467. font-weight: 500;
  468. }
  469. &--disabled {
  470. color: #c8c9cc;
  471. cursor: not-allowed;
  472. }
  473. // 标题文字
  474. &__text {
  475. // 省略过长的标题文字
  476. &--ellipsis {
  477. display: -webkit-box; //定义为盒子显示
  478. overflow: hidden;
  479. text-overflow: ellipsis; //文本溢出隐藏为省略号
  480. -webkit-line-clamp: 1; // 限制一个块元素显示的文本行数
  481. -webkit-box-orient: vertical; //盒模型子元素排列: vertical(竖排)orhorizontal(横排)
  482. }
  483. }
  484. }
  485. }
  486. // 标签右侧的补充区域
  487. &__extra {
  488. position: relative;
  489. display: inline-flex;
  490. white-space: nowrap;
  491. }
  492. // 底部线条
  493. &__line {
  494. position: absolute;
  495. bottom: 3px;
  496. left: 0;
  497. width: 20px;
  498. height: 3px;
  499. background-color: #0022AB;
  500. border-radius: 3px;
  501. transform: translateX(-100%) translateX(-50%);
  502. // transition-duration: 0.3s;
  503. }
  504. // 标签内容
  505. &__content {
  506. background-color: #fff;
  507. overflow: hidden;
  508. .yui-tab__pane {
  509. flex-shrink: 0;
  510. box-sizing: border-box;
  511. width: 100%;
  512. }
  513. }
  514. // 标签内容转场动画样式
  515. &__content--animated {
  516. overflow: hidden;
  517. .yui-tab__pane {
  518. transition-duration: 0.3s;
  519. }
  520. }
  521. // 标签内容的滑动轨道容器
  522. &__track {
  523. position: relative;
  524. display: flex;
  525. width: 100%;
  526. height: 100%;
  527. will-change: left;
  528. background-color: #fff;
  529. }
  530. }
  531. </style>