Together with my friend Pol I recently worked on Napkin. The Android app combines both a notepad and a calculator into a single experience. During development we stumbled upon several small challenges. This post tries to summarize our experience and learnings with regard to EditText
.
Napkin mainly consists of a single screen which acts as a text editor. To make the app more visually appealing we decided to highlight keywords, titles and variable declarations.
The basic setup is quite straightforward, after adding an EditText
to our layout we were ready to rock. In order to support multi-line text the inputType
attribute is set accordingly.
Here’s how the layout looks like:
<EditText
android:id="@+id/input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine|textNoSuggestions"
tools:text="Hello World" />
The inputType
attributes also controls the look of the keyboard. For our use case we want the keyboard to show the digit bar on top, but unfortunately there’s no flag for that. Some people on the web recommended using textVisiblePassword
but that actually has other side affects. E.g. deleting multiple characters by holding the delete key stops working. On top of that we found out that every keyboard app behaves differently, for example GBoard seemed to completely ignore textNoSuggestions
.
After trying out all possible combinations, we settled on textMultiLine|textNoSuggestions
and accepted the limitations given by the system.
We want our text editor to automatically perform formatting as you type, so we need to react on every keystroke. This is done by attaching a TextWatcher
listener to our EditText
. The android-ktx library even offers a handy Kotlin extension function to register for changes by providing a lambda function:
inputView.addTextChangedListener { text: Editable? ->
TODO("implement")
}
The EditText
class extends the TextView
class and uses the same styling mechanism: Spans. A Span
is an object which is used by TextView when rendering or layouting text.
The easiest way to create text with spans is by using the APIs provided by SpannableStringBuilder
. There are many subclasses of Span
provided by the system. Apart from the official documentation there are a lot of great blog posts about this topic. Check them out if you want to learn more!
For our use case we are simply using a ForegroundColorSpan
which applies the desired text color to our headings, variables and keywords.
setText()
after every input changeOur initial approach was to process the input text in the background, build a formatted SpannableString
and use EditText.setText()
to set the newly formatted text. This is not ideal because of several reasons:
TextWatcher
will fire after setting the new text, which means we would need to take care of avoiding an endless loop (more on that later)Luckily Android provides a better way to deal with this: Specifically EditText
: getText()
returns an Editable
object, which itself provides APIs to add and remove spans of the current input text. This allows us to apply the spans without calling EditText.setText()
. The relevant API looks like this:
interface Editable : CharSequence, GetChars, Spannable, Appendable {
fun clearSpans()
fun setSpan(what: Any!, start: Int, end: Int, flags: Int)
...
}
Happy that we’ve found a solution, we switched to using the methods above. Our approach now looked like this:
Once our background calculation is ready we:
clearSpans()
to remove all old spanssetSpan(...)
clearSpans()
when using EditText
With this new approach we noticed a new strange behavior: After setting the new spans, the EditText stopped showing a text cursor and didn’t process input anymore.
As mentioned in the beginning spans are used to style text. It turns out that text selection and the text cursor are spans too. And as you might have guessed by now clearSpans()
does a really great job at deleting ALL spans :) So calling clearSpans()
on an EditText
probably is never a good idea as it makes your EditText
completely useless.
Luckily Editable
also provides methods to retrieve all spans by class type and also a way to remove individual spans. Here’s our extension function which combines all those methods:
inline fun <reified T> Editable.removeSpans() {
val allSpans = getSpans(0, length, T::class.java)
for (span in allSpans) {
removeSpan(span)
}
}
Happy to have our text cursor back, we continued our journey and quickly stumbled across another issue…
TextWatcher
We quickly discovered that our span changes actually fired our TextWatcher listener which in turn caused an endless loop. So the listener should ignore any changes coming not from the user.
In order to achieve this we introduced a new PausableTextWatcher
class which does not fire events while being paused.
class PausableTextWatcher(val listener: (text: Editable) -> Unit) : TextWatcher {
private var active = true
override fun afterTextChanged(s: Editable?) {
if (active) {
listener(s!!)
}
}
fun pause(block: () -> Unit) = synchronized(this) {
active = false
block()
active = true
}
...
}
After replacing the old TextWatcher
with the new one, we can now simply perform our edit operations like this:
textWatcher.pause {
text.setSpan(...)
}
After we had a working solution we somehow experienced performance issues with the keyboard. The keyboard app, in our case GBoard would start to react slowly and become completely unresponsive. Even worse: Not only our app was affected, the keyboard got unresponsive in all apps across the system.
Did our little app just nuke GBoard?
Further investigation revealed that the spans seemed to be the root cause. Digging deeper in the Android source we discovered that the ForegroundColorSpan
which we used implemented ParcelableSpan
. After some wild guessing we concluded that the GBoard app receives all text and all spans on every keystroke and couldn’t properly handle this amount of data. As a workaround we’re now using our own span class which does not implement the ParcelableSpan
interface. This way our spans get ignored by the system and thus are never transferred to the GBoard app. After applying this code change - voila! - the GBoard app behaves normal again.
To summarize, styling EditTexts
is definitely not straightforward and the topic of text itself is very exhaustive. Spans are a powerful concept which allow a lot of stylistic freedom while still being able to use standard widgets like EditText
. It’s crazy (and wonderful) how many little details you need to know in order to master small parts of the Android framework.
I hope you enjoyed this small write-up. If you’re curious how the end product turned out, feel free to download Napkin from Google Play. If you have any feedback feel free to drop me a message!