UniApp制作自定义的下拉菜单组件(鸿蒙系统适配版)
前言
在移动应用开发中,下拉菜单是一个常见且实用的交互组件,它能在有限的屏幕空间内展示更多的选项。虽然各种UI框架都提供了下拉菜单组件,但在一些特定场景下,我们往往需要根据产品需求定制自己的下拉菜单。尤其是在鸿蒙系统逐渐普及的今天,如何让我们的组件在华为设备上有更好的表现,是值得思考的问题。
本文将分享我在实际项目中使用UniApp开发自定义下拉菜单组件的经验,包括基础实现、动画效果以及在鸿蒙系统上的特殊适配。希望能给同样面临这类需求的开发者提供一些参考。
需求分析
在开始编码前,我们先明确一下自定义下拉菜单需要满足的基本需求:
- 支持单选/多选模式
- 可自定义菜单项的样式和内容
- 支持搜索筛选功能
- 展开/收起的流畅动画
- 支持级联选择
- 良好的交互反馈
- 在鸿蒙系统上的适配优化
技术选型
基于上述需求,我选择的技术栈如下:
- UniApp作为跨端开发框架
- Vue3 + TypeScript提供响应式编程体验
- SCSS处理样式
- 使用CSS3实现过渡动画
- 鸿蒙系统特有API支持
组件设计
首先,我们来设计组件的基本结构:
<template>
<view class="custom-dropdown" :class="{'harmony-dropdown': isHarmonyOS}">
<!-- 触发器部分 -->
<view class="dropdown-trigger" @click="toggleDropdown">
<text class="trigger-text">{{ triggerText }}</text>
<view class="trigger-icon" :class="{'is-active': isOpen}">
<text class="iconfont icon-down"></text>
</view>
</view>
<!-- 下拉内容部分 -->
<view
class="dropdown-content"
:class="{'is-open': isOpen}"
:style="contentStyle"
>
<!-- 搜索框 -->
<view class="search-box" v-if="showSearch">
<input
type="text"
v-model="searchText"
placeholder="搜索..."
class="search-input"
confirm-type="search"
@input="handleSearch"
/>
<text
class="clear-icon"
v-if="searchText"
@click.stop="clearSearch"
>×</text>
</view>
<!-- 选项列表 -->
<scroll-view
scroll-y
class="options-list"
:enhanced="isHarmonyOS"
:bounces="false"
>
<view
v-for="(item, index) in filteredOptions"
:key="index"
class="option-item"
:class="{
'is-selected': isSelected(item),
'harmony-item': isHarmonyOS
}"
@click="selectOption(item)"
>
<text class="option-text">{{ item[labelKey] }}</text>
<text
v-if="isSelected(item)"
class="selected-icon iconfont icon-check"
></text>
</view>
<!-- 空状态 -->
<view class="empty-tip" v-if="filteredOptions.length === 0">
<text>无匹配结果</text>
</view>
</scroll-view>
<!-- 操作按钮 -->
<view class="action-btns" v-if="mode === 'multiple'">
<view class="btn btn-clear" @click="clearSelection">清空</view>
<view class="btn btn-confirm" @click="confirmSelection">确定</view>
</view>
</view>
<!-- 遮罩层 -->
<view
class="dropdown-mask"
:class="{'is-visible': isOpen}"
@click="closeDropdown"
></view>
</view>
</template>
<script lang="ts">
import { defineComponent, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { isHarmonyOS } from '@/utils/system';
export default defineComponent({
name: 'CustomDropdown',
props: {
// 选项列表
options: {
type: Array,
default: () => []
},
// 显示的键名
labelKey: {
type: String,
default: 'label'
},
// 值的键名
valueKey: {
type: String,
default: 'value'
},
// 选择模式:single/multiple
mode: {
type: String,
default: 'single'
},
// 是否显示搜索框
showSearch: {
type: Boolean,
default: false
},
// 最大高度
maxHeight: {
type: [String, Number],
default: 300
},
// 触发器文本
placeholder: {
type: String,
default: '请选择'
},
// 默认选中值
modelValue: {
type: [String, Number, Array],
default: ''
}
},
emits: ['update:modelValue', 'change', 'open', 'close'],
setup(props, { emit }) {
// 状态变量
const isOpen = ref(false);
const searchText = ref('');
const selectedOptions = ref<any[]>([]);
const isHarmonyOS = ref(false);
// 计算下拉内容样式
const contentStyle = computed(() => {
const style: any = {};
if (typeof props.maxHeight === 'number') {
style.maxHeight = `${props.maxHeight}px`;
} else {
style.maxHeight = props.maxHeight;
}
return style;
});
// 计算过滤后的选项
const filteredOptions = computed(() => {
if (!searchText.value) return props.options;
return props.options.filter((item: any) => {
const label = item[props.labelKey]?.toString() || '';
return label.toLowerCase().includes(searchText.value.toLowerCase());
});
});
// 计算触发器显示文本
const triggerText = computed(() => {
if (selectedOptions.value.length === 0) {
return props.placeholder;
}
if (props.mode === 'single') {
return selectedOptions.value[0][props.labelKey];
}
if (selectedOptions.value.length === 1) {
return selectedOptions.value[0][props.labelKey];
}
return `已选择${selectedOptions.value.length}项`;
});
// 初始化选中项
const initSelection = () => {
if (!props.modelValue) {
selectedOptions.value = [];
return;
}
if (props.mode === 'single') {
const value = props.modelValue;
const option = props.options.find((item: any) =>
item[props.valueKey] === value
);
selectedOptions.value = option ? [option] : [];
} else {
const values = Array.isArray(props.modelValue) ? props.modelValue : [props.modelValue];
selectedOptions.value = props.options.filter((item: any) =>
values.includes(item[props.valueKey])
);
}
};
// 检查选项是否被选中
const isSelected = (option: any) => {
return selectedOptions.value.some((item: any) =>
item[props.valueKey] === option[props.valueKey]
);
};
// 选择选项
const selectOption = (option: any) => {
if (props.mode === 'single') {
selectedOptions.value = [option];
emitChange();
closeDropdown();
} else {
const index = selectedOptions.value.findIndex((item: any) =>
item[props.valueKey] === option[props.valueKey]
);
if (index > -1) {
selectedOptions.value.splice(index, 1);
} else {
selectedOptions.value.push(option);
}
}
// 鸿蒙系统震动反馈
if (isHarmonyOS.value) {
vibrateForHarmony();
}
};
// 确认多选结果
const confirmSelection = () => {
emitChange();
closeDropdown();
};
// 清空选择
const clearSelection = () => {
selectedOptions.value = [];
if (props.mode === 'single') {
emitChange();
}
};
// 处理搜索
const handleSearch = () => {
// 可以添加防抖逻辑
};
// 清空搜索
const clearSearch = () => {
searchText.value = '';
};
// 切换下拉菜单状态
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
if (isOpen.value) {
emit('open');
} else {
emit('close');
}
};
// 关闭下拉菜单
const closeDropdown = () => {
if (!isOpen.value) return;
isOpen.value = false;
searchText.value = '';
emit('close');
};
// 提交变更
const emitChange = () => {
let value;
if (props.mode === 'single') {
value = selectedOptions.value.length ? selectedOptions.value[0][props.valueKey] : '';
} else {
value = selectedOptions.value.map((item: any) => item[props.valueKey]);
}
emit('update:modelValue', value);
emit('change', {
value,
options: [...selectedOptions.value]
});
};
// 鸿蒙系统震动反馈
const vibrateForHarmony = () => {
// #ifdef APP-PLUS
try {
if (plus.os.name === 'Android' && plus.device.vendor === 'HUAWEI') {
plus.device.vibrate(10);
}
} catch (e) {
console.error('震动反馈失败', e);
}
// #endif
};
// 点击外部关闭
const handleOutsideClick = (e: Event) => {
const target = e.target as HTMLElement;
const dropdown = document.querySelector('.custom-dropdown');
if (dropdown && !dropdown.contains(target)) {
closeDropdown();
}
};
// 监听modelValue变化
watch(() => props.modelValue, () => {
initSelection();
}, { immediate: true });
// 监听options变化
watch(() => props.options, () => {
initSelection();
});
// 组件挂载
onMounted(() => {
isHarmonyOS.value = isHarmonyOS();
initSelection();
// 添加点击外部关闭事件
document.addEventListener('click', handleOutsideClick);
});
// 组件卸载
onBeforeUnmount(() => {
document.removeEventListener('click', handleOutsideClick);
});
return {
isOpen,
searchText,
selectedOptions,
isHarmonyOS,
contentStyle,
filteredOptions,
triggerText,
isSelected,
selectOption,
confirmSelection,
clearSelection,
handleSearch,
clearSearch,
toggleDropdown,
closeDropdown
};
}
});
</script>
<style lang="scss">
.custom-dropdown {
position: relative;
width: 100%;
.dropdown-trigger {
display: flex;
align-items: center;
justify-content: space-between;
height: 80rpx;
padding: 0 20rpx;
background-color: #fff;
border: 1rpx solid #ddd;
border-radius: 8rpx;
.trigger-text {
flex: 1;
font-size: 28rpx;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trigger-icon {
width: 40rpx;
text-align: center;
transition: transform 0.3s;
&.is-active {
transform: rotate(180deg);
}
.iconfont {
font-size: 24rpx;
color: #666;
}
}
}
.dropdown-content {
position: absolute;
top: 90rpx;
left: 0;
width: 100%;
background-color: #fff;
border: 1rpx solid #eee;
border-radius: 8rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
z-index: 100;
overflow: hidden;
max-height: 0;
opacity: 0;
transform: translateY(-10rpx);
transition: all 0.3s ease-out;
&.is-open {
max-height: var(--dropdown-max-height, 600rpx);
opacity: 1;
transform: translateY(0);
}
.search-box {
position: relative;
padding: 16rpx;
border-bottom: 1rpx solid #eee;
.search-input {
width: 100%;
height: 64rpx;
padding: 0 60rpx 0 20rpx;
background-color: #f5f5f5;
border: none;
border-radius: 32rpx;
font-size: 26rpx;
}
.clear-icon {
position: absolute;
right: 36rpx;
top: 50%;
transform: translateY(-50%);
width: 40rpx;
height: 40rpx;
line-height: 40rpx;
text-align: center;
font-size: 32rpx;
color: #999;
}
}
.options-list {
max-height: 400rpx;
.option-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx;
border-bottom: 1rpx solid #f5f5f5;
&:active {
background-color: #f9f9f9;
}
&.is-selected {
background-color: #f0f9ff;
.option-text {
color: #0078ff;
font-weight: bold;
}
.selected-icon {
color: #0078ff;
}
}
.option-text {
flex: 1;
font-size: 28rpx;
color: #333;
}
.selected-icon {
font-size: 32rpx;
margin-left: 10rpx;
}
}
.empty-tip {
padding: 40rpx 0;
text-align: center;
color: #999;
font-size: 26rpx;
}
}
.action-btns {
display: flex;
padding: 16rpx;
border-top: 1rpx solid #eee;
.btn {
flex: 1;
height: 70rpx;
line-height: 70rpx;
text-align: center;
font-size: 28rpx;
border-radius: 35rpx;
&.btn-clear {
color: #666;
background-color: #f5f5f5;
margin-right: 10rpx;
}
&.btn-confirm {
color: #fff;
background-color: #0078ff;
margin-left: 10rpx;
}
}
}
}
.dropdown-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0);
z-index: 99;
pointer-events: none;
transition: background-color 0.3s;
&.is-visible {
background-color: rgba(0, 0, 0, 0.4);
pointer-events: auto;
}
}
}
/* 鸿蒙系统特有样式 */
.harmony-dropdown {
.dropdown-trigger {
border-radius: 16rpx;
border: none;
background-color: #f5f7fa;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
.trigger-text {
font-family: 'HarmonyOS Sans', sans-serif;
}
}
.dropdown-content {
border-radius: 20rpx;
border: none;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
.search-box {
padding: 24rpx 20rpx 16rpx;
.search-input {
background-color: #f5f7fa;
border-radius: 20rpx;
height: 72rpx;
}
}
.options-list {
.option-item {
&.harmony-item {
padding: 24rpx 20rpx;
&.is-selected {
background: linear-gradient(to right, #f0f7ff, #f5faff);
.option-text {
background: linear-gradient(to right, #0078ff, #0092ff);
-webkit-background-clip: text;
color: transparent;
}
}
&:active {
background-color: #f7f9fc;
}
}
}
}
.action-btns {
padding: 20rpx;
.btn {
border-radius: 20rpx;
height: 80rpx;
line-height: 80rpx;
font-family: 'HarmonyOS Sans', sans-serif;
&.btn-clear {
background-color: #f5f7fa;
}
&.btn-confirm {
background: linear-gradient(to right, #0078ff, #0092ff);
box-shadow: 0 4rpx 16rpx rgba(0, 120, 255, 0.3);
}
}
}
}
}
</style>
鸿蒙系统适配关键点
在为鸿蒙系统适配我们的下拉菜单组件时,需要特别注意以下几点:
1. 检测鸿蒙系统
首先,我们需要一个工具函数来检测当前设备是否运行鸿蒙系统:
// utils/system.ts
/**
* 检测当前设备是否为鸿蒙系统
*/
export function isHarmonyOS(): boolean {
// #ifdef APP-PLUS
const systemInfo = uni.getSystemInfoSync();
const systemName = systemInfo.osName || '';
const systemVersion = systemInfo.osVersion || '';
// 鸿蒙系统识别
return systemName.toLowerCase().includes('harmony') ||
(systemName === 'android' && systemVersion.includes('harmony'));
// #endif
return false;
}
2. UI风格适配
鸿蒙系统的设计语言强调简洁、轻盈、自然,需要适配以下UI细节:
- 圆角设计:鸿蒙系统偏好较大的圆角,我们在组件中使用了20rpx的圆角值
- 渐变色:按钮和激活态使用渐变色提升视觉效果
- 阴影效果:适当的阴影增强层次感,但要保持轻盈质感
- 字体适配:使用鸿蒙系统的HarmonyOS Sans字体
- 间距调整:鸿蒙UI通常有更宽松的内边距
3. 交互体验优化
鸿蒙系统注重流畅的交互体验:
- 震动反馈:选择选项时添加轻微震动
- 滚动优化:使用enhanced模式增强滚动性能
- 过渡动画:确保展开/收起有流畅的过渡效果
// 鸿蒙系统震动反馈
const vibrateForHarmony = () => {
// #ifdef APP-PLUS
try {
if (plus.os.name === 'Android' && plus.device.vendor === 'HUAWEI') {
plus.device.vibrate(10); // 非常轻微的震动,提供触觉反馈
}
} catch (e) {
console.error('震动反馈失败', e);
}
// #endif
};
实际应用案例
案例一:筛选条件下拉菜单
在一个电商App的商品列表页中,我们使用了自定义下拉菜单组件来实现筛选功能。用户可以通过下拉菜单选择价格区间、品牌、尺寸等筛选条件。
<template>
<view class="filter-bar">
<custom-dropdown
v-model="selectedPrice"
:options="priceOptions"
placeholder="价格"
label-key="label"
value-key="value"
mode="single"
@change="applyFilter"
></custom-dropdown>
<custom-dropdown
v-model="selectedBrands"
:options="brandOptions"
placeholder="品牌"
label-key="name"
value-key="id"
mode="multiple"
show-search
@change="applyFilter"
></custom-dropdown>
<custom-dropdown
v-model="selectedSort"
:options="sortOptions"
placeholder="排序"
@change="applyFilter"
></custom-dropdown>
</view>
</template>
<script>
import CustomDropdown from '@/components/CustomDropdown.vue';
export default {
components: {
CustomDropdown
},
data() {
return {
selectedPrice: '',
selectedBrands: [],
selectedSort: 'default',
priceOptions: [
{ label: '全部', value: '' },
{ label: '0-100元', value: '0-100' },
{ label: '100-300元', value: '100-300' },
{ label: '300-500元', value: '300-500' },
{ label: '500元以上', value: '500-' }
],
brandOptions: [
{ name: '华为', id: 'huawei' },
{ name: '小米', id: 'xiaomi' },
{ name: '苹果', id: 'apple' },
{ name: '三星', id: 'samsung' },
{ name: 'OPPO', id: 'oppo' },
{ name: 'vivo', id: 'vivo' }
],
sortOptions: [
{ label: '默认排序', value: 'default' },
{ label: '价格从低到高', value: 'price-asc' },
{ label: '价格从高到低', value: 'price-desc' },
{ label: '销量优先', value: 'sales-desc' },
{ label: '评分优先', value: 'rating-desc' }
]
};
},
methods: {
applyFilter() {
// 应用筛选条件
this.$emit('filter-change', {
price: this.selectedPrice,
brands: this.selectedBrands,
sort: this.selectedSort
});
}
}
};
</script>
案例二:级联选择器
我们还使用自定义下拉菜单组件实现了地址选择的级联选择器,用户可以依次选择省、市、区。
<template>
<view class="address-selector">
<custom-dropdown
v-model="selectedProvince"
:options="provinces"
placeholder="选择省份"
@change="onProvinceChange"
></custom-dropdown>
<custom-dropdown
v-model="selectedCity"
:options="cities"
placeholder="选择城市"
:disabled="!selectedProvince"
@change="onCityChange"
></custom-dropdown>
<custom-dropdown
v-model="selectedDistrict"
:options="districts"
placeholder="选择区县"
:disabled="!selectedCity"
@change="onDistrictChange"
></custom-dropdown>
</view>
</template>
<script>
import { defineComponent, ref, watch } from 'vue';
import CustomDropdown from '@/components/CustomDropdown.vue';
import { fetchProvinces, fetchCities, fetchDistricts } from '@/api/address';
export default defineComponent({
components: {
CustomDropdown
},
emits: ['change'],
setup(props, { emit }) {
const selectedProvince = ref('');
const selectedCity = ref('');
const selectedDistrict = ref('');
const provinces = ref([]);
const cities = ref([]);
const districts = ref([]);
// 加载省份数据
const loadProvinces = async () => {
try {
provinces.value = await fetchProvinces();
} catch (error) {
console.error('加载省份失败', error);
}
};
// 加载城市数据
const loadCities = async (provinceId) => {
if (!provinceId) {
cities.value = [];
return;
}
try {
cities.value = await fetchCities(provinceId);
} catch (error) {
console.error('加载城市失败', error);
}
};
// 加载区县数据
const loadDistricts = async (cityId) => {
if (!cityId) {
districts.value = [];
return;
}
try {
districts.value = await fetchDistricts(cityId);
} catch (error) {
console.error('加载区县失败', error);
}
};
// 省份变化
const onProvinceChange = () => {
selectedCity.value = '';
selectedDistrict.value = '';
loadCities(selectedProvince.value);
emitChange();
};
// 城市变化
const onCityChange = () => {
selectedDistrict.value = '';
loadDistricts(selectedCity.value);
emitChange();
};
// 区县变化
const onDistrictChange = () => {
emitChange();
};
// 发送变化事件
const emitChange = () => {
emit('change', {
province: selectedProvince.value,
city: selectedCity.value,
district: selectedDistrict.value
});
};
// 初始化
onMounted(() => {
loadProvinces();
});
return {
selectedProvince,
selectedCity,
selectedDistrict,
provinces,
cities,
districts,
onProvinceChange,
onCityChange,
onDistrictChange
};
}
});
</script>
常见问题与解决方案
在开发和使用这个组件的过程中,我遇到了一些常见问题,分享解决方案:
1. 下拉菜单被裁剪问题
问题:当下拉菜单位于页面底部时,展开的内容可能会被裁剪。
解决方案:计算剩余空间,动态调整下拉方向:
const adjustDropdownPosition = () => {
const triggerEl = triggerRef.value;
const contentEl = contentRef.value;
if (!triggerEl || !contentEl) return;
// 获取触发器位置信息
const rect = triggerEl.getBoundingClientRect();
// 视窗高度
const viewHeight = window.innerHeight;
// 触发器底部到视窗底部的距离
const spaceBelow = viewHeight - rect.bottom;
// 内容高度
const contentHeight = contentEl.offsetHeight;
// 如果下方空间不足,向上展开
if (spaceBelow < contentHeight && rect.top > contentHeight) {
dropdownDirection.value = 'up';
} else {
dropdownDirection.value = 'down';
}
};
2. 多个下拉菜单同时打开问题
问题:当页面中有多个下拉菜单时,打开一个菜单,其他已打开的菜单应该自动关闭。
解决方案:使用全局事件总线管理下拉菜单的打开状态:
// 全局事件总线
const emitter = mitt();
// 打开下拉菜单
const openDropdown = () => {
// 通知其他下拉菜单关闭
emitter.emit('dropdown-open', dropdownId.value);
isOpen.value = true;
emit('open');
};
onMounted(() => {
// 监听其他下拉菜单打开事件
emitter.on('dropdown-open', (id) => {
if (id !== dropdownId.value && isOpen.value) {
isOpen.value = false;
emit('close');
}
});
});
onBeforeUnmount(() => {
emitter.off('dropdown-open');
});
3. 在鸿蒙系统上的滚动卡顿问题
问题:在某些华为设备上,下拉菜单内容滚动不够流畅。
解决方案:开启硬件加速和使用Native View:
<scroll-view
scroll-y
class="options-list"
:enhanced="isHarmonyOS"
:show-scrollbar="false"
:fast-deceleration="isHarmonyOS"
:bounces="false"
>
同时,对滚动容器添加硬件加速样式:
.options-list {
transform: translateZ(0);
-webkit-overflow-scrolling: touch;
will-change: scroll-position;
}
总结
通过本文,我们详细介绍了如何使用UniApp开发一个自定义下拉菜单组件,并特别关注了在鸿蒙系统上的适配优化。从组件的基本结构设计,到交互细节的处理,再到在实际应用中的案例展示,希望能给大家提供一些思路。
随着鸿蒙系统的普及,做好相关适配工作将越来越重要。在下拉菜单这样的基础交互组件上,通过一些细节的优化,可以大大提升用户体验,尤其是在华为设备上。
最后,欢迎大家基于这个组件进行二次开发,添加更多功能或者根据自己的业务需求进行定制。如有任何问题或改进建议,也欢迎交流讨论。