[Improvement] Tab animation fluency && position (#379)

This commit is contained in:
neverland 2017-12-05 20:23:34 +08:00 committed by GitHub
parent 2327e75516
commit 5a17bc520a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 199 additions and 393 deletions

View File

@ -20,12 +20,12 @@
- 45+ Reusable components
- 90%+ Unit test coverage
- Extensive documentation and demos.
- Extensive documentation and demos
- Support [babel-plugin-import](https://github.com/ant-design/babel-plugin-import)
- Support TypeScript
<p align="center">
<img alt="feature demo" src="https://img.yzcdn.cn/public_files/2017/12/05/6a69f80c7ba6754795a7cc6d0766950f.png">
<img alt="components preview" src="https://img.yzcdn.cn/public_files/2017/12/05/95f5ee7524b7845abb2f51803a01d65e.png">
</p >
## Install

View File

@ -22,7 +22,7 @@
- 支持 TypeScript
<p align="center">
<img alt="feature demo" src="https://img.yzcdn.cn/public_files/2017/12/05/6a69f80c7ba6754795a7cc6d0766950f.png">
<img alt="components preview" src="https://img.yzcdn.cn/public_files/2017/12/05/95f5ee7524b7845abb2f51803a01d65e.png">
</p >
## 安装

View File

@ -2,7 +2,7 @@
<demo-section>
<demo-block :title="$t('basicUsage')">
<van-tabs :active="active">
<van-tab :title="$t('tab') + index" v-for="index in 4" :key="index">
<van-tab :title="$t('tab') + index" v-for="index in tabs" :key="index">
{{ $t('content') }} {{ index }}
</van-tab>
</van-tabs>
@ -64,7 +64,8 @@ export default {
data() {
return {
active: 2
active: 2,
tabs: [1, 2, 3, 4]
};
},

View File

@ -106,7 +106,7 @@ export default {
|-----------|-----------|-----------|-------------|-------------|
| type | There are two style tabs, set this attribute to change tab style | `String` | `line` | `card` |
| active | Index of active tab | `String` `Number` | `0` | - |
| duration | Toggle tab's animation time | `Number` | `0.3` | - | - |
| duration | Toggle tab's animation time | `Number` | `0.2` | - | - |
| swipeThreshold | Set swipe tabs threshold | `Number` | `4` | - | - |
### Tab API

View File

@ -106,7 +106,7 @@ export default {
|-----------|-----------|-----------|-------------|-------------|
| type | Tab 样式类型 | `String` | `line` | `card` |
| active | 默认激活的 tab | `String` `Number` | `0` | - |
| duration | 切换 tab 的动画时间 | `Number` | `0.3` | - | - |
| duration | 切换 tab 的动画时间 | `Number` | `0.2` | - | - |
| swipeThreshold | 滚动阀值,设置 Tab 超过多少个可滚动 | `Number` | `4` | - | - |
### Tab API

View File

@ -72,7 +72,6 @@
<script>
import Vue from 'vue';
import { isServer } from '../../utils';
import Popup from '../../popup';
import Toast from '../../toast';
import SkuHeader from '../components/SkuHeader';
@ -177,7 +176,7 @@ export default {
computed: {
bodyStyle() {
if (isServer) {
if (this.$isServer) {
return;
}

View File

@ -1,5 +1,5 @@
<template>
<div class="van-tab__pane" :class="{ 'van-tab__pane--select': key === $parent.curActive }">
<div class="van-tab__pane" :class="{ 'van-tab__pane--select': index === parentGroup.curActive }">
<slot></slot>
</div>
</template>
@ -20,46 +20,19 @@ export default {
disabled: Boolean
},
data() {
computed: {
index() {
return this.parentGroup.tabs.indexOf(this);
}
},
created() {
this.findParentByName('van-tabs');
const nextIndex = this.parentGroup.tabs.length;
this.updateParentData(nextIndex);
return {
key: nextIndex
};
},
watch: {
title() {
this.updateParentData();
},
disabled() {
this.updateParentData();
}
},
methods: {
updateParentData(nextIndex) {
const index = arguments.length ? nextIndex : this.key;
this.parentGroup.tabs.splice(index, 1, {
title: this.title,
disabled: this.disabled,
index
});
}
this.parentGroup.tabs.push(this);
},
destroyed() {
const key = this.key;
const tabs = this.parentGroup.tabs;
for (let i = 0; i < tabs.length; i++) {
/* istanbul ignore else */
if (tabs[i].index === key) {
this.parentGroup.tabs.splice(i, 1);
return;
}
}
this.parentGroup.tabs.splice(this.index, 1);
}
};
</script>

View File

@ -1,39 +1,23 @@
<template>
<div class="van-tabs" :class="`van-tabs--${type}`">
<div class="van-tabs__nav-wrap" v-if="type === 'line' && tabs.length > swipeThreshold">
<div class="van-tabs__swipe" ref="swipe">
<div class="van-tabs__nav van-tabs__nav--line">
<div class="van-tabs__nav-bar" :style="navBarStyle"></div>
<div :class="{ 'van-tabs__swipe': scrollable, 'van-hairline--top-bottom': type === 'line' }">
<div class="van-tabs__nav" :class="`van-tabs__nav--${type}`" ref="nav">
<div v-if="type === 'line'" class="van-tabs__nav-bar" :style="navBarStyle" />
<div
v-for="(tab, index) in tabs"
:key="index"
class="van-tab van-hairline"
:class="{ 'van-tab--active': index === curActive }"
ref="tabkey"
@click="handleTabClick(index)"
ref="tabs"
class="van-tab"
:class="{
'van-tab--active': index === curActive,
'van-tab--disabled': tab.disabled
}"
@click="onClick(index)"
>
<span>{{ tab.title }}</span>
</div>
</div>
</div>
</div>
<div
v-else
class="van-tabs__nav"
:class="`van-tabs__nav--${type}`"
>
<div class="van-tabs__nav-bar" :style="navBarStyle" v-if="type === 'line'"></div>
<div
v-for="(tab, index) in tabs"
:key="index"
class="van-tab van-hairline"
:class="{ 'van-tab--active': index === curActive }"
ref="tabkey"
@click="handleTabClick(index)"
>
<span>{{ tab.title }}</span>
</div>
</div>
<div class="van-tabs__content">
<slot></slot>
</div>
@ -41,27 +25,23 @@
</template>
<script>
import swipe from './swipe';
import translateUtil from '../utils/transition';
import { raf } from '../utils/raf';
export default {
export default {
name: 'van-tabs',
props: {
// tab
active: {
type: [Number, String],
default: 0
},
// linecard
type: {
type: String,
default: 'line'
},
// tab
duration: {
type: Number,
default: 0.3
default: 0.2
},
swipeThreshold: {
type: Number,
@ -70,163 +50,101 @@
},
data() {
this.winWidth = this.$isServer ? 0 : window.innerWidth;
return {
tabs: [],
curActive: +this.active,
isSwiping: false,
isInitEvents: false,
curActive: 0,
navBarStyle: {}
};
},
watch: {
active(val) {
this.curActive = +val;
this.correctActive(val);
},
tabs(tabs) {
this.correctActive(this.curActive);
this.setNavBar();
},
curActive() {
this.setNavBarStyle();
/* istanbul ignore else */
if (this.tabs.length > this.swipeThreshold) {
this.doOnValueChange();
}
},
tabs(val) {
this.$nextTick(() => {
this.setNavBarStyle();
if (val.length > this.swipeThreshold) {
this.initEvents();
this.doOnValueChange();
} else {
this.isInitEvents = false;
}
const activeExist = val.some(tab => tab.index === this.curActive);
if (!activeExist) {
this.curActive = val[0].index || 0;
}
});
}
},
computed: {
swipeWidth() {
return this.$refs.swipe && this.$refs.swipe.getBoundingClientRect().width;
},
maxTranslate() {
/* istanbul ignore if */
if (!this.$refs.tabkey) return;
const lastTab = this.$refs.tabkey[this.tabs.length - 1];
const lastTabWidth = lastTab.offsetWidth;
const lastTabOffsetLeft = lastTab.offsetLeft;
return (lastTabOffsetLeft + lastTabWidth) - this.swipeWidth;
this.scrollIntoView();
this.setNavBar();
}
},
mounted() {
//
this.$nextTick(() => {
this.setNavBarStyle();
this.correctActive(this.active);
this.setNavBar();
},
if (this.tabs.length > this.swipeThreshold) {
this.initEvents();
this.doOnValueChange();
computed: {
scrollable() {
return this.tabs.length > this.swipeThreshold;
}
});
},
methods: {
/**
* `type``line`tab下方的横线的样式
*/
setNavBarStyle() {
if (this.type !== 'line' || !this.$refs.tabkey) return {};
const tabKey = this.curActive;
const elem = this.$refs.tabkey[tabKey];
const offsetWidth = `${elem.offsetWidth || 0}px`;
const offsetLeft = `${elem.offsetLeft || 0}px`;
setNavBar() {
this.$nextTick(() => {
const tab = this.$refs.tabs[this.curActive];
this.navBarStyle = {
width: offsetWidth,
transform: `translate3d(${offsetLeft}, 0, 0)`,
width: `${tab.offsetWidth || 0}px`,
transform: `translate3d(${tab.offsetLeft || 0}px, 0, 0)`,
transitionDuration: `${this.duration}s`
};
},
handleTabClick(index) {
if (this.tabs[index].disabled) {
this.$emit('disabled', index);
return;
}
this.$emit('click', index);
this.curActive = index;
},
/**
* 将当前value值转换为需要translate的值
*/
value2Translate(value) {
/* istanbul ignore if */
if (!this.$refs.tabkey) return 0;
const tab = this.$refs.tabkey[value];
const maxTranslate = this.maxTranslate;
const tabWidth = tab.offsetWidth;
const tabOffsetLeft = tab.offsetLeft;
let translate = tabOffsetLeft + (tabWidth * 2.7) - this.swipeWidth;
if (translate < 0) {
translate = 0;
}
return -1 * (translate > maxTranslate ? maxTranslate : translate);
},
initEvents() {
const el = this.$refs.swipe;
if (!el || this.isInitEvents) return;
this.isInitEvents = true;
let swipeState = {};
swipe(el, {
start: event => {
swipeState = {
start: new Date(),
startLeft: event.pageX,
startTranslateLeft: translateUtil.getElementTranslate(el).left
};
},
drag: event => {
this.isSwiping = true;
swipeState.left = event.pageX;
const deltaX = swipeState.left - swipeState.startLeft;
const translate = swipeState.startTranslateLeft + deltaX;
/* istanbul ignore else */
if (translate > 0 || (translate * -1) > this.maxTranslate) return;
translateUtil.translateElement(el, translate, null);
},
end: () => {
this.isSwiping = false;
}
});
},
doOnValueChange() {
const value = +this.curActive;
const swipe = this.$refs.swipe;
correctActive(active) {
active = +active;
const exist = this.tabs.some(tab => tab.index === active);
this.curActive = exist ? active : (this.tabs[0].index || 0);
},
translateUtil.translateElement(swipe, this.value2Translate(value), null);
onClick(index) {
if (this.tabs[index].disabled) {
this.$emit('disabled', index);
} else {
this.$emit('click', index);
this.curActive = index;
}
},
scrollIntoView() {
if (!this.scrollable) {
return;
}
const tab = this.$refs.tabs[this.curActive];
const { nav } = this.$refs;
const { winWidth } = this;
const { scrollLeft } = nav;
const { offsetLeft, offsetWidth: tabWidth } = tab;
// out of right side
if ((winWidth + scrollLeft - offsetLeft - tabWidth * 1.8) < 0) {
this.scrollTo(nav, scrollLeft, offsetLeft + tabWidth * 1.8 - winWidth);
}
// out of left side
else if (offsetLeft < (scrollLeft + tabWidth * 0.8)) {
this.scrollTo(nav, scrollLeft, offsetLeft - tabWidth * 0.8);
}
},
scrollTo(el, from, to) {
let count = 0;
const frames = Math.round(this.duration * 1000 / 16);
const animate = () => {
el.scrollLeft += (to - from) / frames;
if (++count < frames) {
raf(animate);
}
}
};
animate();
}
}
};
</script>

View File

@ -1,46 +0,0 @@
import Vue from 'vue';
let isSwiping = false;
const supportTouch = !Vue.prototype.$isServer && 'ontouchstart' in window;
export default function(element, options) {
const moveFn = function(event) {
if (options.drag) {
options.drag(supportTouch ? event.changedTouches[0] || event.touches[0] : event);
}
};
const endFn = function(event) {
if (!supportTouch) {
document.removeEventListener('mousemove', moveFn);
document.removeEventListener('mouseup', endFn);
}
isSwiping = false;
if (options.end) {
options.end(supportTouch ? event.changedTouches[0] || event.touches[0] : event);
}
};
element.addEventListener(supportTouch ? 'touchstart' : 'mousedown', function(event) {
if (isSwiping) return;
if (!supportTouch) {
document.addEventListener('mousemove', moveFn);
document.addEventListener('mouseup', endFn);
}
isSwiping = true;
if (options.start) {
options.start(supportTouch ? event.changedTouches[0] || event.touches[0] : event);
}
});
if (supportTouch) {
element.addEventListener('touchmove', moveFn);
element.addEventListener('touchend', endFn);
element.addEventListener('touchcancel', endFn);
}
};

32
packages/utils/raf.js Normal file
View File

@ -0,0 +1,32 @@
/**
* requestAnimationFrame polyfill
*/
import { isServer } from './index';
let prev = Date.now();
function fallback(fn) {
const curr = Date.now();
const ms = Math.max(0, 16 - (curr - prev));
const id = setTimeout(fn, ms);
prev = curr + ms;
return id;
}
const global = isServer ? global : window;
const iRaf =
global.requestAnimationFrame ||
global.webkitRequestAnimationFrame ||
fallback;
const iCancel =
global.cancelAnimationFrame ||
global.webkitCancelAnimationFrame ||
global.clearTimeout;
export function raf(fn) {
return iRaf.call(global, fn);
}
export function cancel(id) {
iCancel.call(global, id);
}

View File

@ -1,73 +0,0 @@
import Vue from 'vue';
var exportObj = {};
if (!Vue.prototype.$isServer) {
var cssPrefix = '-webkit-';
var vendorPrefix = 'Webkit';
var transformProperty = vendorPrefix + 'Transform';
var transformStyleName = cssPrefix + 'transform';
var transitionProperty = vendorPrefix + 'Transition';
var transitionStyleName = cssPrefix + 'transition';
var transitionEndProperty = vendorPrefix.toLowerCase() + 'TransitionEnd';
var getTranslate = function(element) {
var result = { left: 0, top: 0 };
if (element === null || element.style === null) return result;
var transform = element.style[transformProperty];
var matches = /translate\(\s*(-?\d+(\.?\d+?)?)px,\s*(-?\d+(\.\d+)?)px\)\s*translateZ\(0px\)/ig.exec(transform);
if (matches) {
result.left = +matches[1];
result.top = +matches[3];
}
return result;
};
var translateElement = function(element, x, y) {
if (x === null && y === null) return;
if (element === null || element === undefined || element.style === null) return;
if (!element.style[transformProperty] && x === 0 && y === 0) return;
if (x === null || y === null) {
var translate = getTranslate(element);
if (x === null) {
x = translate.left;
}
if (y === null) {
y = translate.top;
}
}
cancelTranslateElement(element);
element.style[transformProperty] += ' translate(' + (x ? (x + 'px') : '0px') + ',' + (y ? (y + 'px') : '0px') + ') translateZ(0px)';
};
var cancelTranslateElement = function(element) {
if (element === null || element.style === null) return;
var transformValue = element.style[transformProperty];
if (transformValue) {
transformValue = transformValue.replace(/translate\(\s*(-?\d+(\.?\d+?)?)px,\s*(-?\d+(\.\d+)?)px\)\s*translateZ\(0px\)/g, '');
element.style[transformProperty] = transformValue;
}
};
exportObj = {
transformProperty: transformProperty,
transformStyleName: transformStyleName,
transitionProperty: transitionProperty,
transitionStyleName: transitionStyleName,
transitionEndProperty: transitionEndProperty,
getElementTranslate: getTranslate,
translateElement: translateElement,
cancelTranslateElement: cancelTranslateElement
};
};
export default exportObj;

View File

@ -4,46 +4,38 @@
.van-tabs {
position: relative;
&__nav-wrap {
overflow: hidden;
}
&__swipe {
user-select: none;
transition: transform linear .2s;
.van-tab {
flex: 0 0 22%;
}
.van-tabs__nav {
overflow: visible;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
display: none;
background-color: transparent;
}
}
}
&__nav {
overflow: hidden;
transition: transform .5s cubic-bezier(.645, .045, .355, 1);
position: relative;
display: flex;
position: relative;
background-color: $white;
&--line {
height: 44px;
.van-tab {
&::after {
border-width: 1px 0;
}
}
}
&--card {
height: 28px;
margin: 0 15px;
background-color: $white;
border-radius: 2px;
border: 1px solid $gray-darker;
overflow: hidden;
.van-tab {
color: $gray-darker;
@ -55,8 +47,8 @@
}
&.van-tab--active {
background-color: $gray-darker;
color: $white;
background-color: $gray-darker;
}
}
}
@ -64,27 +56,25 @@
&__nav-bar {
z-index: 1;
position: absolute;
left: 0;
bottom: 0;
height: 2px;
background-color: #f13e3a;
transition: transform .3s cubic-bezier(.645, .045, .355, 1);
transform-origin: 0 0;
position: absolute;
background-color: $red;
}
}
.van-tab {
flex: 1;
cursor: pointer;
padding: 0 5px;
font-size: 14px;
position: relative;
color: $text-color;
background-color: $white;
font-size: 14px;
line-height: 44px;
box-sizing: border-box;
cursor: pointer;
text-align: center;
flex: 1;
padding: 0 5px;
box-sizing: border-box;
background-color: $white;
min-width: 0; /* hack for flex ellipsis */
span {
@ -92,10 +82,22 @@
@mixin ellipsis;
}
&:active {
background-color: $active-color;
}
&--active {
color: $red;
}
&--disabled {
color: $gray;
&:active {
background-color: $white;
}
}
&__pane {
display: none;