Έχεις ένα Android custom view και χρησιμοποιείς την drawText() από το Canvas API για να εμφανίσεις κείμενο.
Εάν το κείμενο περιέχει emoji, υπάρχει περίπτωση κάποια από αυτά να μην υποστηρίζονται και να βλέπεις το σύμβολο □ (tofu) στη θέση τους.
Σε αυτό το άρθρο θα δούμε γιατί συμβαίνει αυτό, γιατί έχεις το πρόβλημα μόνο στα custom views και όχι στα κανονικά TextView, καθώς και πώς να το διορθώσεις.
Πρώτα απ’ όλα, ο κώδικας Link to heading
Παρακάτω θα βρεις τον κώδικα που λύνει το πρόβλημα.
Αν σε ενδιαφέρει να καταλάβεις και πώς λειτουργεί, τα επόμενα sections εξηγούν τα κύρια σημεία του.
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")
Πότε εμφανίζεται το tofu; Link to heading
Αν χρησιμοποιείς κάποιο custom font που δεν περιέχει emoji, για παράδειγμα Poppins, το Android κάνει fallback στο system emoji font. Αν όμως το system font είναι παλιό και δεν γνωρίζει το συγκεκριμένο emoji, θα δεις το tofu στη θέση του.
Κάθε έκδοση Android υποστηρίζει emoji μέχρι ένα συγκεκριμένο Unicode version. Emoji που προστέθηκαν μετά δεν υποστηρίζονται και εμφανίζονται ως □.
Για παράδειγμα, το 🥰 προστέθηκε στο Unicode 11.0 (2018). Ένα device με Android 8.0 (API 26) δεν το γνωρίζει και θα εμφανίσει το tofu στη θέση του.
Χρήση της βιβλιοθήκης EmojiCompat Link to heading
Όταν καλούμε την EmojiCompat.get().process(rawText), η EmojiCompat αντικαθιστά τα emoji που δεν υποστηρίζει το device με glyphs από μια emoji font, χρησιμοποιώντας EmojiSpan objects μέσα σε ένα CharSequence object.
Τα emoji που υπάρχουν ήδη στο device emoji font παραμένουν ως έχουν.
Χρήση StaticLayout αντί για drawText() Link to heading
Εάν το παραπάνω CharSequence object το περάσουμε στην drawText(), θα συνεχίσουμε να βλέπουμε τα tofus. Αυτό συμβαίνει γιατί η drawText() αγνοεί εντελώς τα spans.
Το ίδιο θα συμβεί αν μετατρέψουμε το CharSequence σε String, καλώντας για παράδειγμα την toString(). Στην πραγματικότητα, η drawText() καλεί την toString() under the hood, οπότε τα spans χάνονται.
Η λύση είναι να χρησιμοποιήσουμε το StaticLayout.
Αυτή είναι η κλάση που χρησιμοποιεί και το Android internally όταν θέλει να κάνει render ένα κείμενο σε ένα TextView. Υποστηρίζει Spanned text, κατ’ επέκταση και τα EmojiSpan μέσα στο CharSequence.
Με την StaticLayout υπάρχουν δύο πράγματα που πρέπει να προσέξεις:
- Αντί για Paint πρέπει να χρησιμοποιήσεις TextPaint. Το TextPaint είναι υποκλάση του Paint και απαιτείται από το StaticLayout.
- Πρέπει να ξαναχτίζεις το StaticLayout κάθε φορά που αλλάζει το text, το TextPaint, ή οι διαστάσεις του view.
Γιατί τα TextView δουλεύουν χωρίς να κάνουμε τίποτα; Link to heading
Όταν χρησιμοποιούμε AppCompatActivity, κάθε TextView στα XML layouts μετατρέπεται αυτόματα σε AppCompatTextView κατά το inflation.
Το AppCompatTextView έχει ενσωματωμένο EmojiCompat support, οπότε τα emoji εμφανίζονται σωστά χωρίς να χρειάζεται να κάνουμε κάτι επιπλέον εμείς.
Downloadable fonts vs Bundled fonts Link to heading
Υπάρχουν δύο εκδόσεις της βιβλιοθήκης:
- androidx.emoji2:emoji2
Αν έχεις appcompat:1.4+, δε χρειάζεται να προσθέσεις το dependency στο gradle, ούτε να το αρχικοποιήσεις στον κώδικά σου. Το emoji2 έρχεται αυτόματα ως transitive dependency και αρχικοποιείται αυτόματα μέσω manifest merging.
Χρησιμοποιεί downloadable fonts, δηλαδή κατεβάζει το emoji font μέσω του Google Play fonts provider την ώρα που τρέχει η εφαρμογή.
Αν το device δεν έχει Google Play Services, το emoji font δεν κατεβαίνει και τα unsupported emoji παραμένουν □.
- androidx.emoji2:emoji2-bundled
Το emoji font (NotoColorEmoji) συμπεριλαμβάνεται απευθείας στο APK/Bundle, με ένα κόστος στο συνολικό size (περίπου 10MB). Έτσι, δεν υπάρχει εξάρτηση από το Google Play Services.
Σε αντίθεση με το emoji2, το emoji2-bundled πρέπει να το προσθέσεις manually στο gradle.
Μόλις το κάνεις αυτό, η αυτόματη αρχικοποίηση που γίνεται με το appcompat:1.4+ απενεργοποιείται, οπότε θα πρέπει να αρχικοποιήσεις το EmojiCompat manually στην onCreate του application subclass, πριν δημιουργηθεί οποιοδήποτε view.
Αναμονή μέχρι να φορτώσει το emoji font Link to heading
Αν χρειαστεί να δείξεις το κείμενο πριν ολοκληρωθεί η φόρτωση του emoji font, θα δεις και πάλι το tofu στη θέση των emoji. Αυτό ισχύει και για τις δύο εκδόσεις της βιβλιοθήκης, διότι φορτώνουν το emoji font ασύγχρονα.
Για να μην έχεις πρόβλημα, θα πρέπει να χρησιμοποιήσεις το EmojiCompat.InitCallback για να ξέρεις αν και πότε έχει ολοκληρωθεί η φόρτωση.
Ένας άλλος τρόπος είναι να χρησιμοποιήσεις την έκδοση emoji2-bundled και να κάνεις initialize το EmojiCompat στο main thread:
BundledEmojiCompatConfig(this, ContextCompat.getMainExecutor(this))
Με αυτόν τον τρόπο δε χρειάζεται να χρησιμοποιήσεις το EmojiCompat.InitCallback. Όμως μπλοκάρεις το main thread και καθυστερείς την έναρξη του app, οπότε πρέπει να τον αποφύγεις.
Fun fact Link to heading
Το σύμβολο □ ονομάζεται tofu γιατί αυτά τα ορθογώνια κουτάκια θυμίζουν οπτικά τα λευκά ορθογώνια κομμάτια του ομώνυμου φαγητού που φτιάχνεται από γάλα σόγιας και έχει τις ρίζες του στην Κίνα.
Ο όρος προήλθε από την Ιαπωνία, όπου το πρόβλημα ήταν ιδιαίτερα έντονο λόγω της πολυπλοκότητας της ιαπωνικής γραφής.
Χρήσιμα links Link to heading
https://developer.android.com/develop/ui/views/text-and-emoji/emoji2#appcompat