onchange
事件; @Prop({default: []}) private columns!: any[];
@Prop({default: 44}) private itemHeight!: number | string;
@Prop({default: 'name'}) private valueKey!: string;
@Prop({default: ''}) private defaultValue!: string;
@Prop({default: 'id'}) private defaultKey!: string;
@Prop({default: '取消'}) private cancelText!: string;
@Prop({default: '#5e6d82'}) private cancelColor!: string;
@Prop({default: '确认'}) private confirmText!: string;
@Prop({default: '#007bff'}) private confirmColor!: string;
@Prop({default: '标题'}) private title!: string;
@Prop({default: '44'}) private toolBarHeight!: number | string;
@Prop({default: true}) private showToolBar!: boolean;
html
结构- 头部
- 筛选主体
- 筛选列组件(for循环)
- 高亮框(即中间两条线)
- 遮罩蒙板,通过渐变的方式将上下变模糊一些
<template>
<div class="ar-picker">
<div class="ar-picker__columns">
<div>列组件div>
<div class="ar-picker__mask">div>
<div class="ar-picker__frame">div>
div>
div>
template>
.ar-picker{
position: relative;
background: #fff;
user-select: none;
&__columns{
position: relative;
display: flex;
}
&__mask{
position: absolute;
top:0;
bottom: 0;
right: 0;
left:0;
z-index: 2;
background-image: linear-gradient(
180deg,
hsla(0, 0%, 100%, 0.9),
hsla(0, 0%, 100%, 0.4)
),
linear-gradient(0deg, hsla(0, 0%, 100%, 0.9), hsla(0, 0%, 100%, 0.4));
background-repeat: no-repeat;
background-position: top, bottom;
backface-visibility: hidden;
pointer-events: none;
}
&__frame{
position: absolute;
top: 50%;
right: 4px;
left: 4px;
z-index: 3;
transform: translateY(-50%);
pointer-events: none;
&::after{
content: '';
position: absolute;
box-sizing: border-box;
top: -50%;
right: -50%;
bottom: -50%;
left: -50%;
border-top: 1px solid #ebedf0;
border-bottom: 1px solid #ebedf0;
transform: scale(0.5);
}
}
}
// picker的选项值
@Prop({default: []}) private columns!: any[];
// 每项的高度
@Prop({default: 44}) private height!: number | string;
// 每项的显示的内容
@Prop({default: 'name'}) private valueKey!: string;
// 每项的被选中时返回的值
@Prop({default: 'id'}) private returnKey!: string;
picker
中columns
的高度本组件允许用户自定义每项的高度,所以我们要考虑传递过来的值为0
的情况:
get optonHeight(){
return this.height ? (+this.height) : DEFAULT_ITEM_HEIGHT;
}
columns
的高度在本组件中我们默认只显示5
个,所以高度的计算为:每一项的高度 * 5:
const DEFAULT_ITEM_HEIGHT = 44;
get columnHeight(){
return this.optonHeight * 5;
}
get columnsStyle(){
return {
height: `${this.columnHeight}px`
}
}
即每一项的高度
get frameStyle(){
return {
height: `${this.optonHeight}px`
}
}
get maskStyle(){
return {
backgroundSize: `100% ${(this.columnHeight - this.optonHeight) / 2}px`
}
}
columns
的值用户可以传递的columns
有这些:
// 单例
columns: ['小明', '小红', '小刚']; // text
columns: [{ id: 1, name: '小明' }, { id: 2, name: '小红' }]; // object
// 多列
columns: [
[{ id: 1, name: '小明' }, { id: 2, name: '小红' }],
[{ id: 'apple', name: '苹果' }, { id: 'banner', name: '香蕉' }] // object true
]
// 级联操作
columns: [
{
id: '1',
name: '广东省',
children: [
{
id: 'shenzhen',
name: '深圳',
children: [{
id: 'baoan',
name: '宝安区'
},{
id: 'longhu',
name: '龙湖区'
}]
},
{
id: 'dongguan',
name: '东莞',
children: [{
id: 'guancheng',
name: '莞城'
},{
id: 'songshanhu',
name: '松山湖'
}]
}
]
},{
id: '2',
name: '湖南省',
children: [
{
id: 'changsha',
name: '长沙',
children: [{
id: 'changshaxian',
name: '长沙县'
},{
id: 'wangcheng',
name: '望城'
}]
},{
id: 'yueyang',
name: '岳阳',
children: [{
id: 'yunxi',
name: '云溪区'
},{
id: 'junshan',
name: '君山区'
}]
}
]
}
]
我们的columns
组件是呈现每一列的数据,所以我们需要将数据重构为N个(列)数组结构。
@Watch('columns', { deep: true, immediate: true})
changeColumns(){
this.formatColumn();
}
要分别处理单列(纯文本、对象)、多列和级联的情况,然后赋值给formattedColumns
。
private formatColumns(){
const { columns, dataType } = this;
switch(dataType){
case 'text': this.formatText();break;
case 'single': this.formattedColumns = [ columns ];break;
case 'multi': this.formattedColumns = columns; break;
case 'cascade': this.formatCascade();break;
default: break;
}
}
private formatText(){
let formattedColumns = [];
for(let i = 0, len = this.columns.length; i < len; i++){
let obj: any = {};
const key = this.defaultKey || 'id';
obj[key] = i;
obj.name = this.columns[i];
formattedColumns.push(obj);
}
this.formattedColumns = [formattedColumns];
}
private formatCascade(){
let firstColumn: any[] = [];
Object.keys(this.columns).forEach((key: any) => {
firstColumn.push(this.columns[key]);
})
let formattedColumns = [];
formattedColumns.push(firstColumn)
let cursor = firstColumn[0].children;
while(Array.isArray(cursor) && cursor.length){
formattedColumns.push(cursor);
cursor = cursor[0].children;
}
this.formattedColumns = formattedColumns;
}
@Prop({default: 44}) private itemHeight!: number | string;
@Prop({default: () => []}) private columnList!: any[];
每一列作为单独的一个组件,即为picker_column
, 所以每一列必有flex:1
的样式,由于中间的内容是会滚动的,所以含有overflow: hidden
的样式。内部含有ul
, 我们通过更改它的tanslateY
来改变哪一项位于picker
的正中间,内部的li
会根据用户提供的itemHeight
决定,显示的内容需要水平垂直。
<template>
<div class="ar-picker-column">
<ul class="ar-picker-column__wrapper"
ref="wrapper"
>
<li class="ar-picker-column__item"
:style="optionHeight"
>
<div>每一项div>
li>
ul>
div>
template>
.ar-picker--column{
flex:1;
overflow: hidden;
font-size: 14px;
&__wrapper{
transition-timing-function: cubic-bezier(0.23, 1, 0.68, 1);
}
&__item{
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
}
}
每一项的高度是由itemHight
决定的:
get optionHeight(){
return {
height: `${this.itemHeight}px`
};
}
接下来我要来实现一个大头,就是数据渲染,这里还得考虑到用户定义的高亮默认值。
<template>
<div class="ar-picker-column">
<ul class="ar-picker-column__wrapper"
ref="wrapper"
>
<li class="ar-picker-column__item"
:style="optionHeight"
v-for="(item, index) in columnList"
:key="index"
>
<div>{{item.name}}div>
li>
ul>
div>
template>
"ar-picker">
"ar-picker__columns"
:style="columnsStyle"
>
"(item, index) in formattedColumns"
:key="index"
:itemHeight="optonHeight"
:columnList="item"
/>
"ar-picker__mask"
:style="maskStyle"
>
"ar-picker__frame"
:style="frameStyle"
>
以上的代码可以实现单列和多列的渲染,级联渲染虽然也可以,但是我们没有考虑用户定义了默认值的情况,现在第二列显示的是浙江的杭州和温州
,第三轮显示的是杭州的西湖区和余杭区
。如果用户希望初始化的时候显示的是福建的福州和厦门
和厦门的思明区和沧海区
,那就有问题了。
@Prop({default: 'fujian, xiamen, haicang'}) private defaultValue!: string;
@Prop({default: 'id'}) private defaultKey!: string;
private formatCascade(){
let firstColumn: any[] = [];
Object.keys(this.columns).forEach((key: any) => {
firstColumn.push(this.columns[key]);
})
let formattedColumns = [];
let cursor = firstColumn;
formattedColumns.push(firstColumn);
const defaultValueArr = this.defaultValue.split(',');
let defaultValueArrIndex = 0;
while(cursor && Array.isArray(cursor) && cursor.length > 0 && cursor[0].children){
console.log(cursor, 'cursor');
let children = this.getChildColumn(cursor, defaultValueArr[defaultValueArrIndex]);
formattedColumns.push(children);
cursor = children;
defaultValueArrIndex++;
}
this.formattedColumns = formattedColumns;
}
private getChildColumn(column: any[], key: string): any[]{
key = key.trim();
for(let i = 0, len = column.length; i < len; i++){
if(column[i][this.defaultKey] === key){
column = column[i].children ? column[i].children : [];
return column;
}
}
return [];
}
初始化的时候,如果没有默认值,那么我们每一列第一项应该处于最中间,因为最多显示5个列表项,所以一开始的时候应该将ul
偏移两个itemHeight
的距离。
get baseOffset(){
return (this.itemHeight * (5 - 1)) / 2;
}
url
组件需要根据滑动时改变的offset
来改变偏移量,因为这是动画,所以加一个动画过度时间duration
:
private offset:number = 0;
private duration:number = 0;
get wrapperStyle(){
return {
transform: `translate3d(0, ${this.offset + this.baseOffset}px, 0)`,
transitionDuration: `${this.duration}ms`,
transitionProperty: 'all',
}
}
因为可能有多列,所以把每一列封装成了一个组件。这个组件需要实现以下功能。
index
代表当前是第几列,defaultIndex
代表当前列高亮哪一个项,在columns
加上:
@Prop({default: 0}) private index!: number;
@Prop({default: () => []}) private defaultIndex!: any[];
private getDefaultIndex(){
let defaultIndex = new Array(this.defaultValueArr.length);
for(let i = 0, len = this.formattedColumns.length; i < len; i++ ){
const list = this.formattedColumns[i];
defaultIndex[i] = 0;
for(let j = 0, jLen = list.length; j < jLen; j++){
if(list[j][this.defaultKey] === this.defaultValueArr[i]){
defaultIndex[i] = j;
}
}
}
this.defaultIndex = defaultIndex;
}
get defaultValueArr(){
return this.defaultValue.split(',');
}
当defaultValue
的值为:fujian, xiamen, haicang
时,根据getDefaultIndex
获得的defaultIndex
的值为[1,1,1]
。
在使用column
组件时,修改为:
<column v-for="(item, index) in formattedColumns"
:key="index"
:itemHeight="optonHeight"
:columnList="item"
:columnIndex="index"
:defaultIndex="defaultIndex"
/>
这样我们在column
组件中监听defaultIndex
:
@Watch('defaultIndex', { deep: true, immediate: true})
changeDefaultIndex(newVal: any[]){
let index = newVal[this.columnIndex] ?? 0;
this.setIndex(index);
}
在setIndex
中,我们会根据index
来更改offset
的值,进而改变ul
的偏移量,我们需要控制index
的取值范围:
// 先获得当前可选项的个数
get count(){
return this.columnList.length;
}
private adjust(index: number){
return range(index, 0, this.count);
}
private setIndex(index: number){
index = this.adjust(index) || 0;
const offset = -index * this.itemHeight;
this.offset = offset;
}
export const range = (num: number, min: number, max: number) => {
return Math.min(Math.max(num, min), max-1);
}
这样fujian, xiamen, haicang
就在初始化的时候高亮了。
offset
接下来到了我们绑定touch
事件实现picker
触摸滑动的时间了。跟前两个组件一样,绑定touch
相关事件的函数:
mounted(){
this.bindTouchEvent(this.$el);
}
在开始的时候,我们触发touchStart
,顺便把duration
设置为0
:
private startOffset: number = 0;
public handleTouchStart(event: TouchEvent){
this.touchStart(event);
this.startOffset = this.offset;
this.duration = 0;
}
在触摸过程中,不断更新offset
,offset
的取值应该在每项的高度和整个picker
的高度之间:
public handleTouchMove(event: TouchEvent){
this.toucheMove(event);
this.offset = range(this.startOffset + this.deltaY, -(this.count * this.itemHeight), this.itemHeight);
}
到这里就可以滑动了,虽然特别卡顿(卡成了安卓机哈哈哈),在触摸结束时,index
的值应该在0
和每一项高度的中间,我们更新一下offset
的值:(setIndex
多了个参数emitChange
, 当为true
时我们可以通知一下父组件picker changed
,这对级联数据是有帮助的)
public handleTouchEnd(){
const index = this.getIndexByOffset(this.offset);
this.duration = DEFAULT_DURATION;
this.setIndex(index, true);
}
private currentIndex: number = this.defaultIndex[this.columnIndex];
private setIndex(index: number, emitChange?: boolean){
index = this.adjust(index) || 0;
const offset = -index * this.itemHeight;
const trigger = () => {
if(index !== this.currentIndex){
this.currentIndex = index;
if(emitChange){
this.$emit('change', {
columnIndex: this.columnIndex,
currentIndex: index,
item: this.columnList[index]
});
}
}
}
trigger();
this.offset = offset;
}
这里需要考虑我们的三种类型,当为单列或多列时都需要更新currentValue
, 单列还会告诉用户当前选择了那一项的index
,而级联的不仅需要更新currentValue
还需要更新下一列的内容。
private onChange(obj: any){
const { columnIndex, currentIndex, item } = obj;
let currentValueArr = this.currentValue.split(',');
const currentValue = item[this.defaultKey];
if(this.dataType === 'text' || this.dataType === 'single'){
this.currentValue = currentValue;
this.$emit('change', Object.assign({...item}, {index: currentIndex}));
}else if(this.dataType === 'multi'){
currentValueArr[columnIndex] = currentValue;
this.currentValue = currentValueArr.join(',');
this.$emit('change', item);
}else{
this.onChangeCascade(columnIndex, currentIndex, currentValue);
}
}
我们需要修改一下原先的代码:
private currentValue: string = this.defaultValue;
get defaultValueArr(){
return this.currentValue.split(',');
}
// 原先formatCascade中直接使用的是this.defaultValue获得的defaultValueArr,现在需要改为由currentValue得到的defaultValueArr
private formatCascade(){
const defaultValueArr = this.defaultValueArr;
let defaultValueArrIndex = 0;
let firstColumn: any[] = [];
Object.keys(this.columns).forEach((key: any, index: number) => {
firstColumn.push(this.columns[key]);
})
let formattedColumns = [];
let cursor = firstColumn;
formattedColumns.push(firstColumn);
while(cursor && Array.isArray(cursor) && cursor.length > 0 && cursor[0].children){
let children = this.getChildColumn(cursor, defaultValueArr[defaultValueArrIndex]);
formattedColumns.push(children);
cursor = children;
defaultValueArrIndex++;
}
this.formattedColumns = formattedColumns;
}
// 原先是返回空数组,现在是返回当列的第一项
private getChildColumn(column: any[], key: string): any[]{
key = key.trim();
if(key.length !== 0){
for(let i = 0, len = column.length; i < len; i++){
if(column[i][this.defaultKey] === key){
column = column[i].children ? column[i].children : [];
return column;
}
}
}
return column[0].children;
}
所以由上面的代码可得,级联操作的formattedColumns
的内容是根据currentValue
,故:
private onChangeCascade(columnIndex: number, currentIndex: number, currentValue: string){
// 列表项取决于defaultValueArr,要更改defaultValueArr[columnIndex]等于defaultKey值
let defaultValueArr = this.currentValue.split(',');
if(columnIndex !== this.formattedColumns.length -1){
// 切换某一列,后面的列都要改变,且每一个的值都为某列的第一个
for(let i = columnIndex, len = this.formattedColumns.length; i < len; i++){
if(i === columnIndex ) {
const columns = this.formattedColumns[i];
for(let j = 0, jLen = columns.length; j < jLen; j++){
const value = columns[j][this.defaultKey];
if(value == currentValue) defaultValueArr[columnIndex] = value;
}
}else{
defaultValueArr[i] = '';
}
}
this.currentValue = defaultValueArr.join(',');
this.formatColumns();
} else{
defaultValueArr[columnIndex] = currentValue;
this.currentValue = defaultValueArr.join(',');
}
}
到这里column
组件就完成的差不多了,接下来我们来实现别人家的惯性滑动
。
惯性滑动思路:在手指离开屏幕时,如果和上一次 move 时的间隔小于
MOMENTUM_LIMIT_TIME
且 move 距离大于MOMENTUM_LIMIT_DISTANCE
时,执行惯性滑动
const MOMENTUM_LIMIT_TIME = 300;
const MOMENTUM_LIMIT_DISTANCE = 15;
private touchStartTime: number = 0;
private momentumOffset: number = 0;
public handleTouchStart(event: TouchEvent){
this.touchStart(event);
this.startOffset = this.offset;
this.duration = 0;
this.touchStartTime = Date.now();
this.momentumOffset = this.startOffset;
}
public handleTouchMove(event: TouchEvent){
this.toucheMove(event);
this.offset = range(this.startOffset + this.deltaY, -(this.count * this.itemHeight), this.itemHeight);
const now = Date.now();
if(now - this.touchStartTime > MOMENTUM_LIMIT_TIME) {
this.touchStartTime = now;
this.momentumOffset = this.offset;
}
}
public handleTouchEnd(){
const distance = this.offset - this.momentumOffset;
const duration = Date.now() - this.touchStartTime;
const allMomentum = duration < MOMENTUM_LIMIT_TIME && Math.abs(distance) > MOMENTUM_LIMIT_DISTANCE;
// 惯性滑动思路:
// 在手指离开屏幕时,如果和上一次 move 时的间隔小于 `MOMENTUM_LIMIT_TIME` 且 move
// 距离大于 `MOMENTUM_LIMIT_DISTANCE` 时,执行惯性滑动
if(allMomentum){
this.momentum(distance, duration);
return;
}
const index = this.getIndexByOffset(this.offset);
this.duration = DEFAULT_DURATION;
this.setIndex(index, true);
}
public momentum(distance: number, duration: number){
const speed = Math.abs(distance / duration);
distance = this.offset + (speed / 0.003) * (distance < 0 ? -1 : 1);
const index = this.getIndexByOffset(distance);
this.duration = +1000;
this.setIndex(index, true);
}
<template>
<div class="ar-picker__toolbar" :style="BarStyle">
<div class="ar-picker__cancel" @click="handleCancel"><span :style="Canceltyle">{{cancelText}}span>div>
<div class="ar-picker__title">{{title}}div>
<div class="ar-picker__confirm" @click="handleConfirm"><span :style="ConfirmStyle">{{confirmText}}span>div>
div>
template>
<script lang='ts'>
import { Component, Prop, Vue } from 'vue-property-decorator';
@Component
export default class extends Vue {
@Prop({default: '取消'}) private cancelText!: string;
@Prop({default: '#5e6d82'}) private cancelColor!: string;
@Prop({default: '确认'}) private confirmText!: string;
@Prop({default: '#007bff'}) private confirmColor!: string;
@Prop({default: '标题'}) private title!: string;
@Prop({default: '44'}) private toolBarHeight!: number | string;
get BarStyle(){
return `${this.toolBarHeight ? this.toolBarHeight : 44}px`
}
get ConfirmStyle(){
return {
color: `${this.confirmColor ? this.confirmColor : '#007bff'}`
}
}
get Canceltyle(){
return {
color: `${this.cancelColor ? this.cancelColor : '#007bff'}`
}
}
private handleConfirm(){
this.$emit('comfirm');
}
private handleCancel(){
this.$emit('cancel');
}
}
script>
private onCancel(){
this.$emit('cancel');
}
private onConfirm(){
this.$emit('confirm', {
value: this.currentValue,
});
}
并不是改变offset
时都要触发change
,只有当现在的offset
与上一个offset
不一样或者正在移动结束时才触发。
private moving: boolean = false;
private transitionEndTrigger: any = null;
public handleTouchStart(event: TouchEvent){
// ...
this.transitionEndTrigger = null;
}
public handleTouchMove(event: TouchEvent){
// ...
if(this.direction === 'vertical'){
this.moving = true;
preventDefault(event, true);
}
// ...
}
private setIndex(index: number, emitChange?: boolean){
index = this.adjust(index) || 0;
const offset = -index * this.itemHeight;
const trigger = () => {
if(index !== this.currentIndex){
this.currentIndex = index;
if(emitChange){
this.$emit('change', {
columnIndex: this.columnIndex,
currentIndex: index,
item: this.columnList[index]
});
}
}
}
if(this.moving && offset !== this.offset){
this.transitionEndTrigger = trigger();
}else{
trigger();
}
this.offset = offset;
}
column
的惯性滑动当column
的惯性滑动还没有停止时,你却按下了确定按钮,那么此时你应该停止惯性滑动,并将此刻停留的值告诉用户。
public stopMomentum(){
this.moving = false;
this.duration = 0;
if(this.transitionEndTrigger){
this.transitionEndTrigger();
this.transitionEndTrigger = null;
}
}
怎么在picker
组件中调用column
的stopMomentum
方法?
private onConfirm(){
this.$children.forEach((component: any) => {
if(component.stopMomentum){
component.stopMomentum();
}
})
this.$emit('confirm', {
value: this.currentValue,
});
}
尝试在惯性滑动的过程中点击确认按钮,stopMomentum
确实被调用了3次,但是这时候的this.currentValue
并不准确,可能会出现改变的那一列的后面列的值是空的。
private onConfirm(){
this.$children.forEach((component: any) => {
if(component.stopMomentum){
component.stopMomentum();
}
})
// 级联切换列的时候,会出现空值
if(this.dataType === 'cascade'){
let currentValueArr = this.defaultValueArr;
for(let i = 1, len = currentValueArr.length; i < len; i++){
if(currentValueArr[i] === ''){
currentValueArr[i] = this.formattedColumns[i][0][this.defaultKey];
}
}
this.currentValue = currentValueArr.join(',');
}
this.$emit('confirm', {
value: this.currentValue,
});
}
我又参考vant
开发了一个picker
组件,之前使用picker
组件感觉在返回的值上面并不友好,于是在这次开发中用了自己的思路更改了传入的值的结构以及返回的值,感觉这样比较适合于自己开发。
感谢阅读~