Making a dynamic type-to-tag dialog that’s auto pop up in Flutter

Phi Phụng Tạ
4 min readJul 14, 2021

--

Hi, I guess many of you guys have seen this. A fully automatic dialog that pops up whenever you try to tag anything using prefixes like @ or #. Take the one from Facebook as an example.

This is a common feature that you probably will need to do when it comes to these kinds of applications such as social media, chat, … This is why I am going to show you how to implement this and get this ready for production with a small package which is TypeAheadTextFieldController.

Add this to pubspec.yaml

dependencies:
flutter:
sdk: flutter
type_ahead_text_field:

Import

import 'package:type_ahead_text_field/type_ahead_text_field.dart';

Well, that’s enough of an introduction, let’s dive into the main part.

First, what we need to do is break this feature into smaller ones as well as how TypeAheadTextFieldController can solve that.

  • Create callbacks that will run whenever the user types designated prefixes. And let’s not forget when the user types more and more that the cursor moves out of the word that has matched prefix
onStateChanged: (state) {
if (state != null && (filterState == null || filterState != state)) {
filterState = state;
bhMatchedState.add(state);
}

if (state != null) {
if (overlayEntry == null) {
showSuggestionDialog();
}
} else {
removeOverlay();
}
});
  • Suggest a list of data based on detected prefix. For instance, @ for friends, # for trending tags
  • One TextField can handle multiple types of prefixes. For instance, it can detect @,#, and more
suggestibleData: data.toSet()
  • Suggestion items are based on the detected prefix
final List<String> users = ['john', 'josh', 'lucas', 'don', 'will'];
final List<String> tags = ['meme', 'challenge', 'city'];
data.addAll(users.map((e) => SuggestedDataWrapper(id: e, prefix: '@')));
data.addAll(tags.map((e) => SuggestedDataWrapper(id: e, prefix: '#')));
TypeAheadTextFieldController(
appliedPrefixes: ['@', '#'].toSet(),
suggestibleData: data.toSet(), ...)
  • Place the suggestion dialog right at the detected prefix and when the TextField cursor is placed right after it. You can get the PrefixMatchState once the controller detected prefixes
class PrefixMatchState {
late final String prefix;
late final String text;
Offset? offset;

PrefixMatchState(this.prefix, this.text, {this.offset});
}
  • Move the suggestion dialog as user type which is making the offset of the TextField cursor changed. With Facebook, only the Y offset of it is variable since Facebook’s suggestion dialog width is constant. TypeAheadTextFieldController supports checking the X offset because who knows, you may need it ;)
  • What about if the text length is so big that the TextField scroll down. In that case, we will need to recalculate the X. My solution is to provide the TextField controller and get the position
TextField(
key: tfKey,
readOnly: readOnly,
scrollController: controller?.scrollController,
controller: controller,...)
  • You can custom the TextSpan for words that match different prefixes and you can add dynamic data. For instance, UserModel or TagModel. CustomSpan will be only applied to text that added to approved data
customSpanBuilder: (SuggestedDataWrapper data) {
///data.id
///data.prefix
///data.item
return customSpan(data);
},...
ListView.builder(
itemBuilder: (context, index) {
var item = filteredData[index];
return GestureDetector(
child: ListTile(
title: Text('${item.id}'),
),
onTap: () {
controller!.approveSelection(
filterState!, item);
removeOverlay();
},
);
},
itemCount: filteredData.length,
),
  • What if the user adds an item but then decides to delete the text? You can listen to onRemove callback to see what has been removed
onRemove: (data) {
WidgetsBinding.instance?.addPostFrameCallback((timeStamp) {
setState(() {});
});
},

Let’s take a look at what we’ve got

Implementation:

TypeAheadTextFieldController

controller = new TypeAheadTextFieldController(
appliedPrefixes: ['@', '#'].toSet(),
suggestibleData: data.toSet(),
textFieldKey: tfKey,
edgePadding: EdgeInsets.all(6),
onRemove: (data) {
WidgetsBinding.instance?.addPostFrameCallback((timeStamp) {
setState(() {});
});
},
customSpanBuilder: (SuggestedDataWrapper data) {
///data.id
///data.prefix
///data.item
return customSpan(data);
},
onStateChanged: (PrefixMatchState? state) {
if (state != null && (filterState == null || filterState != state)) {
filterState = state;
bhMatchedState.add(state);
}

if (state != null) {
if (overlayEntry == null) {
showSuggestionDialog();
}
} else {
removeOverlay();
}
});

suggestionDialog

offset = controller!.calculateGlobalOffset(
context, snapshot.data?.offset,
dialogHeight: 300, dialogWidth: 200);
return offset != null
? Stack(
children: [
AnimatedPositioned(
key: suggestionWidgetKey,
duration: Duration(milliseconds: 300),
left: (offset.dx),
top: (offset.dy),

TextField

TextField(
key: tfKey,
readOnly: readOnly,
scrollController: controller?.scrollController,
controller: controller,
maxLines: 10,
decoration: InputDecoration.collapsed(hintText: 'Description'),
cursorHeight: 14,
cursorWidth: 2,
onSubmitted: (s) {
removeOverlay();
},
)

Source code: https://github.com/phungtp97/flutter-type-ahead-controller/tree/master/example

Thanks for reading! please like, comment, and leave a star on my Github page if you like this package. If there are any issues, please contact me I will always be willing to improve!

--

--