You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter:实现Dropdown选项插入TextFormField指定位置的容器

Flutter实现可插入标签的混合输入框

要实现「点击下拉选项将标签容器插入输入框光标位置/末尾,且支持后续输入」的需求,你之前用prefixIcon的方案无法实现标签与输入文本的混合显示,需要重构组件结构。以下是可行的实现方案:

核心思路

利用EditableText结合WidgetSpan实现可编辑的混合内容区域,通过自定义控制器管理光标状态、输入文本和标签插入逻辑:

  1. 自定义MultiselectController统一管理输入状态、光标位置和标签操作
  2. 用EditableText替代TextFormField,通过buildTextSpan方法将特殊标记的标签转换为容器样式的Widget
  3. 下拉选项点击时调用控制器的插入方法,根据焦点状态决定插入位置

修改后的完整代码

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 00:58:09