ハロー、ノハナ社の @hiroyuki-seto です
Android Advent Calendar 2018の13日目です。
みなさん大好き1 絵文字の話です。
導入
今日は父親の誕生日なので、家族の話をしましょう。
👪
のような表記の絵文字があったとします。
この絵文字は String.length
で何文字になるでしょうか?
答えは 2
や 8
などの可能性があります。
なぜこうなるのか
👪 (Family)はUnicodeで U+1F46A
で定義されています。
これはサロゲート文字と呼ばれ、U+D83D
と U+DC6A
という2つの文字を合わせてできているのでString.length
の値は2
になります。
また、Unicodeには複数の絵文字を組み合わせて1つの絵文字にする機能が定義されています。
Familyは👨(Man, U+1F468
)とZWJ(U+200D
)と👩(Woman, U+1F469
)とZWJ(U+200D
)と👦(Boy, U+1F466
)を組み合わせても表現でき、この場合のString.length
の値は8
になります。
表記長を数えたい
弊社ではノハナというフォトブックサービスをやっています。
フォトブックの各ページでは写真にコメントをつけることができ、絵文字を入力することもできます。
コメントは32文字まで入力できるのですが、この「32文字」は String.length
の値ではなく、当然目に見える文字数、表記長です。
頑張って数える
サロゲート文字についてはCharacter.isHighSurrogate(Char)
やChar.isHighSurrogate()
やCharacter.isSurrogatePair(Char, Char)
などを使うと判定することができます。
Character.codePointCount
を使うと、ハイサロゲートとロウサロゲートを合わせて1文字とカウントしてくれます。
複数の絵文字を組み合わせる件ですが、UnicodeではRecommended Emoji ZWJ Sequencesというものを公開しています。
これを完全に再現する実装をすれば対応できます。
たかだか777個しかないので、全部実装できますね!
いや、やりたくないですね..
BreakIterator
BreakIteratorというものがあります。文字と文字の境界を判定してくれるクラスです。
こんな感じにすると、大半の絵文字はちゃんとカウントしてくれます。
fun CharSequence.getDisplayLength(): Int {
val instance = BreakIterator.getCharacterInstance()
instance.setText(text.text.toString())
instance.first()
var displayLength = 0
while (instance.next() != BreakIterator.DONE) {
displayLength++
}
return displayLength
}
しかし、💑 (Couple With Heart)を U+1F469``U+200D``U+2764``U+FE0F``U+200D``U+1F468
で表記した場合に 2
が返ってきてしまいます。
これは色々と問題になります。
EmojiCompatとSpannable
本題です。
EmojiCompatを使うと、比較的簡単に解決することができます。
EmojiCompatでは、文字列にEmojiSpanを設定することによって絵文字の表示を実現しています。
また、EmojiCompatはRecommended Emoji ZWJ Sequencesの内容を実装しており、複数の絵文字を結合した場合でも適切に表示することができます。
詳しい仕組みについては、takahiromさんのDroidKaigiの資料を参考にしてください。
👪の例で言うと、U+1F46A
やU+1F468``U+200D``U+1F469``U+200D``U+1F466
という文字列に1つのEmojiSpanを設定しています。
つまりEmojiSpanがかかっている文字列を1文字とカウントすれば絵文字の表記長を数えることができます。
コードにするとこんな感じです。
fun CharSequence.getDisplayLength(): Int {
val codePointCount = Character.codePointCount(this, 0, length)
val text = EmojiCompat.get().process(SpannableString(this))
if (text !is Spannable) {
//文字長が0の場合など
return codePointCount
}
val spans = text.getSpans(0, text.length, EmojiSpan::class.java)
if (spans.isEmpty()) {
//絵文字が含まれていない場合
return codePointCount
}
val string = this.toString()
var displayLength = 0
var i = 0
while (i < text.length) {
val span = spans.firstOrNull { i == text.getSpanStart(it) }
if (span == null) {
//charCountの分だけを1文字とみなす
val codePoint = string.codePointAt(i)
val count = Character.charCount(codePoint)
i += count
} else {
//EmojiSpanのかかっているindexまでを1文字とみなす
i = text.getSpanEnd(span)
}
displayLength++
}
return displayLength
}
対応できないこと
EmojiCompatが対応していない絵文字
EmojiCompatがSpanを設定できないので、当然文字が数えられません。
この記事を書いた時点では、Emoji 11には対応できていません。
Emoji11の機能である「髪の色を変更」した場合は正しく表記長をカウントできないと思います。
おわりに
お分かりいただけたとおり、Spannableは非常に便利なクラスです。
ただし中毒性があり、黒魔術を生み出す可能性があるので用法用量を守って正しく使いましょう。
DroidKaigi2019では弊社の @tacke_jp がUnicode絵文字についてお話しするのでご期待ください。
おまけ
Swift4では
//👪
"\u{1F46A}".count //1が返ってくる
//複数の絵文字を組み合わせた👪
"\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F466}".count //1が返ってくる
-
(要出典) ↩