Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Last active October 15, 2025 18:31
Show Gist options
  • Select an option

  • Save slightfoot/d734cd52f12ebce971ca4127bf16adab to your computer and use it in GitHub Desktop.

Select an option

Save slightfoot/d734cd52f12ebce971ca4127bf16adab to your computer and use it in GitHub Desktop.
Input Field with Name Tagging - by Simon Lightfoot :: #HumpdayQandA on 15th October 2025 :: https://www.youtube.com/watch?v=G5iipsrjb78
// MIT License
//
// Copyright (c) 2025 Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import 'package:flutter/material.dart';
void main() {
runApp(
const MaterialApp(
debugShowCheckedModeBanner: false,
home: ExampleWidget(),
),
);
}
class ExampleWidget extends StatefulWidget {
const ExampleWidget({super.key});
@override
State<ExampleWidget> createState() => _ExampleWidgetState();
}
class _ExampleWidgetState extends State<ExampleWidget> {
late final TextWithNamesEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextWithNamesEditingController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: TextFieldWithNames(
controller: _controller,
onNamesRequested: (BuildContext context, String hint) {
final names = <String>[
'Simon',
'Steve',
'Scott',
'Randal',
'Dalan',
'TrainOfThrought',
'Madhan',
'Michael',
'Tom',
];
if (hint.isEmpty) {
return names;
}
return names //
.where((el) => el.toLowerCase().startsWith(hint.toLowerCase()))
.toList();
},
),
),
),
);
}
}
class TextWithNamesEditingController extends TextEditingController {
TextWithNamesEditingController({super.text});
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
// FIXME: since we are overriding buildTextSpan we need to handle the
// composing range ourselves, but this composing region might conflict
// with the @name region and this has to be handled correctly.
final exp = RegExp(r'@\w*|[^@]+');
return TextSpan(
children: [
...exp.allMatches(value.text).map((RegExpMatch match) {
final text = value.text.substring(match.start, match.end);
return TextSpan(
text: text,
style:
(text.isNotEmpty && text[0] == '@') //
? const TextStyle(color: Colors.blue)
: null,
);
}),
],
style: style,
);
}
}
typedef TextFieldNamesRequester = List<String> Function(BuildContext context, String hint);
class TextFieldWithNames extends StatefulWidget {
const TextFieldWithNames({
super.key,
required this.controller,
required this.onNamesRequested,
});
final TextWithNamesEditingController controller;
final TextFieldNamesRequester onNamesRequested;
@override
State<TextFieldWithNames> createState() => _TextFieldWithNamesState();
}
class _OverlayParameters {
const _OverlayParameters({
required this.start,
required this.end,
required this.position,
required this.names,
});
final int start;
final int end;
final Offset position;
final List<String> names;
static const empty = _OverlayParameters(
start: 0,
end: 0,
position: Offset.zero,
names: [],
);
}
class _TextFieldWithNamesState extends State<TextFieldWithNames> {
final editableTextKey = GlobalKey<EditableTextState>();
final inputDecoratorKey = GlobalKey();
final _overlayController = OverlayPortalController();
final _overlayParameters = ValueNotifier<_OverlayParameters>(_OverlayParameters.empty);
final _layerLink = LayerLink();
late final FocusNode _focusNode;
TextEditingValue get editingValue => widget.controller.value;
@override
void initState() {
super.initState();
_focusNode = FocusNode(debugLabel: 'TextFieldWithNamesState#$hashCode');
_focusNode.addListener(_onFocusChanged);
widget.controller.addListener(_onTextValueChanged);
}
@override
void didUpdateWidget(covariant TextFieldWithNames oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller) {
oldWidget.controller.removeListener(_onTextValueChanged);
widget.controller.addListener(_onTextValueChanged);
}
}
@override
void dispose() {
widget.controller.removeListener(_onTextValueChanged);
_focusNode.dispose();
super.dispose();
}
void _updateOverlayParameters(int start, int end, String hint) {
final rect = editableTextKey.currentState!.renderEditable.getLocalRectForCaret(
TextPosition(
offset: editingValue.selection.baseOffset,
affinity: editingValue.selection.affinity,
),
);
final box = editableTextKey.currentContext!.findRenderObject() as RenderBox;
final overlayRelativePosition = box.localToGlobal(
rect.bottomLeft,
ancestor: inputDecoratorKey.currentContext!.findRenderObject(),
);
final names = widget.onNamesRequested(context, hint);
_overlayParameters.value = _OverlayParameters(
start: start,
end: end,
position: overlayRelativePosition,
names: names,
);
}
void _onFocusChanged() {
if (_focusNode.hasFocus == false) {
_overlayController.hide();
}
}
void _onTextValueChanged() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (editingValue.selection.isCollapsed) {
final text = editingValue.text;
final offset = editingValue.selection.baseOffset;
for (int i = offset - 1; i >= 0; i--) {
// FIXME: this should match all unicode whitespace characters
if (text.substring(i, i + 1) == ' ') {
break;
}
if (text.substring(i, i + 1) == '@') {
_updateOverlayParameters(
i + 1,
offset,
text.substring(i + 1, offset),
);
if (_overlayController.isShowing == false) {
_overlayController.show();
}
return;
}
}
}
if (_overlayController.isShowing) {
_overlayController.hide();
}
});
}
void _insertTextAndDismiss(String text) {
_overlayController.hide();
widget.controller.value = editingValue.replaced(
TextRange(
start: _overlayParameters.value.start,
end: _overlayParameters.value.end,
),
'$text ',
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final selectionStyle = DefaultSelectionStyle.of(context);
return OverlayPortal(
controller: _overlayController,
overlayChildBuilder: (BuildContext context) {
return ValueListenableBuilder(
valueListenable: _overlayParameters,
builder: (BuildContext context, _OverlayParameters parameters, Widget? child) {
final viewInsets = MediaQueryData.fromView(View.of(context)).viewInsets;
print(viewInsets);
return CompositedTransformFollower(
link: _layerLink,
offset: parameters.position,
child: Align(
alignment: Alignment.topLeft,
child: Padding(
padding: EdgeInsets.only(bottom: viewInsets.bottom),
child: IntrinsicWidth(
child: TextFieldTapRegion(
child: Material(
type: MaterialType.canvas,
elevation: 8.0,
child: ListTileTheme.merge(
visualDensity: VisualDensity.compact,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final item in parameters.names) //
ListTile(
onTap: () => _insertTextAndDismiss(item),
title: Text(item),
),
],
),
),
),
),
),
),
),
),
);
},
);
},
child: CompositedTransformTarget(
link: _layerLink,
child: InputDecorator(
key: inputDecoratorKey,
decoration: const InputDecoration(
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(width: 2.0),
),
),
child: EditableText(
key: editableTextKey,
focusNode: _focusNode,
controller: widget.controller,
cursorColor: selectionStyle.cursorColor ?? Colors.black,
backgroundCursorColor: Colors.grey,
maxLines: null,
style: theme.textTheme.titleMedium!,
onTapOutside: (_) => _focusNode.unfocus(),
),
),
),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment