鸿蒙OSUniApp制作自定义的下拉菜单组件(鸿蒙系统适配版)#三方框架 #Uniapp
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/multiplemode: {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-PLUStry {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-PLUSconst systemInfo = uni.getSystemInfoSync();const systemName = systemInfo.osName || '';const systemVersion = systemInfo.osVersion || '';// 鸿蒙系统识别return systemName.toLowerCase().includes('harmony') || (systemName === 'android' && systemVersion.includes('harmony'));// #endifreturn false;
}
2. UI风格适配
鸿蒙系统的设计语言强调简洁、轻盈、自然,需要适配以下UI细节:
- 圆角设计:鸿蒙系统偏好较大的圆角,我们在组件中使用了20rpx的圆角值
- 渐变色:按钮和激活态使用渐变色提升视觉效果
- 阴影效果:适当的阴影增强层次感,但要保持轻盈质感
- 字体适配:使用鸿蒙系统的HarmonyOS Sans字体
- 间距调整:鸿蒙UI通常有更宽松的内边距
3. 交互体验优化
鸿蒙系统注重流畅的交互体验:
- 震动反馈:选择选项时添加轻微震动
- 滚动优化:使用enhanced模式增强滚动性能
- 过渡动画:确保展开/收起有流畅的过渡效果
// 鸿蒙系统震动反馈
const vibrateForHarmony = () => {// #ifdef APP-PLUStry {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-dropdownv-model="selectedPrice":options="priceOptions"placeholder="价格"label-key="label"value-key="value"mode="single"@change="applyFilter"></custom-dropdown><custom-dropdownv-model="selectedBrands":options="brandOptions"placeholder="品牌"label-key="name"value-key="id"mode="multiple"show-search@change="applyFilter"></custom-dropdown><custom-dropdownv-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-dropdownv-model="selectedProvince":options="provinces"placeholder="选择省份"@change="onProvinceChange"></custom-dropdown><custom-dropdownv-model="selectedCity":options="cities"placeholder="选择城市":disabled="!selectedProvince"@change="onCityChange"></custom-dropdown><custom-dropdownv-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开发一个自定义下拉菜单组件,并特别关注了在鸿蒙系统上的适配优化。从组件的基本结构设计,到交互细节的处理,再到在实际应用中的案例展示,希望能给大家提供一些思路。
随着鸿蒙系统的普及,做好相关适配工作将越来越重要。在下拉菜单这样的基础交互组件上,通过一些细节的优化,可以大大提升用户体验,尤其是在华为设备上。
最后,欢迎大家基于这个组件进行二次开发,添加更多功能或者根据自己的业务需求进行定制。如有任何问题或改进建议,也欢迎交流讨论。
参考资源
- UniApp官方文档
- HarmonyOS设计指南
- Vue3官方文档
- CSS Animation完整指南