Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 

555 linhas
17 KiB

  1. <template>
  2. <view class="u-tabs" :class="[customClass, shapeModeClass]">
  3. <view class="u-tabs__wrapper">
  4. <slot name="left" />
  5. <view class="u-tabs__wrapper__scroll-view-wrapper">
  6. <scroll-view :scroll-x="scrollable" :scroll-left="scrollLeft" scroll-with-animation
  7. class="u-tabs__wrapper__scroll-view" :show-scrollbar="false" ref="u-tabs__wrapper__scroll-view">
  8. <view class="u-tabs__wrapper__nav" ref="u-tabs__wrapper__nav">
  9. <view class="u-tabs__wrapper__nav__item" v-for="(item, index) in tabList" :key="index"
  10. @tap="clickHandler(item, index)" @longpress="longPressHandler(item,index)"
  11. :ref="`u-tabs__wrapper__nav__item-${index}`"
  12. :style="[itemComputedStyle, {flex: scrollable ? '' : 1}]" :class="[`u-tabs__wrapper__nav__item-${index}`,
  13. shapeMode && `u-tabs__wrapper__nav__item--${shapeMode}`,
  14. item.disabled && 'u-tabs__wrapper__nav__item--disabled',
  15. innerCurrent == index ? 'u-tabs__wrapper__nav__item-active' : '']">
  16. <slot v-if="$slots.icon" name="icon" :item="item" :keyName="keyName" :index="index" />
  17. <template v-else>
  18. <view class="u-tabs__wrapper__nav__item__prefix-icon" v-if="item.icon">
  19. <up-icon :name="item.icon" :customStyle="addStyle(iconStyle)"></up-icon>
  20. </view>
  21. </template>
  22. <slot v-if="$slots.content" name="content" :item="item" :keyName="keyName" :index="index" />
  23. <slot v-else-if="!$slots.content && ($slots.default || $slots.$default)" :item="item"
  24. :keyName="keyName" :index="index" />
  25. <text v-else :class="[item.disabled && 'u-tabs__wrapper__nav__item__text--disabled']"
  26. class="u-tabs__wrapper__nav__item__text"
  27. :style="[textStyle(index)]">{{ item[keyName] }}</text>
  28. <u-badge :show="!!(item.badge && (item.badge.show || item.badge.isDot || item.badge.value))"
  29. :isDot="item.badge && item.badge.isDot || propsBadge.isDot"
  30. :value="item.badge && item.badge.value || propsBadge.value"
  31. :max="item.badge && item.badge.max || propsBadge.max"
  32. :type="item.badge && item.badge.type || propsBadge.type"
  33. :showZero="item.badge && item.badge.showZero || propsBadge.showZero"
  34. :bgColor="item.badge && item.badge.bgColor || propsBadge.bgColor"
  35. :color="item.badge && item.badge.color || propsBadge.color"
  36. :shape="item.badge && item.badge.shape || propsBadge.shape"
  37. :numberType="item.badge && item.badge.numberType || propsBadge.numberType"
  38. :inverted="item.badge && item.badge.inverted || propsBadge.inverted"
  39. customStyle="margin-left: 4px;"></u-badge>
  40. <view
  41. v-if="shapeMode === 'card' && innerCurrent == index && index < tabList.length - 1"
  42. class="u-tabs__wrapper__nav__item__card-corner"></view>
  43. <view
  44. v-if="shapeMode === 'pill-arrow' && innerCurrent == index"
  45. class="u-tabs__wrapper__nav__item__active-arrow"></view>
  46. </view>
  47. <!-- #ifdef APP-NVUE -->
  48. <view class="u-tabs__wrapper__nav__line" ref="u-tabs__wrapper__nav__line" :style="[{
  49. width: addUnit(lineWidth),
  50. height: addUnit(lineHeight),
  51. background: lineColor,
  52. backgroundSize: lineBgSize,
  53. display: showLine ? 'block' : 'none'
  54. }]">
  55. </view>
  56. <!-- #endif -->
  57. <!-- #ifndef APP-NVUE -->
  58. <view class="u-tabs__wrapper__nav__line" ref="u-tabs__wrapper__nav__line"
  59. :style="[{
  60. width: addUnit(lineWidth),
  61. transform: `translate(${lineOffsetLeft}px)`,
  62. transitionDuration: `${duration}ms`,
  63. height: addUnit(lineHeight),
  64. background: lineColor,
  65. backgroundSize: lineBgSize,
  66. display: showLine ? 'block': 'none'
  67. }]">
  68. </view>
  69. <!-- #endif -->
  70. </view>
  71. </scroll-view>
  72. </view>
  73. <slot name="right" />
  74. </view>
  75. </view>
  76. </template>
  77. <script>
  78. // #ifdef APP-NVUE
  79. const animation = uni.requireNativePlugin('animation')
  80. const dom = uni.requireNativePlugin('dom')
  81. // #endif
  82. import {
  83. props
  84. } from './props';
  85. import {
  86. mpMixin
  87. } from '../../libs/mixin/mpMixin';
  88. import {
  89. mixin
  90. } from '../../libs/mixin/mixin';
  91. import defProps from '../../libs/config/props.js'
  92. import {
  93. addUnit,
  94. addStyle,
  95. deepMerge,
  96. deepClone,
  97. getPx,
  98. sleep,
  99. getWindowInfo
  100. } from '../../libs/function/index';
  101. /**
  102. * Tabs 标签
  103. * @description tabs标签组件,在标签多的时候,可以配置为左右滑动,标签少的时候,可以禁止滑动。 该组件的一个特点是配置为滚动模式时,激活的tab会自动移动到组件的中间位置。
  104. * @tutorial https://uview-plus.jiangruyi.com/components/tabs.html
  105. * @property {String | Number} duration 滑块移动一次所需的时间,单位秒(默认 200 )
  106. * @property {String | Number} swierWidth swiper的宽度(默认 '750rpx' )
  107. * @property {String} keyName 从`list`元素对象中读取的键名(默认 'name' )
  108. * @property {String} shapeMode 标签形态模式,可选capsule/card/pill-arrow/tag(默认 '' )
  109. * @event {Function(index)} change 标签改变时触发 index: 点击了第几个tab,索引从0开始
  110. * @event {Function(index)} click 点击标签时触发 index: 点击了第几个tab,索引从0开始
  111. * @event {Function(index)} longPress 长按标签时触发 index: 点击了第几个tab,索引从0开始
  112. * @example <u-tabs :list="list" :is-scroll="false" :current="current" @change="change" @longPress="longPress"></u-tabs>
  113. */
  114. export default {
  115. name: 'u-tabs',
  116. mixins: [mpMixin, mixin, props],
  117. data() {
  118. return {
  119. tabList: [],
  120. scrollLeft: 0,
  121. scrollViewWidth: 0,
  122. lineOffsetLeft: 0,
  123. lineShow: false,
  124. tabsRect: {
  125. left: 0
  126. },
  127. innerCurrent: 0,
  128. moving: false,
  129. }
  130. },
  131. watch: {
  132. current: {
  133. immediate: true,
  134. handler(newValue, oldValue) {
  135. // 内外部值不相等时,才尝试移动滑块
  136. if (newValue !== this.innerCurrent) {
  137. if (typeof newValue == 'string') {
  138. this.innerCurrent = parseInt(newValue)
  139. } else {
  140. this.innerCurrent = newValue
  141. }
  142. this.$nextTick(() => {
  143. this.resize()
  144. })
  145. }
  146. }
  147. },
  148. // list变化时,重新渲染list各项信息
  149. list: {
  150. handler(newValue, oldValue) {
  151. // 重新拷贝一份list用于增加其他额外信息处理后导致重复监听的死循环
  152. this.tabList = deepClone(newValue);
  153. this.$nextTick(() => {
  154. this.resize()
  155. })
  156. },
  157. immediate: true,
  158. deep: true,
  159. }
  160. },
  161. computed: {
  162. shapeModeClass() {
  163. return this.shapeMode ? `u-tabs--shape-${this.shapeMode}` : ''
  164. },
  165. showLine() {
  166. return this.lineShow && !['capsule', 'pill-arrow', 'tag'].includes(this.shapeMode)
  167. },
  168. itemComputedStyle() {
  169. const style = addStyle(this.itemStyle) || {}
  170. if (this.upHasProp('itemStyle')) {
  171. return style
  172. }
  173. const defaultModeHeights = {
  174. capsule: '30px',
  175. card: '34px',
  176. 'pill-arrow': '32px',
  177. tag: '28px'
  178. }
  179. const height = defaultModeHeights[this.shapeMode]
  180. if (!height) {
  181. return style
  182. }
  183. return deepMerge(style, {
  184. height
  185. })
  186. },
  187. textStyle() {
  188. return index => {
  189. const style = {}
  190. // 取当期是否激活的样式
  191. const customeStyle = (index == this.innerCurrent) ?
  192. addStyle(this.activeStyle) :
  193. addStyle(this.inactiveStyle)
  194. const isActive = index == this.innerCurrent
  195. const defaultActiveColor = defProps.tabs?.activeStyle?.color || '#303133'
  196. const defaultInactiveColor = defProps.tabs?.inactiveStyle?.color || '#606266'
  197. const isActiveStyleOverridden = this.upHasProp('activeStyle')
  198. || (customeStyle && customeStyle.color && customeStyle.color !== defaultActiveColor)
  199. const isInactiveStyleOverridden = this.upHasProp('inactiveStyle')
  200. || (customeStyle && customeStyle.color && customeStyle.color !== defaultInactiveColor)
  201. if (isActive && ['pill-arrow', 'tag'].includes(this.shapeMode) && !isActiveStyleOverridden) {
  202. style.color = '#ffffff'
  203. } else if (isActive && !isActiveStyleOverridden) {
  204. style.color = this.upThemeVar('--up-main-color', this.$u.color.mainColor || defaultActiveColor)
  205. }
  206. if (!isActive && ['pill-arrow', 'tag'].includes(this.shapeMode) && !isInactiveStyleOverridden) {
  207. style.color = '#606266'
  208. } else if (!isActive && !isInactiveStyleOverridden) {
  209. style.color = this.upThemeVar('--up-content-color', this.$u.color.contentColor || defaultInactiveColor)
  210. }
  211. // 如果当前菜单被禁用,则加上对应颜色,需要在此做处理,是因为nvue下,无法在style样式中通过!import覆盖标签的内联样式
  212. if (this.tabList[index].disabled) {
  213. style.color = this.upThemeVar('--up-disabled-color', this.$u.color.disabledColor || '#c8c9cc')
  214. }
  215. return deepMerge(customeStyle, style)
  216. }
  217. },
  218. propsBadge() {
  219. return defProps.badge
  220. }
  221. },
  222. async mounted() {
  223. this.init()
  224. this.windowResizeCallback = (res) => {
  225. this.init()
  226. }
  227. uni.onWindowResize(this.windowResizeCallback)
  228. },
  229. beforeUnmount() {
  230. uni.offWindowResize(this.windowResizeCallback)
  231. },
  232. emits: ['click', 'longPress', 'change', 'update:current'],
  233. methods: {
  234. addStyle,
  235. addUnit,
  236. setLineLeft() {
  237. const tabItem = this.tabList[this.innerCurrent];
  238. if (!tabItem) {
  239. return;
  240. }
  241. // 获取滑块该移动的位置
  242. let lineOffsetLeft = this.tabList
  243. .slice(0, this.innerCurrent)
  244. .reduce((total, curr) => total + curr.rect.width, 0);
  245. // 获取下划线的数值px表示法
  246. const lineWidth = getPx(this.lineWidth);
  247. this.lineOffsetLeft = lineOffsetLeft + (tabItem.rect.width - lineWidth) / 2
  248. // #ifdef APP-NVUE
  249. // 第一次移动滑块,无需过渡时间
  250. this.animation(this.lineOffsetLeft, parseInt(this.duration))
  251. // #endif
  252. // 如果是第一次执行此方法,滑块默认不显示,在加载完成后进行显示
  253. if (!this.lineShow) this.lineShow = true;
  254. },
  255. // nvue下设置滑块的位置
  256. animation(x, duration = 0) {
  257. // #ifdef APP-NVUE
  258. const ref = this.$refs['u-tabs__wrapper__nav__line']
  259. animation.transition(ref, {
  260. styles: {
  261. transform: `translateX(${x}px)`
  262. },
  263. duration
  264. })
  265. // #endif
  266. },
  267. // 点击某一个标签
  268. clickHandler(item, index) {
  269. // 因为标签可能为disabled状态,所以click是一定会发出的,但是change事件是需要可用的状态才发出
  270. this.$emit('click', {
  271. ...item,
  272. index
  273. }, index)
  274. // 如果disabled状态,返回
  275. if (item.disabled) return
  276. // 如果点击当前不触发change
  277. if (this.innerCurrent == index) return
  278. this.innerCurrent = index
  279. this.$nextTick(() => {
  280. this.resize()
  281. })
  282. this.$emit('update:current', index)
  283. this.$emit('change', {
  284. ...item,
  285. index
  286. }, index)
  287. },
  288. // 长按事件
  289. longPressHandler(item, index) {
  290. this.$emit('longPress', {
  291. ...item,
  292. index
  293. })
  294. },
  295. init() {
  296. sleep().then(() => {
  297. this.resize()
  298. })
  299. },
  300. setScrollLeft() {
  301. // 当前活动tab的布局信息,有tab菜单的width和left(为元素左边界到父元素左边界的距离)等信息
  302. if (this.innerCurrent < 0) {
  303. this.innerCurrent = 0;
  304. }
  305. const tabRect = this.tabList[this.innerCurrent]
  306. // 累加得到当前item到左边的距离
  307. const offsetLeft = this.tabList
  308. .slice(0, this.innerCurrent)
  309. .reduce((total, curr) => {
  310. return total + curr.rect.width
  311. }, 0)
  312. // 此处为屏幕宽度
  313. const windowWidth = getWindowInfo().windowWidth
  314. // 将活动的tabs-item移动到屏幕正中间,实际上是对scroll-view的移动
  315. let scrollLeft = offsetLeft - (this.tabsRect.width - tabRect.rect.width) / 2 - (windowWidth - this.tabsRect
  316. .right) / 2 + this.tabsRect.left / 2
  317. // 这里做一个限制,限制scrollLeft的最大值为整个scroll-view宽度减去tabs组件的宽度
  318. scrollLeft = Math.min(scrollLeft, this.scrollViewWidth - this.tabsRect.width)
  319. this.scrollLeft = Math.max(0, scrollLeft)
  320. },
  321. // 获取所有标签的尺寸
  322. resize() {
  323. // 如果不存在list,则不处理
  324. if (this.tabList.length === 0) {
  325. return
  326. }
  327. Promise.all([this.getTabsRect(), this.getAllItemRect()]).then(([tabsRect, itemRect = []]) => {
  328. // 兼容在swiper组件中使用
  329. if (tabsRect.left > tabsRect.width) {
  330. tabsRect.right = tabsRect.right - Math.floor(tabsRect.left / tabsRect.width) * tabsRect
  331. .width
  332. tabsRect.left = tabsRect.left % tabsRect.width
  333. }
  334. // console.log(tabsRect)
  335. this.tabsRect = tabsRect
  336. this.scrollViewWidth = 0
  337. itemRect.map((item, index) => {
  338. // 计算scroll-view的宽度,这里
  339. this.scrollViewWidth += item.width
  340. // 另外计算每一个item的中心点X轴坐标
  341. this.tabList[index].rect = item
  342. })
  343. // 获取了tabs的尺寸之后,设置滑块的位置
  344. this.setLineLeft()
  345. this.setScrollLeft()
  346. })
  347. },
  348. // 获取导航菜单的尺寸
  349. getTabsRect() {
  350. return new Promise(resolve => {
  351. this.queryRect('u-tabs__wrapper__scroll-view').then(size => resolve(size))
  352. })
  353. },
  354. // 获取所有标签的尺寸
  355. getAllItemRect() {
  356. return new Promise(resolve => {
  357. const promiseAllArr = this.tabList.map((item, index) => this.queryRect(
  358. `u-tabs__wrapper__nav__item-${index}`, true));
  359. Promise.all(promiseAllArr).then(sizes => resolve(sizes));
  360. })
  361. },
  362. // 获取各个标签的尺寸
  363. queryRect(el, item) {
  364. // #ifndef APP-NVUE
  365. // $uGetRect为uView自带的节点查询简化方法,详见文档介绍:https://uview-plus.jiangruyi.com/js/getRect.html
  366. // 组件内部一般用this.$uGetRect,对外的为uni.$u.getRect,二者功能一致,名称不同
  367. return new Promise(resolve => {
  368. this.$uGetRect(`.${el}`).then(size => {
  369. resolve(size)
  370. })
  371. })
  372. // #endif
  373. // #ifdef APP-NVUE
  374. // nvue下,使用dom模块查询元素高度
  375. // 返回一个promise,让调用此方法的主体能使用then回调
  376. return new Promise(resolve => {
  377. dom.getComponentRect(item ? this.$refs[el][0] : this.$refs[el], res => {
  378. resolve(res.size)
  379. })
  380. })
  381. // #endif
  382. },
  383. },
  384. }
  385. </script>
  386. <style lang="scss" scoped>
  387. .u-tabs {
  388. &__wrapper {
  389. @include flex;
  390. align-items: center;
  391. &__scroll-view-wrapper {
  392. flex: 1;
  393. /* #ifndef APP-NVUE */
  394. overflow: auto hidden;
  395. /* #endif */
  396. }
  397. &__scroll-view {
  398. @include flex;
  399. flex: 1;
  400. }
  401. &__nav {
  402. @include flex;
  403. position: relative;
  404. &__item {
  405. padding: 0 11px;
  406. @include flex;
  407. position: relative;
  408. align-items: center;
  409. justify-content: center;
  410. /* #ifdef H5 */
  411. cursor: pointer;
  412. /* #endif */
  413. &--disabled {
  414. /* #ifdef H5 */
  415. cursor: not-allowed;
  416. /* #endif */
  417. }
  418. &__text {
  419. font-size: 15px;
  420. color: $u-content-color;
  421. white-space: nowrap !important;
  422. &--disabled {
  423. color: $u-disabled-color !important;
  424. }
  425. }
  426. &__card-corner {
  427. position: absolute;
  428. top: 0;
  429. right: -10px;
  430. width: 20px;
  431. height: 100%;
  432. background-color: inherit;
  433. transform: skewX(25deg);
  434. border-top-right-radius: 10px;
  435. z-index: 1;
  436. }
  437. &__active-arrow {
  438. position: absolute;
  439. left: 50%;
  440. bottom: -6px;
  441. width: 0;
  442. height: 0;
  443. border-left: 6px solid transparent;
  444. border-right: 6px solid transparent;
  445. border-top: 6px solid #ff3b30;
  446. transform: translateX(-50%);
  447. }
  448. }
  449. &__line {
  450. height: 3px;
  451. background: $u-primary;
  452. width: 30px;
  453. position: absolute;
  454. bottom: 2px;
  455. border-radius: 100px;
  456. transition-property: transform;
  457. transition-duration: 300ms;
  458. }
  459. }
  460. }
  461. &--shape-capsule {
  462. .u-tabs__wrapper__scroll-view-wrapper {
  463. padding: 3px;
  464. border-radius: 999px;
  465. background-color: #edf0f5;
  466. }
  467. .u-tabs__wrapper__nav__item {
  468. min-height: 30px;
  469. padding: 0 14px;
  470. border-radius: 999px;
  471. transition: background-color 0.2s;
  472. }
  473. .u-tabs__wrapper__nav__item-active {
  474. background-color: #ffffff;
  475. }
  476. }
  477. &--shape-card {
  478. .u-tabs__wrapper__scroll-view-wrapper {
  479. padding: 0;
  480. border-radius: 10px;
  481. background-color: #9ccde5;
  482. box-shadow: inset 0 0 0 1px rgba(96, 98, 102, 0.06);
  483. }
  484. .u-tabs__wrapper__nav__item {
  485. min-height: 34px;
  486. padding: 0;
  487. border-radius: 10px 10px 0 0;
  488. transition: background-color 0.2s;
  489. }
  490. .u-tabs__wrapper__nav__item-active {
  491. background-color: #f6f8fb;
  492. box-shadow: inset 0 0 0 1px rgba(96, 98, 102, 0.06);
  493. z-index: 2;
  494. }
  495. }
  496. &--shape-pill-arrow {
  497. .u-tabs__wrapper__nav {
  498. padding-bottom: 6px;
  499. }
  500. .u-tabs__wrapper__nav__item {
  501. min-height: 32px;
  502. padding: 0 12px;
  503. border-radius: 8px;
  504. background-color: #e8e8e8;
  505. margin-right: 8px;
  506. }
  507. .u-tabs__wrapper__nav__item-active {
  508. background: linear-gradient(90deg, #ff6c57 0%, #ff3b30 100%);
  509. }
  510. }
  511. &--shape-tag {
  512. .u-tabs__wrapper__nav {
  513. padding: 2px 0;
  514. }
  515. .u-tabs__wrapper__nav__item {
  516. min-height: 28px;
  517. padding: 0 14px;
  518. border-radius: 999px;
  519. background-color: #f3f4f6;
  520. margin-right: 8px;
  521. }
  522. .u-tabs__wrapper__nav__item-active {
  523. background-color: #2a6bf6;
  524. }
  525. }
  526. }
  527. </style>