entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

パルシィでは縁取り可能なTextViewを自作している

ピクシブ株式会社で主にアプリ開発を担当している @m4kvn です。普段は、パルシィ(Palcy)のAndroidアプリ版の開発をしています。

play.google.com

以前には次のような記事も書いています。

inside.pixiv.blog

inside.pixiv.blog

今回は、パルシィで利用するために作成している縁取り文字用のTextViewについて、どのように実装しているのかを紹介します。

縁取り文字ってなに?

次の画像の文字列のように、まわりに色がついていて強調表現された文字のことです。

パルシィでは、この縁取られた文字を使って表現したい箇所が複数あります。そのため専用のTextViewを作りました。

どのように実現するか?

TextViewの onDraw(Canvas?) をオーバーライドし、strokeを適用した文字列を描画したあと、その上にstroke未適用時の文字列を描画します。このとき super.onDraw(canvas) を使うことで、TextViewにもともとあった機能をそのまま使えるようにしています。

override fun onDraw(canvas: Canvas?) {
    ...
    val restoreColor = currentTextColor
    val restorePaint = Paint(paint)

    // はじめにstrokeを適用したテキストを描画する
    paint.style = Paint.Style.STROKE
    paint.strokeJoin = strokeJoin
    paint.strokeMiter = strokeMiter
    paint.strokeWidth = strokeWidth
    setTextColor(strokeColor)
    super.onDraw(canvas)
    // 上記で描画したテキストの上にもともとのテキストを描画する
    paint.style = restorePaint.style
    paint.strokeJoin = restorePaint.strokeJoin
    paint.strokeMiter = restorePaint.strokeMiter
    paint.strokeWidth = restorePaint.strokeWidth
    setTextColor(restoreColor)
    super.onDraw(canvas)
    ...
}

これを実装したTextViewで次のように HTML.fromHtml で変換した値を設定してみます。

val html = "<big><big>BIG</big></big> and normal"
val spanned = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
outlineTextView.text = spanned

すると次のような結果になり縁取り文字を表現できました。

HTMLで文字色を指定した場合にうまく描画できない

次のような <font color=””> を含む文字列を使った場合に、そこで指定した色で塗りつぶされた文字が描画されてしまいます。

val html = "<font color=\"red\">colored</font> <big><big>NOT</big></big> colored"
val spanned = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
outlineTextView.text = spanned

どうやらForegroundColorSpanが設定されていた場合に setTextColor(color) をしても上書きされてしまうらしいです。そのため onDraw(canvas) をする前にForegroundColorSpanを取り除いたテキストを作成して縁取りの描画をしてから元々の文字列に戻して中の文字を描画するようにしてみます。

override fun onDraw(canvas: Canvas?) {
    ...
    val restoreString = text
    if (text is Spanned) {
        val spannable = text.toSpannable()
        val fgSpans = spannable.getSpans<ForegroundColorSpan>()
        fgSpans.forEach { spannable.removeSpan(it) }
        text = spannable // ForegroundColorSpanを除いたものを設定
    }
    setTextColor(strokeColor)
    super.onDraw(canvas) // 縁取り用の描画
    ...
    text = restoreString // ForegroundColorSpanを含めたものを再設定
    setTextColor(restoreColor)
    super.onDraw(canvas) // 中の文字用の描画
    ...
}

すると思っていたような結果になりました。

最終的なコード

こちらが最終的に完成したTextViewのコードになります。ComposeでのAndroidViewで利用することが前提なのでコンストラクタが1つしかありませんが、XMLから利用する場合はコンストラクタの追加や、AttributeSetの処理が必要になります。

class OutlineTextView(context: Context) : AppCompatTextView(context) {
    private var strokeColor: Int? = null
    private var strokeWidth: Float = 1f
    private var strokeJoin: Paint.Join = Paint.Join.MITER
    private var strokeMiter: Float = 10f

    fun setStroke(
        width: Float = strokeWidth,
        color: Int? = strokeColor,
        join: Paint.Join = strokeJoin,
        miter: Float = strokeMiter,
    ) {
        strokeWidth = width
        strokeColor = color
        strokeJoin = join
        strokeMiter = miter
    }

    @SuppressLint("DrawAllocation")
    override fun onDraw(canvas: Canvas?) {
        val strokeColor = strokeColor ?: return super.onDraw(canvas)
        val restoreString = text
        val restoreColor = currentTextColor

        // ForegroundColorSpanがある状態ではstrokeColorが上書きされてしまう
        // なのでForegroundColorSpanを削除したtextを別途つくりtextを上書きする
        if (text is Spanned) {
            val spannable = text.toSpannable()
            val fgSpans = spannable.getSpans<ForegroundColorSpan>()
            fgSpans.forEach { spannable.removeSpan(it) }
            text = spannable
        }
        val restorePaint = Paint(paint)
        paint.style = Paint.Style.STROKE
        paint.strokeJoin = strokeJoin
        paint.strokeMiter = strokeMiter
        paint.strokeWidth = strokeWidth
        setTextColor(strokeColor)
        super.onDraw(canvas)
        text = restoreString // textを上書きしている可能性があるため元に戻す
        paint.style = restorePaint.style
        paint.strokeJoin = restorePaint.strokeJoin
        paint.strokeMiter = restorePaint.strokeMiter
        paint.strokeWidth = restorePaint.strokeWidth
        setTextColor(restoreColor)
        super.onDraw(canvas)
    }
}
20191219012842
makun
2018年4月新卒入社。講談社と共同で開発しているパルシィというアプリのAndroid版を担当する。エンジニア採用や育成も行う。