Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Last active December 9, 2023 17:01
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save slightfoot/8645ddcc0541060d2648a9ec61ba5083 to your computer and use it in GitHub Desktop.
Save slightfoot/8645ddcc0541060d2648a9ec61ba5083 to your computer and use it in GitHub Desktop.
Name Tagging during input example - by Simon Lightfoot - Humpday Q&A :: 6th December 2023 #Flutter #Dart - https://www.youtube.com/live/TaHhT1QdYUM?t=4057
// MIT License
//
// Copyright (c) 2023 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) {
return ['Simon', 'Randal', 'Scott'];
},
),
),
),
);
}
}
class TextWithNamesEditingController extends TextEditingController {
TextWithNamesEditingController({super.text});
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
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);
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 _TextFieldWithNamesState extends State<TextFieldWithNames> {
final editableTextKey = GlobalKey<EditableTextState>();
final _overlayController = OverlayPortalController();
final _overlayPosition = ValueNotifier<Offset>(Offset.zero);
late final FocusNode _focusNode;
TextEditingValue get editingValue => widget.controller.value;
@override
void initState() {
super.initState();
_focusNode = FocusNode(debugLabel: 'TextFieldWithNamesState#$hashCode');
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 _updateOverlayPosition() {
final rect = editableTextKey.currentState!.renderEditable
.getLocalRectForCaret(TextPosition(
offset: editingValue.selection.baseOffset,
affinity: editingValue.selection.affinity,
));
final box = editableTextKey.currentContext!.findRenderObject() as RenderBox;
_overlayPosition.value = box.localToGlobal(
rect.bottomLeft,
ancestor: Overlay.of(context).context.findRenderObject(),
);
}
void _onTextValueChanged() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (editingValue.selection.isCollapsed) {
final text = editingValue.text;
final offset = editingValue.selection.baseOffset;
if (offset - 1 >= 0) {
// FIXME: rather than detect prefix @ lets detect a word starting with
// FIXME: a @ or @ along.
if (text.substring(offset - 1, offset) == '@') {
_updateOverlayPosition();
if (_overlayController.isShowing == false) {
_overlayController.show();
}
return;
}
}
}
if (_overlayController.isShowing) {
_overlayController.hide();
}
});
}
void _insertTextAndDismiss(String text) {
_overlayController.hide();
final offset = editingValue.selection.baseOffset;
widget.controller.value =
editingValue.replaced(TextRange.collapsed(offset), 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: _overlayPosition,
builder: (BuildContext context, Offset position, Widget? child) {
final names = widget.onNamesRequested(context);
return Positioned(
left: position.dx,
top: position.dy,
child: IntrinsicWidth(
child: Material(
type: MaterialType.canvas,
elevation: 8.0,
child: ListTileTheme.merge(
visualDensity: VisualDensity.compact,
child: ListBody(
children: [
for (final item in names) //
ListTile(
onTap: () => _insertTextAndDismiss(item),
title: Text(item),
),
],
),
),
),
),
);
},
);
},
child: InputDecorator(
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!,
),
),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment