Flutter Tutorial
Flutter: Medium-like Text Editor
Building a simplified version of the text editor used in the Medium mobile app
In the following tutorial, we‘re going to build a simplified version of the text editor used in the ‘New Story’ section of the Medium mobile app. Afterward, your app should look like this:
A short analysis of the ‘New Story’ Section in the Medium App
If you navigate to the ‘New story’ section in the Medium app, you’ll notice that the keyboard toolbar is apparent at the bottom of the screen even if the keyboard is not visible. When the keyboard appears, the toolbar moves up to display itself above the keyboard. If you create two text blocks, you’ll see that trying to select text from multiple blocks will fail. You can only do so for one text block at a time. Therefore, we can see that Medium probably uses a ListView
of TextFields
with different stylings.
Before Getting Started
First, lets’s create a new Flutter project
flutter create medium_text_editor
Now, we add the following packages to the pubspec.yaml
inside the dependencies:
provider: ^4.1.3
community_material_icon: ^5.3.45
keyboard_visibility: ^0.5.6
and install the packages with
flutter packages get
Part 1: Text Formatting using a Keyboard Toolbar
Keep it simple and focus on what matters.
Don’t let yourself be overwhelmed.
- Confucius -
In the first part, we will keep the implementation as simple as possible. We will focus on displaying a keyboard toolbar that changes the text formatting of a text field on button clicks.
Text Formatting
We will only implement headings, plain text, bullet points, and quotes as text formatting options. Let’s specify the text style and padding for each option.
Note: We used the Unicode character \u2002
as a prefix for the bullet points.
Customizable Keyboard Toolbar
Next, we will build the keyboard toolbar that we will display above the keyboard. The Toolbar is just a Row
of IconButtons
that set the selected formatting type on a click.
Note: We won’t use the keyboard_actions package as it is not very flexible and leads to lots of weird implementations.
Connecting the Keyboard Toolbar and Text Formatting
For now, state management is quite easy and only requires to update the selected text type. Since it will become more complicated in the second part, we’ll use the Provider
framework for simplicity.
Note: The EditorProvider
notifier will become more useful later on.
Now that we defined our ChangeNotifier
, we need to initialize our state using theChangeNotifierProvider.
To display the toolbar above the keyboard, we simply use Stack
and position our SmartTextField
with bottom: 56
and our Toolbar
with bottom: 0.
The resizeToAvoidBottomInset
property of Scaffold
will automatically adjust the layout if a keyboard is visible so that the toolbar is displayed above.
Style Matters
Since it’s always more pleasant to code if things don’t look like shit, we modify the theme in the main.dart
.
Ta-da🎉
Part 2: Going from Single-Line to a Full-Text Editor
Now that we implemented the single TextField
case, it is time to build a fully functional text editor.
Enriching the State Management
As mentioned before, we need a ListView
of TextFields
. Therefore, we will store three lists in the EditorProvider
:
List<SmartTextType>
contains the formatting style for each blockList<TextEditingController>
contains the controllers for each block that we can use to listen to user inputList<FocusNode>
contains aFocusNode
for each block that we will use to manage the focus of theTextFields
For simplicity and readability, we create the following five getters that we will use at multiple parts in the code:
- length: returns the number of text blocks
- focus: returns the index of the block that has the current focus
- nodeAt: returns the
FocusNode
at a certain index - textAt: returns the
TextEditingController
at a certain index - typeAt: returns the
SmartTextType
at a certain index
Additionally, we will add a setFocus()
method to update the keyboard toolbar when the user changes the focus to another text block and an insert()
method to add a new text block.
Ok, let’s update our EditorProvider
using the following implementation:
Now that we changed the EditorProvider
to store a list of text blocks, we need to update our TextEditor
to display a list of SmartTextFields
. To do so, go to the implementation of the TextEditor
, and at line 31 replace the Consumer
with the following code.
Consumer<EditorProvider>(
builder: (context, state, _) {
return ListView.builder(
itemCount: state.length,
itemBuilder: (context, index) {
return Focus(
onFocusChange: (hasFocus) {
if (hasFocus) state.setFocus(state.typeAt(index));
},
child: SmartTextField(
type: state.typeAt(index),
controller: state.textAt(index),
focusNode: state.nodeAt(index),
)
);
}
);
}
)
Note: We used the Focus
widget to easily listen to focus changes and update the selectedType
of the EditorProvider
Creating & Deleting Text Blocks
Okay, now that we updated our state management and text editor to store and display a list of text blocks, we need to implement the logic of creating and deleting blocks. If the user presses enter, we need to create a new TextField
and switch the focus to it. If the user presses the backspace key to remove a text block, we need to erase the text or merge it with the text block above.
We will first go over the logic and code snippets on how to handle the two events. Afterward, I’ll show you the full code you need to copy into the insert
method of the EditorProvider
. So bear with me!
Handling the Remove Event
To detect the remove event, we’ll use a small trick. We use the zero-width space Unicode character \u200B
as a reference for the start of a new line via
final TextEditingController controller = TextEditingController(
text: '\u200B' + (text?? '')
);
If the user then presses the backspace key and removes the starting character, i.e. \u200B
, we detect a remove event, delete the focused text block, and move the focus to the text block above.
if (!controller.text.startsWith('\u200B')) {
final int index = _text.indexOf(controller);
if (index > 0) {
textAt(index-1).text += controller.text;
nodeAt(index-1).requestFocus();
_text.removeAt(index);
_nodes.removeAt(index);
_types.removeAt(index);
}
}
Note: We concatenate the text of the two text blocks via += controller.text
so that the user can easily merge two separate blocks into one.
Handling the Enter Event
Next, we need to detect when the user pressed the Enter key. Since our TextFields
use the multiline keyboard type (i.e. keyboardType: TextInputType.multiline
), we can check if the TextEditingController
contains the Unicode character \n
which represents a line break. If it does, we gonna split the text and move the part after the \n
character to the next text block that we create on-the-go. This approach is very important, as it allows the user to split an existing text block into two parts. Here you can see the implementation of the logic described above.
if(controller.text.contains('\n')) {
final int index = _text.indexOf(controller);
List<String> _split = controller.text.split('\n');
controller.text = _split.first;
insert(
index: index+1,
text: _split.last
);
nodeAt(index+1).requestFocus();
}
When pressing enter on a bullet point, it is very intuitive that another one appears below. So, we modify the call of the insert
function a bit, as you can see in the following snippet.
insert(
index: index+1,
text: _split.last,
type: typeAt(index) == SmartTextType.BULLET
? SmartTextType.BULLET
: SmartTextType.T
);
The Implementation
Combining the above steps leads us to the following implementation of the TextEditingController
and Listener
that we can paste into the beginning of the insert
function of the EditorProvider.
Hiding the Keyboard Toolbar
While we only implemented a simplified version of the text editor from the Medium mobile app, there is one thing that we can easily improve. In the medium app, the toolbar is visible at the bottom even if the keyboard is not. This is certainly not a problem, but from a design point of view, not very appealing. But good news, it is easy to fix using the keyboard_visibility package.
First, import the keyboard_visibility package to text_editor.dart
via
import 'package:keyboard_visibility/keyboard_visibility.dart';
and then add the following to _TextEditorState
:
bool showToolbar = false;@override
void initState() {
super.initState();
KeyboardVisibilityNotification().addNewListener(
onChange: (isVisible) {
setState(() {
showToolbar = isVisible;
});
},
);
}@override
void dispose() {
KeyboardVisibilityNotification().dispose();
super.dispose();
}
Then, also in _TextEditorState
, wrap the second Positioned
widget with the following if-statement:
if (showToolbar) Positioned(
bottom: 0,
...
And, that’s it!
Full Code
You can find the final code here. Or check out Peter Aleksander Bizjak repository who converted my idea into a usable flutter library.