flutter_taggable 0.0.1
flutter_taggable: ^0.0.1 copied to clipboard
A lightweight extension to TextEditingController that allows for tagging/mentioning users, with a focus on storing tags in a database-friendly format.
example/lib/main.dart
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:taggable/taggable.dart';
class Taggable {
const Taggable({required this.id, required this.name});
final String id;
final String name;
}
class User extends Taggable {
const User({required super.id, required super.name});
}
class Topic extends Taggable {
const Topic({required super.id, required super.name});
}
/// A list of users to search from.
const users = <User>[
User(id: '1ax', name: 'Alice'),
User(id: '2by', name: 'Bob'),
User(id: '3cz', name: 'Charlie'),
User(id: '4dw', name: 'Carol'),
];
/// A list of topics to search from.
const topics = <Topic>[
Topic(id: 'myDartId', name: 'Dart'),
Topic(id: 'myFlutterId', name: 'Flutter'),
Topic(id: 'myPubId', name: 'Pub'),
];
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: Scaffold(body: HomePage()));
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
/// The [LayerLink] is used to link the [CompositedTransformTarget] and
/// [CompositedTransformFollower] widgets required to show the overlay.
final _layerLink = LayerLink();
/// The [_formKey] is used to get the [RenderBox] of the [Form] widget to
/// position the overlay.
final _formKey = GlobalKey<FormState>();
/// The [_focusNode] is used to focus the [TextField] when the overlay is
/// closed.
late FocusNode _focusNode;
/// A list of comments made using the [TagTextEditingController].
final List<List<InlineSpan>> comments = [];
/// The [TagTextEditingController] is used to control the [TextField] and
/// handle the tagging logic.
late final TagTextEditingController _controller;
/// The [_overlayEntry] is used to show the overlay with the list of
/// taggables.
OverlayEntry? _overlayEntry;
/// The [backendFormat] is used to display the backend format of the text
String backendFormat = '';
@override
void initState() {
super.initState();
_focusNode = FocusNode();
// Initialize the [TagTextEditingController] with the required parameters.
_controller = TagTextEditingController<Taggable>(
searchTaggables: searchTaggables,
buildTaggables: buildTaggables,
toFrontendConverter: (taggable) => taggable.name,
toBackendConverter: (taggable) => taggable.id,
tagStyles: {
'@': const TextStyle(color: Colors.blue),
'#': const TextStyle(color: Colors.green),
'all:': const TextStyle(color: Colors.purple),
});
// Add a listener to update the [backendFormat] when the text changes.
_controller.addListener(() {
setState(() {
backendFormat = _controller.backendTextFormat;
});
});
}
@override
void dispose() {
_focusNode.dispose();
_controller.dispose();
_overlayEntry?.remove();
super.dispose();
}
/// This method is used to build the [InlineSpan]s from the backend format.
///
/// You can copy this method to your project and modify it as per your
/// requirements.
FutureOr<List<InlineSpan>> _buildTextSpans(
String backendFormat, {
TextStyle? defaultStyle,
}) async {
String? leadingSpace;
List<InlineSpan> spans = [];
for (String word in backendFormat.split(' ')) {
leadingSpace = leadingSpace == null ? "" : " ";
final tagPrefix = _controller.tagStyles.keys
.where(
(prefix) => word.startsWith(prefix),
)
.firstOrNull;
if (tagPrefix == null) {
spans.add(TextSpan(text: "$leadingSpace$word", style: defaultStyle));
continue;
}
final taggable =
await backendToTaggable(tagPrefix, word.substring(tagPrefix.length));
if (taggable == null) {
spans.add(TextSpan(text: "$leadingSpace$word", style: defaultStyle));
continue;
}
final tagStyle = _controller.tagStyles[tagPrefix]!;
final mergedStyle = defaultStyle?.merge(tagStyle) ?? tagStyle;
spans.add(TextSpan(
text: "$leadingSpace$tagPrefix${taggable.name}",
style: mergedStyle,
recognizer: TapGestureRecognizer()
..onTap = () => debugPrint('Tapped on ${taggable.name}'),
));
}
return spans;
}
/// Shows the overlay with the list of taggables.
Future<Taggable?> buildTaggables(
FutureOr<Iterable<Taggable>> taggables) async {
final availableTaggables = await taggables;
// We use a [Completer] to return the selected taggable from the overlay.
// This is because overlays do not return values directly.
Completer<Taggable?> completer = Completer();
// Remove the existing overlay if it exists.
_overlayEntry?.remove();
if (availableTaggables.isEmpty) {
// If there are no taggables to show, we return null.
_overlayEntry = null;
completer.complete(null);
} else {
_overlayEntry = OverlayEntry(builder: (context) {
// The following few lines are used to position the overlay above the
// [TextField]. It moves along if the [TextField] moves.
final renderBox =
_formKey.currentContext!.findRenderObject() as RenderBox;
return Positioned(
width: renderBox.size.width,
bottom: renderBox.size.height + 8,
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
followerAnchor: Alignment.bottomLeft,
child: Material(
child: ListView(
shrinkWrap: true,
children: availableTaggables.map((taggable) {
// We show the list of taggables in a [ListView].
return ListTile(
title: Text(taggable.name),
tileColor: Theme.of(context).colorScheme.primaryContainer,
onTap: () {
// When a taggable is selected, we remove the overlay
_overlayEntry?.remove();
_overlayEntry = null;
// and complete the [Completer] with the selected taggable.
completer.complete(taggable);
// We also focus the [TextField] to continue typing.
// Do this after completing the [Completer] to avoid
// Messing up the logic of adding the taggable to the text.
_focusNode.requestFocus();
},
);
}).toList(),
),
),
),
);
});
if (mounted) {
Overlay.of(context).insert(_overlayEntry!);
}
}
return completer.future;
}
/// This method searches for taggables based on the tag prefix and tag name.
///
/// You can specify different behaviour based on the tag prefix.
Future<Iterable<Taggable>> searchTaggables(
String tagPrefix, String? tagName) async {
if (tagName == null || tagName.isEmpty) {
return [];
}
return switch (tagPrefix) {
'@' => users
.where((user) =>
user.name.toLowerCase().startsWith(tagName.toLowerCase()))
.toList(),
'#' => topics
.where((topic) =>
topic.name.toLowerCase().startsWith(tagName.toLowerCase()))
.toList(),
'all:' => [...users, ...topics].where((taggable) =>
taggable.name.toLowerCase().startsWith(tagName.toLowerCase())),
_ => [],
};
}
/// This method converts the backend format to the taggable object.
FutureOr<Taggable?> backendToTaggable(String prefix, String id) {
return switch (prefix) {
'@' => users.where((user) => user.id == id).firstOrNull,
'#' => topics.where((topic) => topic.id == id).firstOrNull,
'all:' => [...users, ...topics]
.where((taggable) => taggable.id == id)
.firstOrNull,
_ => null,
};
}
@override
Widget build(BuildContext context) {
// Build a list of comments made using the [TagTextEditingController].
// as well as a [TextField] to add new comments.
return Center(
child: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...comments.map((comment) {
return Card(
margin: const EdgeInsets.all(4),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text.rich(
TextSpan(
children: comment,
),
),
),
);
}),
Form(
key: _formKey,
child: CompositedTransformTarget(
link: _layerLink,
child: TextField(
controller: _controller,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: 'Type @ to tag a user or # to tag a topic',
helperText: 'Backend format: $backendFormat',
suffixIcon: IconButton(
icon: const Icon(Icons.send),
onPressed: () async {
final textSpans = await _buildTextSpans(
_controller.backendTextFormat);
setState(() {
comments.add(textSpans);
_controller.clear();
});
},
),
),
),
),
),
TextButton(
onPressed: () {
// This is an example of setting the initial text.
_controller.setInitialText(
"Hello @1ax and welcome to #myFlutterId",
backendToTaggable,
);
},
child: const Text('Set initial text'),
),
],
),
),
);
}
}