在 Flutter 开发中,下拉选择器是表单填写、条件筛选、数据选择等场景的高频组件。原生DropdownButton仅支持基础单选,多选和搜索筛选需手动实现,存在样式定制难、交互体验差、适配场景有限等问题。
本文封装的CommonDropdown 通用下拉选择器,整合「单选 / 多选切换、内置搜索筛选、全样式自定义、轻量无依赖」四大核心能力,适配表单、筛选等高频业务场景,一行代码即可集成,兼顾易用性与灵活性。
一、核心优势(精准解决开发痛点)
| 核心能力 | 解决痛点 | 核心价值 |
|---|---|---|
| 🎯 单选 / 多选一键切换 | 原生仅支持单选,多选需手动封装 | 通过isMultiple参数一键切换,适配表单单选、筛选多选等不同场景 |
| 🔍 内置搜索筛选 | 选项过多时查找困难 | 支持关键词模糊搜索(不区分大小写),快速定位目标选项 |
| 🎨 全维度样式自定义 | 原生样式难以适配产品视觉规范 | 支持自定义高度、背景色、圆角、边框,贴合 APP 设计风格 |
| 🚀 轻量无依赖 | 第三方组件体积大、依赖复杂 | 仅依赖 Flutter 核心库,无额外依赖,打包体积小、性能优 |
| 📋 表单友好适配 | 选中文本溢出、提示文案不统一 | 自动处理选中文本省略、空值提示,适配表单填写场景 |
| ✨ 交互体验优化 | 搜索框无清空按钮、下拉菜单高度失控 | 搜索框带清除按钮、下拉列表限制最大高度,避免超出屏幕 |
| 🌙 深色模式适配 | 深色模式下样式冲突,适配繁琐 | 一键开启深色模式适配,自动切换文本 / 背景 / 边框色 |
| ⏳ 异步加载支持 | 异步选项加载无状态提示 | 内置加载 / 空状态,适配接口异步获取选项场景 |
二、核心配置速览(关键参数一目了然)
| 配置参数 | 类型 | 默认值 | 核心作用 | 适用场景 |
|---|---|---|---|---|
| options | List<String> | -(必传) | 下拉选项列表(不可为空) | 所有场景 |
| value | dynamic | null | 当前选中值(单选:String;多选:List<String>) | 所有场景 |
| onChanged | ValueChanged<dynamic> | -(必传) | 选择回调(返回选中值,类型与isMultiple匹配) | 所有场景 |
| isMultiple | bool | false | 是否开启多选模式 | 表单(单选)/ 筛选(多选) |
| enableSearch | bool | true | 是否启用搜索筛选功能 | 选项 > 5 条时建议开启 |
| hintText | String | "请选择" | 未选择时的提示文本 | 表单填写场景 |
| height | double | 44 | 选择器主体高度 | 适配不同布局高度需求 |
| bgColor | Color | Colors.white | 选择器背景色 | 全局样式统一 |
| borderRadius | double | 8 | 选择器 / 下拉菜单圆角半径 | 视觉风格定制 |
| borderColor | Color | Color(0xFFE5E5E5) | 边框颜色 | 全局样式统一 |
| textStyle | TextStyle | 16 号黑色 | 选中 / 选项文本样式 | 字体 / 颜色定制 |
| hintStyle | TextStyle | 16 号灰色 | 提示文本样式 | 表单提示风格 |
| adaptDarkMode | bool | false | 是否适配深色模式 | 支持深色模式的 APP |
| darkBgColor | Color | Color(0xFF2C2C2C) | 深色模式背景色 | 深色模式适配 |
| darkBorderColor | Color | Color(0xFF444444) | 深色模式边框色 | 深色模式适配 |
| isLoading | bool | false | 是否显示加载状态 | 异步加载选项场景 |
三、生产级完整代码(可直接复制)
dart
import 'package:flutter/material.dart'; /// 通用下拉选择器(支持单选/多选、搜索筛选、全样式自定义、深色模式适配) class CommonDropdown extends StatefulWidget { // 必选核心参数 final List<String> options; // 选项列表(不可为空) final ValueChanged<dynamic> onChanged; // 选择回调 // 选中值(单选:String;多选:List<String>) final dynamic value; // 功能配置 final bool isMultiple; // 是否多选 final bool enableSearch; // 是否启用搜索 final String hintText; // 未选择提示文本 final bool adaptDarkMode; // 是否适配深色模式 final bool isLoading; // 是否加载中(异步选项场景) // 样式配置 final double height; // 选择器高度 final Color bgColor; // 背景色 final double borderRadius; // 圆角半径 final Color borderColor; // 边框颜色 final TextStyle textStyle; // 文本样式 final TextStyle hintStyle; // 提示文本样式 // 深色模式样式配置 final Color darkBgColor; // 深色模式背景色 final Color darkBorderColor; // 深色模式边框色 final TextStyle darkTextStyle; // 深色模式文本样式 final TextStyle darkHintStyle; // 深色模式提示文本样式 const CommonDropdown({ super.key, required this.options, required this.onChanged, this.value, this.isMultiple = false, this.enableSearch = true, this.hintText = "请选择", this.height = 44, this.bgColor = Colors.white, this.borderRadius = 8, this.borderColor = const Color(0xFFE5E5E5), this.textStyle = const TextStyle(fontSize: 16, color: Color(0xFF333333)), this.hintStyle = const TextStyle(fontSize: 16, color: Color(0xFF999999)), this.adaptDarkMode = false, this.darkBgColor = const Color(0xFF2C2C2C), this.darkBorderColor = const Color(0xFF444444), this.darkTextStyle = const TextStyle(fontSize: 16, color: Color(0xFFE5E5E5)), this.darkHintStyle = const TextStyle(fontSize: 16, color: Color(0xFF777777)), this.isLoading = false, }) : assert(options.isNotEmpty || isLoading, "【CommonDropdown】选项列表不可为空(加载状态除外)!"); @override State<CommonDropdown> createState() => _CommonDropdownState(); } class _CommonDropdownState extends State<CommonDropdown> { // 搜索控制器(带防抖) final TextEditingController _searchController = TextEditingController(); // 筛选后的选项列表 late List<String> _filteredOptions; // 焦点节点(控制搜索框键盘) final FocusNode _searchFocusNode = FocusNode(); // 防抖定时器 Timer? _debounceTimer; @override void initState() { super.initState(); // 初始化筛选列表为原始选项 _filteredOptions = List.from(widget.options); // 监听搜索框清空(优化交互) _searchController.addListener(_onSearchTextChanged); } @override void didUpdateWidget(covariant CommonDropdown oldWidget) { super.didUpdateWidget(oldWidget); // 原始选项变化时,重置筛选列表和搜索框 if (widget.options != oldWidget.options) { _filteredOptions = List.from(widget.options); _searchController.clear(); setState(() {}); } // 选中值变化时刷新UI if (widget.value != oldWidget.value) { setState(() {}); } // 加载状态变化时刷新 if (widget.isLoading != oldWidget.isLoading) { setState(() {}); } } @override void dispose() { // 资源释放:避免内存泄漏 _searchController.dispose(); _searchFocusNode.dispose(); _debounceTimer?.cancel(); super.dispose(); } /// 检查是否为深色模式 bool _isDarkMode() { if (!widget.adaptDarkMode) return false; return MediaQuery.platformBrightnessOf(context) == Brightness.dark; } /// 搜索文本变化监听(清空时重置筛选) void _onSearchTextChanged() { if (_searchController.text.isEmpty) { _filterOptions(""); } } /// 筛选选项(防抖+不区分大小写) void _filterOptions(String keyword) { // 防抖处理:300ms内多次输入仅执行最后一次 _debounceTimer?.cancel(); _debounceTimer = Timer(const Duration(milliseconds: 300), () { if (keyword.isEmpty) { _filteredOptions = List.from(widget.options); } else { _filteredOptions = widget.options .where((option) => option.toLowerCase().contains(keyword.toLowerCase())) .toList(); } if (mounted) { setState(() {}); } }); } /// 构建选中文本(处理单选/多选、空值、溢出) String _buildSelectedText() { // 加载中显示占位 if (widget.isLoading) { return "加载中..."; } // 空值显示提示文本 if (widget.value == null) { return widget.hintText; } // 多选模式:拼接选中项(超出1行自动省略) if (widget.isMultiple) { final List<String> selectedList = widget.value is List<String> ? List.from(widget.value) : []; if (selectedList.isEmpty) { return widget.hintText; } return selectedList.join("、"); } // 单选模式:直接显示选中值 return widget.value.toString(); } /// 检查选项是否被选中(兼容单选/多选) bool _isOptionSelected(String option) { if (widget.value == null) return false; if (widget.isMultiple) { final List<String> selectedList = widget.value is List<String> ? List.from(widget.value) : []; return selectedList.contains(option); } else { return widget.value.toString() == option; } } /// 全选/取消全选逻辑 void _toggleSelectAll() { final List<String> allOptions = List.from(widget.options); final List<String> currentSelected = widget.value is List<String> ? List.from(widget.value) : []; if (currentSelected.length == allOptions.length) { // 取消全选 widget.onChanged([]); } else { // 全选 widget.onChanged(allOptions); } } /// 显示下拉菜单 void _showDropdown() { // 加载中不响应点击 if (widget.isLoading) return; // 打开下拉前重置搜索框和筛选列表 _searchController.clear(); _filterOptions(""); showModalBottomSheet( context: context, isScrollControlled: false, // 避免键盘顶起布局 shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(widget.borderRadius), ), ), backgroundColor: _isDarkMode() ? widget.darkBgColor : widget.bgColor, builder: (context) => Container( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 搜索框(可选) if (widget.enableSearch) TextField( controller: _searchController, focusNode: _searchFocusNode, decoration: InputDecoration( hintText: "搜索选项", hintStyle: _isDarkMode() ? widget.darkHintStyle.copyWith(fontSize: 14) : widget.hintStyle.copyWith(fontSize: 14), border: OutlineInputBorder( borderRadius: BorderRadius.circular(widget.borderRadius), borderSide: BorderSide( color: _isDarkMode() ? widget.darkBorderColor : widget.borderColor, ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(widget.borderRadius), borderSide: BorderSide( color: _isDarkMode() ? widget.darkBorderColor : widget.borderColor, ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(widget.borderRadius), borderSide: const BorderSide(color: Color(0xFF0066FF)), ), suffixIcon: _searchController.text.isNotEmpty ? IconButton( icon: Icon( Icons.clear, color: _isDarkMode() ? widget.darkHintStyle.color : const Color(0xFF999999), ), onPressed: () { _searchController.clear(); _filterOptions(""); }, ) : Icon( Icons.search, color: _isDarkMode() ? widget.darkHintStyle.color : const Color(0xFF999999), ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), onChanged: _filterOptions, autofocus: true, style: _isDarkMode() ? widget.darkTextStyle.copyWith(fontSize: 14) : widget.textStyle.copyWith(fontSize: 14), ), if (widget.enableSearch) const SizedBox(height: 16), // 多选模式:全选/取消全选按钮 if (widget.isMultiple && widget.options.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 8), child: Align( alignment: Alignment.centerRight, child: TextButton( onPressed: _toggleSelectAll, style: TextButton.styleFrom( padding: EdgeInsets.zero, minimumSize: const Size(40, 24), ), child: Text( widget.value is List<String> && (widget.value as List<String>).length == widget.options.length ? "取消全选" : "全选", style: const TextStyle( color: Color(0xFF0066FF), fontSize: 14, ), ), ), ), ), // 选项列表(限制最大高度,避免超出屏幕) ConstrainedBox( constraints: const BoxConstraints(maxHeight: 300), child: widget.isLoading ? // 加载状态 const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 24), child: CircularProgressIndicator(strokeWidth: 2), ), ) : _filteredOptions.isEmpty ? // 无匹配选项提示 Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Text( "暂无匹配选项", style: _isDarkMode() ? widget.darkHintStyle.copyWith(fontSize: 14) : const TextStyle(color: Color(0xFF999999), fontSize: 14), ), ), ) : // 选项列表 ListView.builder( shrinkWrap: true, physics: const ClampingScrollPhysics(), // 避免滚动冲突 itemCount: _filteredOptions.length, itemBuilder: (context, index) { final option = _filteredOptions[index]; final isSelected = _isOptionSelected(option); final currentTextStyle = _isDarkMode() ? widget.darkTextStyle : widget.textStyle; return InkWell( onTap: () { // 处理选择逻辑 if (widget.isMultiple) { final List<String> newValues = widget.value is List<String> ? List.from(widget.value) : []; if (newValues.contains(option)) { newValues.remove(option); } else { newValues.add(option); } widget.onChanged(newValues); } else { widget.onChanged(option); Navigator.pop(context); // 单选后直接关闭 } }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( option, style: currentTextStyle.copyWith( color: isSelected ? const Color(0xFF0066FF) : currentTextStyle.color, fontSize: 15, ), ), if (isSelected) const Icon( Icons.check, color: Color(0xFF0066FF), size: 20, ), ], ), ), ); }, ), ), // 多选模式:底部操作按钮(确认/取消) if (widget.isMultiple && _filteredOptions.isNotEmpty && !widget.isLoading) Padding( padding: const EdgeInsets.only(top: 16), child: Row( children: [ Expanded( child: TextButton( onPressed: () => Navigator.pop(context), style: TextButton.styleFrom( backgroundColor: _isDarkMode() ? const Color(0xFF3A3A3A) : const Color(0xFFF5F5F5), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(widget.borderRadius), ), padding: const EdgeInsets.symmetric(vertical: 12), ), child: Text( "取消", style: TextStyle( color: _isDarkMode() ? const Color(0xFFCCCCCC) : const Color(0xFF666666), fontSize: 15, ), ), ), ), const SizedBox(width: 12), Expanded( child: TextButton( onPressed: () => Navigator.pop(context), style: TextButton.styleFrom( backgroundColor: const Color(0xFF0066FF), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(widget.borderRadius), ), padding: const EdgeInsets.symmetric(vertical: 12), ), child: const Text( "确认", style: TextStyle(color: Colors.white, fontSize: 15), ), ), ), ], ), ), ], ), ), ); } @override Widget build(BuildContext context) { final isDark = _isDarkMode(); final currentBgColor = isDark ? widget.darkBgColor : widget.bgColor; final currentBorderColor = isDark ? widget.darkBorderColor : widget.borderColor; final currentTextStyle = isDark ? widget.darkTextStyle : widget.textStyle; final currentHintStyle = isDark ? widget.darkHintStyle : widget.hintStyle; return InkWell( onTap: _showDropdown, borderRadius: BorderRadius.circular(widget.borderRadius), child: Container( height: widget.height, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( color: currentBgColor, border: Border.all(color: currentBorderColor), borderRadius: BorderRadius.circular(widget.borderRadius), ), alignment: Alignment.centerLeft, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // 选中文本(处理溢出) Expanded( child: Text( _buildSelectedText(), style: widget.value == null || widget.isLoading ? currentHintStyle : currentTextStyle, maxLines: 1, overflow: TextOverflow.ellipsis, // 文本溢出时省略 ), ), // 下拉箭头 Padding( padding: const EdgeInsets.only(left: 8), child: Icon( Icons.arrow_drop_down, color: currentHintStyle.color, size: 20, ), ), ], ), ), ); } }四、五大高频场景实战示例(灵活适配不同需求)
场景 1:表单单选(性别选择,无搜索)
适用场景:用户注册 / 信息填写表单中的性别选择,无需搜索功能实现要点:关闭搜索、单选模式、自定义提示文本、适配表单样式
dart
class FormGenderSelect extends StatefulWidget { @override State<FormGenderSelect> createState() => _FormGenderSelectState(); } class _FormGenderSelectState extends State<FormGenderSelect> { String? _selectedGender; // 单选值:String类型 @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: CommonDropdown( options: ["男", "女", "保密"], value: _selectedGender, onChanged: (value) => setState(() => _selectedGender = value), isMultiple: false, // 单选模式 enableSearch: false, // 关闭搜索 hintText: "请选择性别", borderColor: const Color(0xFFE6E6E6), bgColor: const Color(0xFFFAFAFA), adaptDarkMode: true, // 适配深色模式 ), ); } }场景 2:筛选多选(爱好选择,带搜索 + 全选)
适用场景:个人中心 / 筛选页面的爱好选择,支持多选和搜索实现要点:开启多选、保留搜索、自定义样式、全选功能
dart
class HobbySelect extends StatefulWidget { @override State<HobbySelect> createState() => _HobbySelectState(); } class _HobbySelectState extends State<HobbySelect> { List<String> _selectedHobbies = []; // 多选值:List<String>类型 @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: CommonDropdown( options: ["读书", "运动", "旅游", "摄影", "音乐", "绘画", "美食", "游戏"], value: _selectedHobbies, onChanged: (value) => setState(() => _selectedHobbies = value), isMultiple: true, // 多选模式 enableSearch: true, // 开启搜索 hintText: "请选择爱好(可多选)", borderRadius: 12, bgColor: Colors.white, borderColor: const Color(0xFF0066FF).withOpacity(0.1), adaptDarkMode: true, ), ); } }场景 3:城市选择(单选 + 搜索,异步加载)
适用场景:地址填写 / 定位页面的城市选择,选项多需搜索且异步加载实现要点:单选模式、开启搜索、异步加载状态、适配大量选项
dart
class CitySelect extends StatefulWidget { @override State<CitySelect> createState() => _CitySelectState(); } class _CitySelectState extends State<CitySelect> { String? _selectedCity; List<String> _cityList = []; bool _isLoading = true; @override void initState() { super.initState(); // 模拟接口请求加载城市列表 _loadCityList(); } Future<void> _loadCityList() async { try { await Future.delayed(const Duration(seconds: 1)); // 模拟接口延迟 setState(() { _cityList = [ "北京", "上海", "广州", "深圳", "杭州", "成都", "重庆", "西安", "南京", "武汉", "长沙", "郑州", "青岛", "厦门", "宁波", "苏州", "天津", "沈阳", "长春", "哈尔滨", "石家庄", "太原", "济南", "合肥" ]; _isLoading = false; }); } catch (e) { debugPrint("加载城市列表失败:$e"); setState(() => _isLoading = false); } } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: CommonDropdown( options: _cityList, value: _selectedCity, onChanged: (value) => setState(() => _selectedCity = value), isMultiple: false, enableSearch: true, // 开启搜索(关键) hintText: "请选择城市", height: 48, textStyle: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A)), isLoading: _isLoading, // 异步加载状态 adaptDarkMode: true, ), ); } }场景 4:品类筛选(多选 + 自定义胶囊样式)
适用场景:电商 APP 商品筛选,多选品类且自定义视觉风格实现要点:多选模式、自定义文本 / 边框样式、胶囊圆角、适配深色背景
dart
class CategoryFilter extends StatefulWidget { @override State<CategoryFilter> createState() => _CategoryFilterState(); } class _CategoryFilterState extends State<CategoryFilter> { List<String> _selectedCategories = []; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: CommonDropdown( options: ["数码", "服装", "食品", "家居", "美妆", "图书", "家电", "运动"], value: _selectedCategories, onChanged: (value) => setState(() => _selectedCategories = value), isMultiple: true, enableSearch: true, hintText: "请选择品类", bgColor: const Color(0xFFF0F7FF), borderColor: const Color(0xFF0066FF), textStyle: const TextStyle(color: Color(0xFF0066FF), fontSize: 16), hintStyle: const TextStyle(color: Color(0xFF6699FF), fontSize: 16), borderRadius: 22, // 胶囊样式 height: 44, adaptDarkMode: true, darkBgColor: const Color(0xFF1A2B47), darkBorderColor: const Color(0xFF3385FF), ), ); } }场景 5:表单验证(结合 Flutter Form)
适用场景:注册 / 提交表单,需验证下拉选择器必填项实现要点:结合FormField、验证逻辑、错误提示
dart
class RegisterForm extends StatefulWidget { @override State<RegisterForm> createState() => _RegisterFormState(); } class _RegisterFormState extends State<RegisterForm> { final _formKey = GlobalKey<FormState>(); String? _selectedGender; @override Widget build(BuildContext context) { return Form( key: _formKey, child: Column( children: [ // 性别选择(带表单验证) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: FormField<String>( validator: (value) => value == null ? "请选择性别" : null, builder: (field) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ CommonDropdown( options: ["男", "女", "保密"], value: _selectedGender, onChanged: (value) { setState(() => _selectedGender = value); field.didChange(value); }, isMultiple: false, enableSearch: false, hintText: "请选择性别", adaptDarkMode: true, ), if (field.hasError) Padding( padding: const EdgeInsets.only(top: 4, left: 16), child: Text( field.errorText!, style: const TextStyle(color: Colors.red, fontSize: 12), ), ), ], ), ), ), // 提交按钮 ElevatedButton( onPressed: () { if (_formKey.currentState!.validate()) { // 表单验证通过,提交数据 debugPrint("性别:$_selectedGender"); } }, child: const Text("提交"), ), ], ), ); } }五、工程化最佳实践(提升项目可维护性)
1. 全局样式统一管理
定义全局下拉选择器样式常量,确保 APP 内风格一致,便于统一修改:
dart
/// 全局下拉选择器样式常量 class DropdownStyles { // 表单默认样式(单选、无搜索、适配深色模式) static CommonDropdown formDropdown({ required List<String> options, required dynamic value, required ValueChanged<dynamic> onChanged, bool isMultiple = false, String hintText = "请选择", bool isLoading = false, }) => CommonDropdown( options: options, value: value, onChanged: onChanged, isMultiple: isMultiple, enableSearch: false, hintText: hintText, bgColor: const Color(0xFFFAFAFA), borderColor: const Color(0xFFE6E6E6), textStyle: const TextStyle(fontSize: 15, color: Color(0xFF333333)), hintStyle: const TextStyle(fontSize: 15, color: Color(0xFF999999)), adaptDarkMode: true, isLoading: isLoading, ); // 筛选页样式(多选、带搜索、胶囊圆角) static CommonDropdown filterDropdown({ required List<String> options, required dynamic value, required ValueChanged<dynamic> onChanged, String hintText = "请选择", bool isLoading = false, }) => CommonDropdown( options: options, value: value, onChanged: onChanged, isMultiple: true, enableSearch: true, hintText: hintText, bgColor: Colors.white, borderColor: const Color(0xFF0066FF).withOpacity(0.1), borderRadius: 12, adaptDarkMode: true, isLoading: isLoading, ); } // 使用示例 DropdownStyles.formDropdown( options: ["男", "女", "保密"], value: _selectedGender, onChanged: (value) => setState(() => _selectedGender = value), hintText: "请选择性别", )2. 结合状态管理(Provider)
避免选中值多层级传递,结合 Provider 管理选择状态:
dart
import 'package:flutter/foundation.dart'; import 'package:provider/provider.dart'; /// 筛选状态管理Provider class FilterProvider extends ChangeNotifier { List<String> _selectedCategories = []; List<String> get selectedCategories => _selectedCategories; void setSelectedCategories(List<String> value) { _selectedCategories = value; notifyListeners(); } // 清空选中项 void clearSelected() { _selectedCategories = []; notifyListeners(); } } // 使用示例 class FilterPage extends StatelessWidget { @override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (context) => FilterProvider(), child: Consumer<FilterProvider>( builder: (context, provider, child) => Scaffold( appBar: AppBar(title: const Text("品类筛选")), body: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: Column( children: [ CommonDropdown( options: ["数码", "服装", "食品", "家居"], value: provider.selectedCategories, onChanged: provider.setSelectedCategories, isMultiple: true, enableSearch: true, hintText: "请选择品类", adaptDarkMode: true, ), const SizedBox(height: 16), ElevatedButton( onPressed: provider.clearSelected, child: const Text("清空选择"), ), ], ), ), ), ), ); } }3. 性能优化建议
- 选项数据复用:大量选项(如城市列表)建议缓存(如静态常量),避免重复创建 List;
- 搜索防抖:选项超 100 条时,组件内置 300ms 防抖,减少高频筛选(已集成);
- 避免频繁重建:使用
const构造函数、缓存不变的选项列表; - 懒加载选项:异步加载选项时,通过
isLoading参数显示加载状态,提升体验; - 资源释放:确保
dispose中释放搜索控制器、焦点节点、防抖定时器(已集成); - 列表优化:选项列表使用
shrinkWrap: true+ClampingScrollPhysics,避免滚动冲突; - 深色模式优化:仅在需要时开启
adaptDarkMode,减少不必要的样式计算。
4. 无障碍适配
为下拉选择器添加语义标签,提升屏幕阅读器体验:
dart
// 表单必填下拉选择器 Semantics( label: "性别选择(必填)", hint: "请选择男、女或保密", child: CommonDropdown( options: ["男", "女", "保密"], value: _selectedGender, onChanged: (value) => setState(() => _selectedGender = value), ), ) // 筛选多选下拉选择器 Semantics( label: "爱好筛选(可多选)", hint: "支持搜索,可全选或取消全选", child: CommonDropdown( options: ["读书", "运动", "旅游"], value: _selectedHobbies, onChanged: (value) => setState(() => _selectedHobbies = value), isMultiple: true, ), )六、避坑指南(解决 90% 开发痛点)
| 问题场景 | 常见原因 | 解决方案 |
|---|---|---|
| 多选时 value 报错 | value 类型不是 List<String> | 1. 多选初始值设为空列表[]2. 回调中确保传入List<String>类型 |
| 搜索筛选区分大小写 | 筛选逻辑未统一转小写 | 组件内置toLowerCase()处理,无需手动修改 |
| 选项为空触发断言 | 传入空的 options 列表且非加载状态 | 1. 确保 options 非空2. 异步加载时设置isLoading: true |
| 下拉菜单高度溢出 | 未限制列表最大高度 | 组件内置ConstrainedBox(maxHeight: 300),无需手动设置 |
| 选中文本溢出显示不全 | 未设置文本省略 | 组件内置maxLines: 1+overflow: TextOverflow.ellipsis |
| 内存泄漏 | 未释放搜索控制器 / 防抖定时器 | 组件在dispose中释放资源,无需手动处理 |
| 多选后下拉菜单直接关闭 | 单选逻辑影响多选 | 多选模式下移除自动关闭,添加确认 / 取消按钮手动关闭(已集成) |
| 搜索框清空后筛选未重置 | 未监听搜索框清空事件 | 组件内置搜索框清空监听,自动重置筛选(已集成) |
| 异步加载选项无状态提示 | 未设置isLoading | 异步加载时设置isLoading: true,显示加载动画 |
| 深色模式样式冲突 | 未开启adaptDarkMode | 1. 设置adaptDarkMode: true2. 配置深色模式样式参数 |
| 搜索高频触发筛选 | 无防抖处理 | 组件内置 300ms 防抖,减少性能消耗(已集成) |
七、扩展能力(按需定制)
1. 自定义选项样式(带图标 / 颜色)
支持选项带图标、自定义选中样式:
dart
// 1. 扩展组件参数(新增图标配置) final List<Widget>? optionIcons; // 选项图标列表(与options一一对应) // 2. 优化选项列表构建逻辑(在itemBuilder中添加) final icon = widget.optionIcons != null && index < widget.optionIcons!.length ? widget.optionIcons![index] : null; return InkWell( onTap: () { /* 选择逻辑不变 */ }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ // 选项图标 if (icon != null) Padding( padding: const EdgeInsets.only(right: 8), child: icon, ), Expanded( child: Text( option, style: currentTextStyle.copyWith( color: isSelected ? const Color(0xFF0066FF) : currentTextStyle.color, fontSize: 15, ), ), ), if (isSelected) const Icon( Icons.check, color: Color(0xFF0066FF), size: 20, ), ], ), ), ); // 3. 使用示例 CommonDropdown( options: ["微信", "支付宝", "银行卡"], value: _selectedPayType, onChanged: (value) => setState(() => _selectedPayType = value), optionIcons: const [ Icon(Icons.wechat, color: Color(0xFF07C160)), Icon(Icons.alipay, color: Color(0xFF1677FF)), Icon(Icons.credit_card, color: Color(0xFFFF6700)), ], adaptDarkMode: true, )2. 限制多选最大数量
支持设置多选时的最大选中数,避免选择过多:
dart
// 1. 扩展组件参数 final int? maxSelectCount; // 最大选中数(null表示无限制) // 2. 优化选择逻辑(在onTap中添加) if (widget.isMultiple) { final List<String> newValues = widget.value is List<String> ? List.from(widget.value) : []; // 限制最大选中数 if (widget.maxSelectCount != null && newValues.contains(option)) { // 取消选择不受限制 newValues.remove(option); } else if (widget.maxSelectCount == null || newValues.length < widget.maxSelectCount!) { // 未达最大数时允许选择 if (!newValues.contains(option)) { newValues.add(option); } } else { // 超过最大数提示 ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("最多只能选择${widget.maxSelectCount}个选项"), duration: const Duration(seconds: 1), ), ); return; } widget.onChanged(newValues); } // 3. 使用示例 CommonDropdown( options: ["读书", "运动", "旅游", "摄影"], value: _selectedHobbies, onChanged: (value) => setState(() => _selectedHobbies = value), isMultiple: true, maxSelectCount: 3, // 最多选3个 enableSearch: true, )3. 自定义下拉菜单高度
支持手动调整下拉菜单的最大高度:
dart
// 1. 扩展组件参数 final double maxMenuHeight; // 下拉菜单最大高度 // 2. 替换ConstrainedBox的maxHeight ConstrainedBox( constraints: BoxConstraints(maxHeight: widget.maxMenuHeight), child: /* 选项列表 */, ) // 3. 使用示例 CommonDropdown( options: ["选项1", "选项2", "选项3"], value: _selectedValue, onChanged: (value) => setState(() => _selectedValue = value), maxMenuHeight: 400, // 自定义最大高度 )八、总结
优化后的 CommonDropdown 组件解决了原生下拉选择器的核心痛点,支持单选 / 多选、搜索筛选、深色模式适配、异步加载,适配表单、筛选等高频业务场景。通过工程化的设计思路,补充了表单验证、状态管理、性能优化等最佳实践,可直接应用于生产环境。
该组件轻量无依赖、交互体验优秀、样式高度可定制,既能减少重复开发工作,又能保证 APP 内选择器体验的一致性,是 Flutter 项目中下拉选择场景的理想解决方案。
欢迎大家加入[开源鸿蒙跨平台开发者社区](https://openharmonycrossplatform.csdn.net),一起共建开源鸿蒙跨平台生态。