Why?
级联选择组件在UI
库中很常见,几乎所有的UI
库都支持。但是你会发现几乎所有用于 PC
端的 Cascade
组件都支持对选项多选和搜索,而 Mobile
端的 Cascade
组件,更多的是以 picker
的形式仅支持单选。
局限性大,同时在部分业务场景下,例如表单组件,在 PC
和 Mobile
端的不同呈现兼容,需要我们不得不支持多选这一要求…
如果使用传统的 picker
形式,那么在交互上它不可能实现多选操作,此外如果级联层级过深甚至无法在手机端友好展示,所以我另辟蹊径,参考了目前部分Mobile-UI
库对“地址选择器”的交互效果,即:通过 tab
渲染不同层级实现选项选择。🤨🤨🤨
需要准备什么?
由于我的案例中使用了基础的UI
库组件,所以需要你的项目引入才能使用,例如 tabs、cell、checkbox
…
当然这些基础组件也可以由你自己去封装,为了节约案例功能的实现成本,我这使用的 element 和 vant
使用 vant 是因为项目里移动端用的 vant 库,同时使用 element 是因为 vant 的 checkbox 没有提供半选状态,这个也可以自己实现😅😅😅…
实现思路?
明确需要接受的
props
和向外传播的event
明确
v-model
变量类型和变更条件,例如点击cell
? 还是点击cell-right-slot
?明确当前交互状态,即:用户的每一次点击操作,我们都应该记录下当前操作的选项路径
currentSelectPath
根据上一步的路径,我们可以很轻松的拿到需要渲染的可选层级
selectableOptions
和渲染的tabs
,如果currentSelectPath
为空,则展示根层级即可。至此,我们可以很轻松的实现选项切换这一交互效果和呈现下一步是值绑定,我们需要根据
currentSelectPath
和当前点击的option
推断它的完整路径,并emit:input
实现数据双向绑定上面是传统
Mobile-Cascade
的交互实现,下面开始优化升级添加计算属性,计算每个选项是否被全选、半选,并展示对应的
icon
和被选中样式添加单多选支持,单选时点击叶子节点选中,点击父节点则展开子节点。多选时,仅点击
icon
选中所有叶子节点递归计算已选项的
label
,并用于界面呈现支持
filterable
对级联选项进行搜索选中,交互参考自element
源代码
代码基于 Vue2 ts decorator 语法实现,可根据自己需要,对应调整技术栈.
模板
input 组件用于对已选项进行呈现,你可以切换成 tags。
另外该例子仅对核心功能做实现,如有需要你可以添加一些额外的交互和 props
<template>
<div class="mobile-cascader">
<template v-if="readonly">
<span v-if="selectedLabel">{{ selectedLabel }}</span>
<span>暂无内容</span>
</template>
<template v-else>
<el-input
class="cascade-input"
readonly
:value="selectedLabel"
:placeholder="placeholder"
@focus="showPicker = true">
<template v-slot:suffix>
<i
v-if="selectedLength"
class="el-icon-circle-close"
style="position: relative; top: 4px; right: 4px"
@click="innerValue = []" />
</template>
</el-input>
<van-popup
v-model="showPicker"
:lazy-render="false"
position="bottom"
get-container="body"
round
closeable
class="popover"
:style="{ height: '486px' }">
<template v-if="innerOptions.length">
<div class="title p-16">
{{ title }}
<i class="selected">(已选择 {{ selectedLength }} 项)</i>
</div>
<el-input placeholder="搜索选项" prefix-icon="el-icon-search" v-model="optionFilterKey" />
<SelectorList
v-show="optionFilterKey"
v-model="filterValue"
:options="filterOptions"
:multiple="multiple" />
<van-tabs
v-show="!optionFilterKey"
v-model="activeTab"
class="p-6"
color="#02A7F0"
title-active-color="#02A7F0"
title-inactive-color="#000">
<!-- FIX: value is not unique -->
<van-tab
v-for="(tab, tabIdx) in selectableOptions"
:title="getTabTitle(tab, tabIdx)"
:key="`${getTabPrePath(tabIdx)}_${tabIdx}`">
<van-cell-group class="option-wrapper" :border="false">
<van-cell
v-for="(option, optionIdx) in tab"
:key="`${tabIdx}-${optionIdx}-${option.value}`"
:title="option.label"
:title-class="{ selectedCell: checkSelected(option, tabIdx) }"
clickable
@click="optionClickHandle(option, tabIdx)">
<template v-slot:right-icon>
<el-checkbox
v-if="showableCheckbox(option, tabIdx)"
:value="getOptionSelectedStatus(option, tabIdx).selectAll"
:indeterminate="getOptionSelectedStatus(option, tabIdx).indeterminate"
class="check-box"
@click.native.stop
@change="optionSelectChange(option, tabIdx)" />
<van-icon
name="arrow"
v-if="option.children && option.children.length"
class="arrow-icon"
size="16" />
</template>
</van-cell>
</van-cell-group>
</van-tab>
</van-tabs>
</template>
<van-empty
v-else
description="暂无数据"
:image="require('@/assets/images/empty-image-default.png')" />
</van-popup>
</template>
</div>
</template>
核心逻辑
import type { CascaderOption } from 'element-ui/types/cascader';
import { cloneDeep } from 'lodash';
import { Component, ModelSync, Prop, Vue } from 'vue-property-decorator';]
// 这个是对选项搜索后,展示拉平后的选项列表
import SelectorList from './selectorList.vue';
const ROOT = '_ROOT_';
const SPLIT_CHAR = '|';
@Component({
name: 'MobileCascade',
components: {
SelectorList,
},
})
export default class MobileCascade extends Vue {
@ModelSync('value', 'input', { type: Array }) public innerValue!: string[] | string[][];
@Prop() public readonly innerOptions!: CascaderOption[];
@Prop(String) public readonly placeholder?: string;
@Prop(String) public readonly title?: string;
@Prop(String) public readonly readonly!: boolean;
@Prop({ default: false, type: Boolean }) public readonly multiple!: boolean;
public activeTab = 0;
public showPicker = false;
public optionFilterKey = '';
public currentSelectPath: string[] = [];
/* ---------- filter v-model start --------- */
public get filterValue() {
return this.innerValue2Value(this.innerValue);
}
public set filterValue(v) {
this.innerValue = this.value2InnerValue(v);
}
/* ------ end ---------- */
public get selectedLabel() {
const innerVal = this.multiple ? this.innerValue : [this.innerValue];
return innerVal.map((v) => this.getOptionPathLabel(v as string[]).join('/')).join('、');
}
public get selectedLength() {
return this.multiple ? this.innerValue.length : this.innerValue?.length ? 1 : 0;
}
public get selectableOptions() {
return [ROOT, ...this.currentSelectPath]
.map((p, idx, arr) => this.getOptionChildrenByPath(arr.slice(0, idx + 1)))
.filter((o) => !!o.length);
}
public get filterOptions() {
const flatterOption = this.getFlatterOptions(this.innerOptions);
return flatterOption.filter((o) => o.label.includes(this.optionFilterKey));
}
public mounted() {
this.currentSelectPath = (
this.multiple ? this.innerValue[0] ?? [] : this.innerValue
) as string[];
}
public value2InnerValue(val?: string[]) {
const tv = val?.map((v) => v.split(SPLIT_CHAR)) ?? [];
return this.multiple ? tv : tv[0] ?? [];
}
public innerValue2Value(v: string[] | string[][]) {
const tv = this.multiple ? v : v.length ? [v] : [];
return tv.map((o) => (o as string[]).join(SPLIT_CHAR));
}
public getOptionChildrenByPath(path: string[]) {
return path.reduce(
(prev, current) =>
current === ROOT ? prev : prev.find((o) => o.value === current)?.children ?? [],
this.innerOptions,
);
}
public getOptionPathLabel(path: string[]) {
let options = this.innerOptions;
return path.reduce((labels, val) => {
const opt = options.find((o) => o.value === val);
options = opt?.children ?? [];
return [...labels, opt?.label ?? ''];
}, [] as string[]);
}
public getTabPrePath(tabIdx: number) {
return this.currentSelectPath.slice(0, tabIdx).join(SPLIT_CHAR);
}
public getOptionPath(option: CascaderOption, tabIdx: number) {
const prePath = this.getTabPrePath(tabIdx);
return prePath ? prePath + SPLIT_CHAR + option.value : option.value;
}
public getTabTitle(tab: CascaderOption[], tabIdx: number) {
return tab.find((o) => o.value === this.currentSelectPath[tabIdx])?.label ?? '---';
}
public showableCheckbox(option: CascaderOption, tabIdx: number) {
return (
this.multiple ||
(!option.children?.length && this.getOptionSelectedStatus(option, tabIdx).selectAll)
);
}
public checkSelected(option: CascaderOption, tabIdx: number) {
const optionPath = this.getOptionPath(option, tabIdx);
const value = this.innerValue2Value(this.innerValue);
return value.some((p) => p.startsWith(optionPath));
}
public async setCurrentPath(option: CascaderOption, tabIdx: number, skip = true) {
this.currentSelectPath = this.currentSelectPath.slice(0, tabIdx).concat([option.value]);
// update tab after tabs(dependences currentSelectPath) rerender.
if (option.children?.length && skip) {
await this.$nextTick();
this.activeTab = tabIdx + 1;
}
}
public async optionClickHandle(option: CascaderOption, tabIdx: number) {
await this.setCurrentPath(option, tabIdx);
// single selection and leaf node, click the cell to automatically select
if (!this.multiple && !option.children?.length) {
this.optionSelectChange(option, tabIdx);
}
}
public getOptionSelectedStatus(option: CascaderOption, tabIdx: number) {
const prePath = this.getTabPrePath(tabIdx);
const leafValues = this.findAllLeafNodesPath(option, prePath);
const value = this.innerValue2Value(this.innerValue);
const selectAll = leafValues.every((p) => value.includes(p));
const indeterminate = !selectAll && leafValues.some((p) => value.includes(p));
return { selectAll, indeterminate };
}
// option is a leaf node, it's includes itself. and only one
public findAllLeafNodesPath(root: CascaderOption, prePath = '') {
const values: string[] = [];
const queue: Array<CascaderOption & { prePath: string }> = cloneDeep([{ ...root, prePath }]);
while (queue.length > 0) {
const node = queue.shift()!;
const path = node.prePath ? `${node.prePath}${SPLIT_CHAR}${node.value}` : node.value;
if (node?.children) {
queue.push(...node.children.map((n) => ({ ...n, prePath: path })));
} else {
values.push(path);
}
}
return values;
}
public async optionSelectChange(option: CascaderOption, tabIdx: number) {
if (!this.multiple && !!option.children?.length) {
return;
}
await this.setCurrentPath(option, tabIdx, false);
const prePath = this.getTabPrePath(tabIdx);
const leafValues = this.findAllLeafNodesPath(option, prePath);
let value = this.innerValue2Value(this.innerValue);
const isCheckedAll = leafValues.every((p) => value.includes(p));
const newChecked = !isCheckedAll;
if (!this.multiple) {
// single-selecting,the clicked option is always selected
this.innerValue = this.value2InnerValue(leafValues);
} else {
leafValues.forEach((p) => {
if (newChecked && !value.includes(p)) {
value.push(p);
} else if (!newChecked && value.includes(p)) {
value = value.filter((o) => o !== p);
}
});
this.innerValue = this.value2InnerValue(value);
}
}
public getFlatterOptions(opts: Array<CascaderOption & { preValue?: string; preLabel?: string }>) {
const cloneOpt = cloneDeep(opts);
const options: Array<{ label: string; value: string }> = [];
while (cloneOpt.length > 0) {
const node = cloneOpt.shift()!;
const optVal = node.preValue ? `${node.preValue}${SPLIT_CHAR}${node.value}` : node.value;
const optLabel = node.preLabel ? `${node.preLabel}/${node.label}` : node.label;
if (node.children) {
cloneOpt.push(
...node.children.map((n) => ({ ...n, preValue: optVal, preLabel: optLabel })),
);
} else {
options.push({ label: optLabel, value: optVal });
}
}
return options;
}
}
样式文件
.popover {
.title {
font-size: 16px;
height: 48px;
line-height: 48px;
.selected {
margin-left: 10px;
font-size: 14px;
color: #427daf;
font-style: normal;
}
}
.check-box {
padding: 0 5px 0 10px;
}
.option-wrapper {
height: 350px;
overflow-y: auto;
}
.selectedCell {
color: #409eef;
}
.arrow-icon {
position: absolute;
right: 5px;
top: 14px;
}
}
.p-6 {
padding: 0 6px !important;
}
.p-16 {
padding: 0 16px !important;
}
/deep/.cascade-input {
input {
text-overflow: ellipsis;
}
}
/deep/.van-cell:active {
background: #f2f3f5;
}
/deep/.van-tab {
padding: 0 10px !important;
margin: 0 5px !important;
flex: 0 0 auto !important;
}
SelectorList
组件源码
<template>
<div class="select-group">
<van-checkbox-group>
<van-cell-group>
<van-cell
v-for="item in options"
clickable
:key="item.value"
:title="item.label"
:title-class="{ selectedCell: computeShow(item) }"
@click="handleClick(item)">
<template #right-icon>
<van-checkbox :name="item" ref="checkboxes">
<template #icon>
<van-icon
v-show="computeShow(item)"
name="success"
color="#000"
style="background: #fff; border: none" />
</template>
</van-checkbox>
</template>
</van-cell>
</van-cell-group>
</van-checkbox-group>
</div>
</template>
<script lang="ts">
import { Component, ModelSync, Prop, Vue } from 'vue-property-decorator';
@Component({ name: 'selector-list' })
export default class SelectorList extends Vue {
@ModelSync('value', 'input') public innerValue!: string[];
@Prop() public readonly options?: Array<{ label: string; value: string }>;
@Prop(Boolean) public readonly multiple!: boolean;
public handleClick(opt: { label: string; value: string }) {
let innerVal = this.innerValue ? [...this.innerValue] : [];
if (!this.multiple) {
innerVal = [opt.value];
} else {
const idx = innerVal.findIndex((o) => o === opt.value);
if (idx >= 0) {
innerVal.splice(idx, 1);
} else {
innerVal.push(opt.value);
}
}
this.innerValue = innerVal;
}
public computeShow(opt: { label: string; value: string }) {
return this.innerValue.some((o) => o === opt.value);
}
}
</script>
<style lang="less" scoped>
.select-group {
height: 400px;
display: flex;
flex-direction: column;
overflow: auto;
-webkit-overflow-scrolling: touch;
.selectedCell {
color: #409eef;
}
.van-cell {
font-size: 14px;
height: 35px;
padding: 5px 16px;
}
}
</style>