You have an Android custom view and you’re using drawText() from the Canvas API to display text.
If the text contains emoji, some of them might not be supported, and you’ll see the □ symbol (tofu) in their place.
In this article we’ll look at why this happens, why the problem only occurs in custom views and not in regular TextViews, and how to fix it.
First things first: the code Link to heading
Below you’ll find the code that solves the problem.
If you’re also interested in understanding how it works, the following sections explain the key points.
EmojiCanvasView.kt
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import androidx.emoji2.text.EmojiCompat
class EmojiCanvasView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val textPaint = TextPaint().apply {
color = Color.BLACK
textSize = 64f
isAntiAlias = true
}
private val rawText = "Hello! 🥰 🫠 🐦🔥"
private var staticLayout: StaticLayout? = null
private val emojiInitCallback = object : EmojiCompat.InitCallback() {
override fun onInitialized() {
post { buildLayout(width) }
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
EmojiCompat.get().registerInitCallback(emojiInitCallback)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
EmojiCompat.get().unregisterInitCallback(emojiInitCallback)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
buildLayout(w)
}
private fun buildLayout(width: Int) {
if (width <= 0) return
val processed = try {
EmojiCompat.get().process(rawText) ?: rawText
} catch (e: IllegalStateException) {
rawText
}
staticLayout = StaticLayout.Builder
.obtain(processed, 0, processed.length, textPaint, width)
.build()
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
staticLayout?.draw(canvas)
}
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<dev.anastasioscho.canvasemoji.EmojiCanvasView
android:id="@+id/emojiCanvasView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
MainApplication.kt
import android.app.Application
import androidx.emoji2.bundled.BundledEmojiCompatConfig
import androidx.emoji2.text.EmojiCompat
import java.util.concurrent.Executors
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
EmojiCompat.init(
BundledEmojiCompatConfig(this, Executors.newSingleThreadExecutor())
)
}
}
AndroidManifest.xml (application property)
android:name=".MainApplication"
build.gradle.kts (:app)
implementation("androidx.emoji2:emoji2-bundled:1.6.0")
When does tofu appear? Link to heading
If you’re using a custom font that doesn’t include emoji (Poppins, for example), Android falls back to the system emoji font. But if the system font is old and doesn’t recognize a particular emoji, you’ll see tofu in its place.
Each version of Android supports emoji up to a specific Unicode version. Emoji added after that version aren’t supported and are displayed as □.
For example, 🥰 was added in Unicode 11.0 (2018). A device running Android 8.0 (API 26) doesn’t know about it and will show tofu instead.
Using the EmojiCompat library Link to heading
When we call EmojiCompat.get().process(rawText), EmojiCompat replaces any emoji the device doesn’t support with glyphs from an emoji font, using EmojiSpan objects inside a CharSequence object.
Emoji that are already present in the device’s emoji font remain as-is.
Using StaticLayout instead of drawText() Link to heading
If we pass the CharSequence object above to drawText(), we’ll still see the tofus. That’s because drawText() completely ignores spans.
The same will happen if we convert the CharSequence to a String by calling toString(), for instance. In fact, drawText() calls toString() under the hood, which means the spans are lost.
The solution is to use StaticLayout.
This is the class Android uses internally when it needs to render text in a TextView. It supports Spanned text, and by extension the EmojiSpan objects inside the CharSequence.
With StaticLayout, there are two things to keep in mind:
- Instead of Paint, you need to use TextPaint. TextPaint is a subclass of Paint and is required by StaticLayout.
- You need to rebuild the StaticLayout every time the text, the TextPaint, or the view’s dimensions change.
Why do TextViews work without any extra setup? Link to heading
When we use AppCompatActivity, every TextView in our XML layouts is automatically converted to an AppCompatTextView during inflation.
AppCompatTextView has built-in EmojiCompat support, so emoji are displayed correctly without us having to do anything extra.
Downloadable fonts vs Bundled fonts Link to heading
There are two versions of the library:
- androidx.emoji2:emoji2
If you have appcompat:1.4+, you don’t need to add the dependency to your Gradle file or initialize it in your code. emoji2 comes along automatically as a transitive dependency and is initialized automatically via manifest merging.
It uses downloadable fonts, meaning it downloads the emoji font through the Google Play fonts provider while the app is running.
If the device doesn’t have Google Play Services, the emoji font won’t be downloaded and unsupported emoji will remain □.
- androidx.emoji2:emoji2-bundled
The emoji font (NotoColorEmoji) is bundled directly in the APK/Bundle, at a cost to the overall size (roughly 10 MB). This way, there’s no dependency on Google Play Services.
Unlike emoji2, emoji2-bundled must be added to your Gradle file manually.
Once you do that, the automatic initialization that happens with appcompat:1.4+ is disabled, so you’ll need to initialize EmojiCompat manually in the onCreate of your application subclass, before any view is created.
Waiting for the Emoji font to load Link to heading
If you need to display text before the emoji font has finished loading, you’ll see tofu in place of emoji again. This applies to both versions of the library, since they both load the emoji font asynchronously.
To avoid this, you need to use EmojiCompat.InitCallback to know whether and when loading has completed.
Another option is to use the emoji2-bundled version and initialize EmojiCompat on the main thread:
BundledEmojiCompatConfig(this, ContextCompat.getMainExecutor(this))
This way you don’t need to use EmojiCompat.InitCallback. However, it blocks the main thread and delays app startup, so you should avoid it.
Fun fact Link to heading
The □ symbol is called tofu because those rectangular boxes visually resemble the white rectangular pieces of the food of the same name, which is made from soy milk and has its roots in China.
The term originated in Japan, where the problem was particularly widespread due to the complexity of the Japanese writing system.
Useful links Link to heading
https://developer.android.com/develop/ui/views/text-and-emoji/emoji2#appcompat