Flutter光标自动补全选项插件cursor_autocomplete_options的使用

Flutter光标自动补全选项插件cursor_autocomplete_options的使用

Flutter已经有一个自动完成小部件。但是它并不符合我们在桌面和Web版本上通常看到的UI模式。这是因为自动完成部分位于文本框下方,并且我们无法通过自动完成API更改这一点。 为了满足这一需求,此插件被创建出来。目的是在光标指示器正下方显示一个选项列表视图,每个自动完成功能选项都以列表磁贴的形式显示在一个覆盖层中。

🌟 测试实时演示!

您可以在在线Web演示中测试该插件: 测试插件

Getting started(开始使用)

First, import the package(首先导入包)

import 'package:cursor_autocomplete_options/cursor_autocomplete_options.dart';

Setting variables(设置变量)

在包含文本框的小部件内,该小部件需要是状态化的,以便在使用后释放资源。我们将创建以下小部件:

late final OptionsController<String> optionsController;
final FocusNode textfieldFocusNode = FocusNode();
final TextEditingController textEditingController = TextEditingController();
⚠️ 重要!

我们需要为文本框创建一个焦点节点,因为OptionsController需要能够重新聚焦文本框,而不仅仅是列表磁贴。此外,应用程序需要知道文本框的位置,以便计算字符应放置的位置。因此,请不要忘记将textfield focus nodetextEditingController添加到您的TextFormField小部件中。

After that, in the init state we will initialize the OptionsController(接下来,在初始化状态中我们将初始化OptionsController)

[@override](/user/override)
void initState() {
  super.initState();

  optionsController = OptionsController<String>(
    textfieldFocusNode: textfieldFocusNode,
    textEditingController: textEditingController,
    context: context,
    selectInCursorParser: (option) {
      return option;
    },
  );
}
⚠️ 重要!

如果您的上下文经常更新,那么您在OptionsController的context参数中使用的上下文可能会变得过时并且不再有效。因此,您需要更新它。您可以通过在状态化小部件的build方法中调用控制器的updateContext(context)函数来实现这一点。这在您的小部件从未重建且上下文从不被废弃的情况下不是问题。

[@override](/user/override)
Widget build(BuildContext context) {
  // 在状态化小部件的build方法中添加此行,文本框将包含在此处:
  optionsController.updateContext(context); 

  return ... // 您的小部件
}

About generic type <T> of OptionsController(关于OptionsController的泛型类型<T>)

OptionsController接收一个泛型T值。这可以是任何模型,但如果您只想在光标中添加文本,则可以使用<String>作为泛型类型。

⚠️ 重要!

如果<T>不同于String,您需要传递optionAsString字段将其转换为字符串。因此,如果您不使用<String>作为泛型类型,则此参数成为必需。否则,如果泛型类型为<String>,则无需传递此参数,因为该包已经知道它是字符串。

Now, you can use the textfield with the values we created(现在,您可以使用我们创建的文本框)

TextFormField(
    focusNode: textfieldFocusNode, // 不要忘记!
    controller: textEditingController, // 不要忘记!
    style: const TextStyle(height: 1, fontSize: 18), // 高度参数必须为1。
    ...
),
⚠️ 重要!

您需要在文本框中初始化一个名为style的字段,并将其height参数设置为1。不能是其他值。 文本样式中的其他值,如fontSize,可以是你喜欢的任何值。

⚠️ 重要!

不要忘记在最后释放控制器和文本框。注意:您不需要释放文本框焦点节点,因为OptionsController.dispose()会为您处理。请参见以下示例:

[@override](/user/override)
void dispose() {
  super.dispose();

  // 不要忘记释放!
  optionsController.dispose();
  textEditingController.dispose();
}

Usage(使用)

您可以随时触发在光标位置显示对话框。记住,文本框必须处于焦点状态,以便该包识别光标及其位置。为此,该包将使用文本框的焦点节点来查看是否已聚焦。

要触发带有选项的覆盖层显示,您将在OptionsController控制器上调用showOptionsMenu()函数,并传递您想要给用户的选项列表。此函数将接收建议参数,即类型为<T>的项目列表,与您的OptionController<T>相同。

在触发showOptionsMenu()函数后,它将显示带有选项的对话框,然后在用户选择选项后,应用将触发两个函数onTextAddedCallbackselectInCursorParser(如果它们不是null)。

例如,当用户在文本框中输入“#”时,可以触发它。但这完全取决于你何时触发它。以下是这种情况的一个示例:

TextFormField(
  ... // 其他属性
  onChanged: (value) {
    if (value.isEmpty) return;
    final cursorPositionIndex =
      textEditingController.selection.base.offset;

    final typedValue = value[cursorPositionIndex - 1];

    final isTypedCaracterHashtag = typedValue == '#';

    if (isTypedCaracterHashtag) {
      optionsController.showOptionsMenu(suggestion);
    }
  },
)

Customize the card widget(自定义卡片小部件)

想自定义您的卡片小部件吗? 使用showOptionsMenuWithWrapperBuilder并创建一个包装器,上面有带选项的ListView。

Manipulating selections(操作选择)

您可以使用两个主要功能onTextAddedCallbackselectInCursorParser来操纵和确定将对选择执行的操作。

onTextAddedCallback(onTextAddedCallback)

将为您提供完全控制选定选项的操作。因此,如果您愿意,您可以自行处理文本框的值。

selectInCursorParser(selectInCursorParser)

这是一个预先构建的选项,建立在onTextAddedCallback之上,用于在当前光标位置插入此函数的返回值。还可以通过传递cursorIndexChangeQuantity在返回负载中更改光标位置。有关更多信息,请参阅InsertInCursorPayload

Configurations(配置)

Change card size(更改卡片大小)

OptionsController内部,您可以使用字段overlayCardHeightoverlayCardWeight来操作选项列表视图的宽度和高度。

Change item tile widget(更改项磁贴小部件)

在使用showOptionsMenuWithWrapperBuildershowOptionsMenu函数时,您可以指定tileBuilder。 这样,您可以使用自定义小部件来显示所选选项。

⚠️ 重要:

使用焦点节点为用户提供视觉反馈,所选磁贴的高度可以通过tileHeight参数进行编辑。

Debounce(防抖)

您可以在选项覆盖层触发时配置防抖。默认值为300毫秒。

Controll when to close dialog(控制何时关闭对话框)

也许,您不想在onTextAddedCallbackselectInCursorParser中执行登录后关闭对话框。因此,您可以将willAutomaticallyCloseDialogAfterSelection字段设置为false,这样对话框在选择值后将停止关闭。


完整示例

import 'package:cursor_autocomplete_options/cursor_autocomplete_options.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  [@override](/user/override)
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
        textSelectionTheme: const TextSelectionThemeData(
          selectionHandleColor: Colors.transparent,
        ),
      ),
      home: const Example(),
    );
  }
}

enum SelectedType {
  simple,
  complex;
}

class Example extends StatefulWidget {
  const Example({super.key});

  [@override](/user/override)
  State<Example> createState() => _ExampleState();
}

class _ExampleState extends State<Example> {
  late final OptionsController<String, String> optionsController;
  final FocusNode textfieldFocusNode = FocusNode();
  final TextEditingController textEditingController = TextEditingController();

  [@override](/user/override)
  void initState() {
    super.initState();

    optionsController = OptionsController<String, String>(
      textfieldFocusNode: textfieldFocusNode,
      textEditingController: textEditingController,
      context: context,
      selectInCursorParser: (option) {
        return InsertInCursorPayload(text: option);
      },
    );
  }

  [@override](/user/override)
  void dispose() {
    super.dispose();

    // 不要忘记释放!
    optionsController.dispose();
    textEditingController.dispose();
  }

  AlignmentOptions alignmentOption = AlignmentOptions.center;
  double width = 300;
  int maxLines = 5;

  // final Set<SelectedType> selectedTypes = {SelectedType.simple};

  [@override](/user/override)
  Widget build(BuildContext context) {
    optionsController.updateContext(context);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter textfield sugestions demo'),
        leadingWidth: 310,
        leading: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            const SizedBox(width: 16),
            IconButton(
              onPressed: () {
                showDialog(
                  context: context,
                  builder: (context) {
                    return Dialog(
                      child: ChangeFields(
                        value: width.round(),
                        textfieldLines: maxLines,
                        onSave: (newWidth, maxLines) {
                          setState(() {
                            width = newWidth;
                            this.maxLines = maxLines;
                          });
                        },
                      ),
                    );
                  },
                );
              },
              icon: const Icon(Icons.settings),
            ),
            const SizedBox(width: 16),
            // SegmentedButton<SelectedType>(
            //   segments: SelectedType.values
            //       .map(
            //         (t) =>
            //             ButtonSegment(
            //               value: t,
            //               label: Text(t.name),
            //             ),
            //       )
            //       .toList(),
            //   selected: selectedTypes,
            //   onSelectionChanged: (p0) {
            //     setState(() {
            //       selectedTypes.clear();
            //       selectedTypes.addAll(p0);
            //     });
            //   },
            // ),
          ],
        ),
        actions: [
          DropdownButton<AlignmentOptions>(
            value: alignmentOption,
            items: AlignmentOptions.values.map((option) {
              return DropdownMenuItem<AlignmentOptions>(
                value: option,
                child: Text(option.text),
              );
            }).toList(),
            onChanged: (selectedValue) {
              if (selectedValue == null) return;
              setState(() {
                alignmentOption = selectedValue;
              });
            },
          ),
        ],
      ),
      body: Align(
        alignment: alignmentOption.alignment,
        child: Container(
          color: Colors.grey[200],
          width: width,
          child: Builder(builder: (context) {
            return TextFormField(
              focusNode: textfieldFocusNode,
              controller: textEditingController,
              decoration: const InputDecoration(
                hintText: 'Type something and use "#" anytime to show options',
              ),
              style: const TextStyle(height: 1, fontSize: 18),
              maxLines: maxLines,
              onChanged: (value) {
                if (value.isEmpty) return;

                final cursorPositionIndex =
                    textEditingController.selection.base.offset;

                final typedValue = value[cursorPositionIndex - 1];

                final isTypedCaracterHashtag = typedValue == '#';

                if (isTypedCaracterHashtag) {
                  optionsController.showSimpleOptions(
                    children: complexSuggestion,
                    folderOptionAsString: (option) {
                      return option;
                    },
                    fileOptionAsString: (option) {
                      return option;
                    },
                  );
                }
              },
            );
          }),
        ),
      ),
    );
  }
}

final List<StructuredDataType<String, String>> complexSuggestion = [
  const FolderStructure(item: 'Folder 1', children: [
    FolderStructure(item: 'Folder 1.1', children: [
      FolderStructure(item: 'Folder 2.1.1', children: [
        FileStructureOptions(item: 'File 2.1.1'),
        FileStructureOptions(item: 'File 2.1.2'),
        FileStructureOptions(item: 'File 2.1.3'),
      ]),
      FileStructureOptions(item: 'File 1.1.1'),
      FileStructureOptions(item: 'File 1.1.2'),
      FileStructureOptions(item: 'File 1.1.3'),
    ]),
    FolderStructure(item: 'Folder 1.2', children: [
      FileStructureOptions(item: 'File 1.2.1'),
      FileStructureOptions(item: 'File 1.2.2'),
      FileStructureOptions(item: 'File 1.2.3'),
    ]),
    FolderStructure(item: 'Folder 1.3', children: [
      FileStructureOptions(item: 'File 1.3.1'),
      FileStructureOptions(item: 'File 1.3.2'),
      FileStructureOptions(item: 'File 1.3.3'),
    ]),
  ]),
  const FolderStructure(item: 'Folder 2', children: [
    FolderStructure(item: 'Folder 2.1', children: [
      FileStructureOptions(item: 'File 2.1.1'),
      FileStructureOptions(item: 'File 2.1.2'),
      FileStructureOptions(item: 'File 2.1.3'),
    ]),
    FolderStructure(item: 'Folder 2.2', children: [
      FileStructureOptions(item: 'File 2.2.1'),
      FileStructureOptions(item: 'File 2.2.2'),
      FileStructureOptions(item: 'File 2.2.3'),
    ]),
    FolderStructure(item: 'Folder 2.3', children: [
      FileStructureOptions(item: 'File 2.3.1'),
      FileStructureOptions(item: 'File 2.3.2'),
      FileStructureOptions(item: 'File 2.3.3'),
    ]),
  ]),
  const FolderStructure(item: 'Folder 3', children: [
    FolderStructure(item: 'Folder 3.1', children: [
      FileStructureOptions(item: 'File 3.1.1'),
      FileStructureOptions(item: 'File 3.1.2'),
      FileStructureOptions(item: 'File 3.1.3'),
    ]),
    FolderStructure(item: 'Folder 3.2', children: [
      FileStructureOptions(item: 'File 3.2.1'),
      FileStructureOptions(item: 'File 3.2.2'),
      FileStructureOptions(item: 'File 3.2.3'),
    ]),
    FolderStructure(item: 'Folder 3.3', children: [
      FileStructureOptions(item: 'File 3.3.1'),
      FileStructureOptions(item: 'File 3.3.2'),
      FileStructureOptions(item: 'File 3.3.3'),
    ]),
  ]),
  const FileStructureOptions(item: 'File 1'),
  const FileStructureOptions(item: 'File 2'),
  const FileStructureOptions(item: 'File 3'),
  const FileStructureOptions(item: 'File 4'),
  const FileStructureOptions(item: 'File 5'),
  const FileStructureOptions(item: 'File 6'),
];

final List<String> simpleSuggestion = [
  'Floor',
  'Bar',
  'Manana',
  'Lerolero',
  'Idensa',
  'Yaha',
  'Tysaki',
  'Ruyma',
  'Rolmuro',
  'Ehuka',
  'Yah',
];

enum AlignmentOptions {
  center(Alignment.center, '- Center'),
  topLeft(Alignment.topLeft, '↖ Top left'),
  topCenter(Alignment.topCenter, '↑ Top center'),
  topRight(Alignment.topRight, '↗ Top right'),
  centerLeft(Alignment.centerLeft, '← Center left'),
  centerRight(Alignment.centerRight, '→ Center right'),
  bottomLeft(Alignment.bottomLeft, '↙ Bottom left'),
  bottomCenter(Alignment.bottomCenter, '↓ Bottom center'),
  bottomRight(Alignment.bottomRight, '↘ Bottom right');

  final Alignment alignment;
  final String text;
  const AlignmentOptions(this.alignment, this.text);
}

class ChangeFields extends StatefulWidget {
  final int value;
  final int textfieldLines;
  final void Function(double newWidth, int maxLines) onSave;

  const ChangeFields({
    super.key,
    required this.value,
    required this.textfieldLines,
    required this.onSave,
  });

  [@override](/user/override)
  State<ChangeFields> createState() => _ChangeFieldsState();
}

class _ChangeFieldsState extends State<ChangeFields> {
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();

  late TextEditingController widthEC;

  [@override](/user/override)
  void initState() {
    super.initState();
    widthEC = TextEditingController(text: '${widget.value}');
    maxLines = widget.textfieldLines;
  }

  [@override](/user/override)
  void dispose() {
    widthEC.dispose();
    super.dispose();
  }

  late int maxLines;

  [@override](/user/override)
  Widget build(BuildContext context) {
    return Container(
      width: 300,
      height: 210,
      margin: const EdgeInsets.all(16),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          const Text(
            'Width of textfield',
            style: TextStyle(fontSize: 20),
          ),
          const SizedBox(height: 4),
          Form(
            key: _formKey,
            child: TextFormField(
              controller: widthEC,
              validator: (value) {
                final valueParsed = int.tryParse(value ?? 'a');
                return valueParsed == null || valueParsed < 150
                    ? 'Need to be a number bigger then 150'
                    : null;
              },
              inputFormatters: <TextInputFormatter>[
                FilteringTextInputFormatter.digitsOnly
              ],
              decoration: const InputDecoration(
                border: OutlineInputBorder(),
              ),
              autovalidateMode: AutovalidateMode.onUserInteraction,
            ),
          ),
          const SizedBox(height: 8),
          Row(
            children: [
              const Text(
                'Max lines',
                style: TextStyle(fontSize: 20),
              ),
              const Spacer(),
              SizedBox(
                width: 50,
                child: DropdownButton<int>(
                  value: maxLines,
                  items: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((option) {
                    return DropdownMenuItem<int>(
                      value: option,
                      child: Text('$option'),
                    );
                  }).toList(),
                  onChanged: (selectedValue) {
                    if (selectedValue == null) return;
                    setState(() {
                      maxLines = selectedValue;
                    });
                  },
                ),
              ),
            ],
          ),
          const Spacer(),
          ElevatedButton.icon(
            onPressed: () {
              final parsedWidth = double.tryParse(widthEC.text);
              if (parsedWidth == null) return;
              widget.onSave(parsedWidth, maxLines);
              Navigator.pop(context);
            },
            icon: const Icon(Icons.save),
            label: const Text('Save'),
          ),
        ],
      ),
    );
  }
}

更多关于Flutter光标自动补全选项插件cursor_autocomplete_options的使用的实战教程也可以访问 https://www.itying.com/category-92-b0.html

1 回复

更多关于Flutter光标自动补全选项插件cursor_autocomplete_options的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html


当然,下面是一个关于如何使用 cursor_autocomplete_options 插件的 Flutter 代码示例。这个插件允许你在文本字段中实现光标自动补全选项的功能。为了简化示例,假设你已经将 cursor_autocomplete_options 插件添加到了你的 pubspec.yaml 文件中。

首先,确保你的 pubspec.yaml 文件中包含以下依赖:

dependencies:
  flutter:
    sdk: flutter
  cursor_autocomplete_options: ^最新版本号  # 请替换为最新的版本号

然后,运行 flutter pub get 来获取依赖。

接下来是一个完整的 Flutter 应用示例,展示了如何使用 cursor_autocomplete_options 插件:

import 'package:flutter/material.dart';
import 'package:cursor_autocomplete_options/cursor_autocomplete_options.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final List<String> suggestions = [
    'Apple',
    'Banana',
    'Cherry',
    'Date',
    'Elderberry',
    'Fig',
    'Grape',
  ];

  final TextEditingController _controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Cursor Autocomplete Options Demo'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            AutocompleteTextField<String>(
              controller: _controller,
              decoration: InputDecoration(
                labelText: 'Type something...',
                border: OutlineInputBorder(),
              ),
              optionsBuilder: (String query) {
                if (query.isEmpty) {
                  return [];
                }
                return suggestions.where((String suggestion) {
                  return suggestion.toLowerCase().contains(query.toLowerCase());
                }).toList();
              },
              onSelected: (String value) {
                // 当用户选择一个选项时,这里可以处理选择事件
                print('Selected: $value');
              },
              fieldViewBuilder: (
                BuildContext context,
                TextEditingController textEditingController,
                FocusNode focusNode,
                VoidCallback onEditingComplete,
              ) {
                return TextField(
                  controller: textEditingController,
                  focusNode: focusNode,
                  onEditingComplete: onEditingComplete,
                  decoration: InputDecoration(
                    border: InputBorder.none,
                    contentPadding: EdgeInsets.zero,
                  ),
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}

在这个示例中,我们创建了一个 AutocompleteTextField 小部件,它允许用户在文本字段中输入内容,并根据输入内容显示建议列表。当用户选择一个建议时,会在控制台中打印所选的值。

关键点解释

  1. optionsBuilder: 这个函数根据用户的输入生成建议列表。在这里,我们简单地过滤了包含用户输入字符串的建议。
  2. onSelected: 当用户选择一个建议时,这个函数会被调用。你可以在这里处理选择事件,比如更新状态或发送数据。
  3. fieldViewBuilder: 这个函数用于自定义文本字段的外观。在这里,我们简单地返回了一个标准的 TextField

这个示例应该能帮助你理解如何在 Flutter 应用中使用 cursor_autocomplete_options 插件来实现光标自动补全选项的功能。如果你有更多具体需求或遇到问题,请查阅该插件的官方文档或进一步提问。

回到顶部