Flutter:实现Dropdown选项插入TextFormField指定位置的容器
Flutter实现可插入标签的混合输入框
要实现「点击下拉选项将标签容器插入输入框光标位置/末尾,且支持后续输入」的需求,你之前用prefixIcon的方案无法实现标签与输入文本的混合显示,需要重构组件结构。以下是可行的实现方案:
核心思路
利用EditableText结合WidgetSpan实现可编辑的混合内容区域,通过自定义控制器管理光标状态、输入文本和标签插入逻辑:
- 自定义
MultiselectController统一管理输入状态、光标位置和标签操作 - 用
EditableText替代TextFormField,通过buildTextSpan方法将特殊标记的标签转换为容器样式的Widget - 下拉选项点击时调用控制器的插入方法,根据焦点状态决定插入位置
修改后的完整代码
1. 自定义控制器 multiselect_controller.dart
import 'package:flutter/material.dart'; class MultiselectController extends ChangeNotifier { String _inputText = ""; int? _cursorPosition; bool _hasFocus = false; String get inputText => _inputText; int? get cursorPosition => _cursorPosition; bool get hasFocus => _hasFocus; // 更新光标与焦点状态 void updateCursorState(int? position, bool hasFocus) { _cursorPosition = position; _hasFocus = hasFocus; notifyListeners(); } // 更新输入文本 void updateInputText(String text) { _inputText = text; notifyListeners(); } // 插入标签(用特殊标记包裹,方便后续解析) void insertTag(String tag) { final tagMarkup = "[TAG:$tag]"; if (_hasFocus && _cursorPosition != null) { // 插入到光标位置 final leftText = _inputText.substring(0, _cursorPosition!); final rightText = _inputText.substring(_cursorPosition!); _inputText = "$leftText $tagMarkup $rightText"; _cursorPosition = leftText.length + tagMarkup.length + 2; } else { // 插入到文本末尾 _inputText = _inputText.isEmpty ? "$tagMarkup " : "$_inputText $tagMarkup "; _cursorPosition = _inputText.length; } notifyListeners(); } // 删除标签 void removeTag(String tag) { final tagMarkup = "[TAG:$tag]"; _inputText = _inputText.replaceAll(tagMarkup, "").trim(); notifyListeners(); } }
2. 混合输入组件 multiselect.dart
import 'package:flutter/material.dart'; class Multiselect extends StatefulWidget { final MultiselectController controller; final String? hint; final Function(String tag)? onTagDeleted; const Multiselect({ super.key, required this.controller, this.hint, this.onTagDeleted, }); @override State<Multiselect> createState() => _MultiselectState(); } class _MultiselectState extends State<Multiselect> { late FocusNode _focusNode; late TextEditingController _textController; @override void initState() { super.initState(); _focusNode = FocusNode(); _textController = TextEditingController(text: widget.controller.inputText); // 监听焦点变化 _focusNode.addListener(() { widget.controller.updateCursorState( _textController.selection.start, _focusNode.hasFocus, ); }); // 监听输入文本变化 _textController.addListener(() { widget.controller.updateInputText(_textController.text); }); // 监听控制器状态更新 widget.controller.addListener(() { _textController.text = widget.controller.inputText; _textController.selection = TextSelection.fromPosition( TextPosition(offset: widget.controller.cursorPosition ?? _textController.text.length), ); }); } @override void dispose() { _focusNode.dispose(); _textController.dispose(); super.dispose(); } // 构建标签容器 Widget _buildTag(String tag) { return Container( decoration: BoxDecoration( color: Theme.of(context).primaryColorDark, borderRadius: BorderRadius.circular(20), ), margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( tag, style: const TextStyle(fontSize: 14, color: Colors.white), ), const SizedBox(width: 6), InkWell( onTap: () { widget.controller.removeTag(tag); widget.onTagDeleted?.call(tag); }, child: const Icon( Icons.close, color: Colors.white, size: 16, ), ), ], ), ); } @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( border: Border.all(color: Theme.of(context).dividerColor), borderRadius: BorderRadius.circular(8), ), child: EditableText( controller: _textController, focusNode: _focusNode, style: const TextStyle(fontSize: 14, color: Colors.black87), cursorColor: Theme.of(context).primaryColor, backgroundCursorColor: Colors.grey, hintText: widget.hint ?? "输入内容或选择标签...", hintStyle: TextStyle( color: Theme.of(context).hintColor, fontSize: 14, ), buildTextSpan: (context, style) { final spans = <InlineSpan>[]; final text = _textController.text; // 分割文本,解析标签标记 final parts = text.split(RegExp(r'(\[TAG:.+?\])')); for (final part in parts) { if (part.startsWith('[TAG:') && part.endsWith(']')) { final tag = part.substring(5, part.length - 1); spans.add(WidgetSpan( alignment: PlaceholderAlignment.middle, child: _buildTag(tag), )); spans.add(const TextSpan(text: " ")); } else if (part.isNotEmpty) { spans.add(TextSpan(text: part, style: style)); } } return TextSpan(style: style, children: spans); }, ), ); } }
3. 主页面 home_page.dart
import 'package:flutter/material.dart'; import 'package:taginput_example/widgets/multiselect.dart'; import 'package:taginput_example/widgets/multiselect_controller.dart'; class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); final String title; @override State<MyHomePage> createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { late MultiselectController _multiselectController; @override void initState() { super.initState(); _multiselectController = MultiselectController(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(widget.title), ), body: Center( child: SizedBox( width: 800, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( padding: const EdgeInsets.only(bottom: 20), child: DropdownButtonFormField<int>( isExpanded: true, items: List.generate(8, (index) => index).map((int number) { return DropdownMenuItem( value: number, child: Text(number.toString()), ); }).toList(), onChanged: (value) { if (value != null) { _multiselectController.insertTag(value.toString()); } }, ), ), Multiselect( controller: _multiselectController, hint: "输入内容或选择标签...", onTagDeleted: (tag) { // 可选:处理标签删除后的额外逻辑 }, ) ], ), ), ), ); } }
4. 入口文件 main.dart
import 'package:flutter/material.dart'; import 'package:taginput_example/home_page.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), home: const MyHomePage(title: 'Flutter Demo Home Page'), ); } }
功能说明
- 点击下拉选项时,标签会插入到输入框当前光标位置;若输入框未聚焦,则插入到文本末尾
- 标签以容器样式显示,支持点击关闭按钮删除
- 插入标签后可继续在标签前后输入普通文本,光标可自由移动
- 整体样式模拟原生输入框,交互体验流畅
内容的提问来源于stack exchange,提问作者Neli Todorova
相关产品推荐
相关产品推荐

