dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

AppCompatViewInflaterを使って独自のTextViewをすべての画面に反映する

どうもクラシルAndroidエンジニアの@MeilCliです。今回はAndroidのちょっとした便利テクの紹介です

序文

Androidの開発をしていると極稀に標準のTextViewやImageViewを独自の実装に置き換えたくなることがありますよね*1

たとえばすべての画面で使うほど重要な処理や、なんらかの不具合に対処するワークアラウンドをすべての画面に一括で反映したいなど。androidx.appcompatはこれと同様な状況と言え、実際にLayoutInflater.Factoryを使用して一括でViewを置き換えています。具体的な挙動を申し上げると、layout.xmlで<TextView />を書いたときは自動でAppCompatTextViewに置き換えられてViewが生成されています

クラシルAndroidではTextView/Button/EditTextを独自のものに置き換える必要が生まれ、LayoutInflater.Factoryを使わずにAppCompatViewInflaterを使う方法をとったので紹介します

AppCompatViewInflaterについて

androidxのリポジトリーでLayoutInflaterを扱っている箇所を検索するとAppCompatDelegateImplが見つかります。ここでLayoutInflater.Factory(正確にはLayoutInflater.Factory2)をLayoutInflaterに設定することで、LayoutInflaterを使ったViewの生成の際にxml上のタグをAppCompat系のViewに置き換えて生成しています

実装を追っていくとcreateView(View, String, Context, AttributeSet)においてAppCompatViewInflaterを生成して、それを利用して実際のViewを生成しています。また、AppCopatViewInflaterを生成の際にはThemeからviewInflaterClassを取得してリフレクションで生成するクラスを置き換えれるようになっています

if (mAppCompatViewInflater == null) {
    TypedArray a = mContext.obtainStyledAttributes(R.styleable.AppCompatTheme);
    String viewInflaterClassName =
            a.getString(R.styleable.AppCompatTheme_viewInflaterClass);
    if (viewInflaterClassName == null) {
        // Set to null (the default in all AppCompat themes). Create the base inflater
        // (no reflection)
        mAppCompatViewInflater = new AppCompatViewInflater();
    } else {
        try {
            Class<?> viewInflaterClass =
                    mContext.getClassLoader().loadClass(viewInflaterClassName);
            mAppCompatViewInflater =
                    (AppCompatViewInflater) viewInflaterClass.getDeclaredConstructor()
                            .newInstance();
        } catch (Throwable t) {
            Log.i(TAG, "Failed to instantiate custom view inflater "
                    + viewInflaterClassName + ". Falling back to default.", t);
            mAppCompatViewInflater = new AppCompatViewInflater();
        }
    }
}

AppCompatDelegateImpl.createViewから抜粋

また、AppCompatViewInflaterでは各Viewの生成メソッドがprotectedになっているため、AppCompatViewInflaterを継承してメソッドをオーバーライドすれば独自に作成したViewに一括で置き換えることができます

クラシルでは以下のようにクラスを作り、Themeに設定しました

import android.content.Context
import android.util.AttributeSet
import androidx.annotation.Keep
import androidx.appcompat.app.AppCompatViewInflater
import androidx.appcompat.widget.AppCompatButton
import androidx.appcompat.widget.AppCompatEditText
import androidx.appcompat.widget.AppCompatTextView

@Keep
class KurashiruViewInflater : AppCompatViewInflater() {

    override fun createTextView(context: Context?, attrs: AttributeSet?): AppCompatTextView {
        return ContentTextView(context, attrs)
    }

    override fun createButton(context: Context?, attrs: AttributeSet?): AppCompatButton {
        return ContentButton(checkNotNull(context), attrs)
    }

    override fun createEditText(context: Context?, attrs: AttributeSet?): AppCompatEditText {
        return ContentEditText(checkNotNull(context), attrs)
    }
}
<style name="BaseTheme" parent="Theme.MaterialComponents.Light.NoActionBar.Bridge">
    <item name="viewInflaterClass">path.to.KurashiruViewInflater</item>
</style>

TextViewとかを一括で置き換えたいことってあまりないんじゃ?

UI定義によってはそうかもしれません。クラシルAndroidではTypographyの設定でlineHeight*2やletterSpacingなどのTextAppearanceでは設定できない値を一括で反映させたい需要がありました

どういうことかというと、TextViewに対する属性の一括設定にはstyleを指定する方法とandroid:textAppearanceを指定する方法があります。styleはすべての属性を指定できますが、画面ごとやパーツごとにstyleを指定したい場面があるので、Typographyの指定ではandroid:textAppearanceで指定する方法のほうが何かと便利です。しかし、android:textAppearanceではTypographyとして設定したい属性すべてに対応していません*3。そのためクラシルAndroidで使うTypography向けにTextViewなどを拡張する必要があったのです

実装

すべてを書くと長いので結構省きますがだいたい以下の感じです

<resources>
    <declare-styleable name="ContentTextAppearance" parent="TextAppearance">
        <attr name="android:textSize" />
        <attr name="android:lineHeight" />
        <attr name="lineHeight" />
        <attr name="letterSpacingCompat" />
        <attr name="fakeBold" />
    </declare-styleable>
</resources>
import android.content.Context
import androidx.annotation.Px
import androidx.annotation.StyleRes

object ContentTextAppearance {

    @Px
    fun getLineHeight(context: Context, @StyleRes textAppearance: Int, @Px defaultValue: Int): Int {
        val typedArray = context.obtainStyledAttributes(textAppearance, R.styleable.ContentTextAppearance)
        var result = defaultValue
        if (typedArray.hasValue(R.styleable.ContentTextAppearance_android_lineHeight)) {
            result = typedArray.getDimensionPixelSize(R.styleable.ContentTextAppearance_android_lineHeight, defaultValue)
        }
        if (typedArray.hasValue(R.styleable.ContentTextAppearance_lineHeight)) {
            result = typedArray.getDimensionPixelSize(R.styleable.ContentTextAppearance_lineHeight, defaultValue)
        }
        typedArray.recycle()
        return result
    }

    fun getLetterSpacing(context: Context, @StyleRes textAppearance: Int): Float {
        val typedArray = context.obtainStyledAttributes(textAppearance, R.styleable.ContentTextAppearance)
        val letterSpacing = typedArray.getDimension(R.styleable.ContentTextAppearance_letterSpacingCompat, 0f)
        val textSize = typedArray.getDimensionPixelSize(R.styleable.ContentTextAppearance_android_textSize, 0)
        typedArray.recycle()
        return if (textSize == 0) 0f else letterSpacing / textSize
    }

    fun getFakeBoldEnabled(context: Context, @StyleRes textAppearance: Int): Boolean {
        val typedArray = context.obtainStyledAttributes(textAppearance, R.styleable.ContentTextAppearance)
        return try {
            typedArray.getBoolean(R.styleable.ContentTextAppearance_fakeBold, false)
        } finally {
            typedArray.recycle()
        }
    }
}
import android.content.Context
import android.graphics.Paint
import android.util.AttributeSet
import androidx.core.widget.TextViewCompat
import androidx.emoji.widget.EmojiAppCompatTextView

open class ContentTextView : EmojiAppCompatTextView {

    constructor(context: Context?) : super(context) {
        initialize(null, android.R.attr.textViewStyle)
    }

    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
        initialize(attrs, android.R.attr.textViewStyle)
    }

    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        initialize(attrs, defStyleAttr)
    }

    private fun initialize(attrs: AttributeSet?, defStyleAttr: Int) {
        initializeLineHeight(attrs, defStyleAttr)
        initializeLetterSpacing(attrs, defStyleAttr)
        initializeFakeBold(attrs, defStyleAttr)
    }

    private fun initializeLineHeight(attrs: AttributeSet?, defStyleAttr: Int) {
        val textAppearanceResourceId = findTextAppearanceResourceId(attrs, defStyleAttr)
        val textAppearanceLineHeight = if (0 <= textAppearanceResourceId)
            ContentTextAppearance.getLineHeight(context, textAppearanceResourceId, -1)
        else
            -1
        if (0 <= textAppearanceLineHeight) {
            TextViewCompat.setLineHeight(this, textAppearanceLineHeight)
        }
    }

    private fun initializeLetterSpacing(attrs: AttributeSet?, defStyleAttr: Int) {
        if (textSize == 0f) {
            return
        }

        val textAppearanceResourceId = findTextAppearanceResourceId(attrs, defStyleAttr)
        val textAppearanceLetterSpacing = if (0 <= textAppearanceResourceId)
            ContentTextAppearance.getLetterSpacing(context, textAppearanceResourceId)
        else
            -1f
        if (0 <= textAppearanceLetterSpacing) {
            letterSpacing = textAppearanceLetterSpacing
        }
    }

    private fun initializeFakeBold(attrs: AttributeSet?, defStyleAttr: Int) {
        val textAppearanceResourceId = findTextAppearanceResourceId(attrs, defStyleAttr)
        val fakeBoldEnabled = ContentTextAppearance.getFakeBoldEnabled(context, textAppearanceResourceId)
        paintFlags = if (fakeBoldEnabled) {
            paintFlags or Paint.FAKE_BOLD_TEXT_FLAG
        } else {
            paintFlags and Paint.FAKE_BOLD_TEXT_FLAG.inv()
        }
    }

    @Deprecated("")
    override fun setTextAppearance(context: Context, resId: Int) {
        @Suppress("DEPRECATION")
        super.setTextAppearance(context, resId)

        val textAppearanceLineHeight = if (0 <= resId) ContentTextAppearance.getLineHeight(context, resId, -1) else -1
        if (0 <= textAppearanceLineHeight) {
            TextViewCompat.setLineHeight(this, textAppearanceLineHeight)
        }

        val textAppearanceLetterSpacing = if (0 <= resId) ContentTextAppearance.getLetterSpacing(context, resId) else -1f
        if (0 <= textAppearanceLetterSpacing) {
            letterSpacing = textAppearanceLetterSpacing
        }

        val fakeBoldEnabled = ContentTextAppearance.getFakeBoldEnabled(context, resId)
        paintFlags = if (fakeBoldEnabled) {
            paintFlags or Paint.FAKE_BOLD_TEXT_FLAG
        } else {
            paintFlags and Paint.FAKE_BOLD_TEXT_FLAG.inv()
        }
    }

    private fun findTextAppearanceResourceId(attrs: AttributeSet?, defStyleAttr: Int): Int {
        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ContentTextView, defStyleAttr, 0)
        val result = typedArray.getResourceId(R.styleable.ContentTextView_android_textAppearance, -1)
        typedArray.recycle()
        return result
    }
}

ContentTextViewを使うことでTextAppearanceでlineHeightfakeBoldの指定を行えたり、letterSpacingをsp単位で指定できたりします。これをすべての画面で使いたかったということですね

ちなみにですが、ContentTextViewの由来は名前が思いつかなかったからというのが大きいですが、ユーザーコンテンツの部分で特にTypographyを頑張りたかったからユーザーコンテンツのコンテンツから取ってContentTextViewです。基底クラスであればあるほど名付けにくい現象があるのでMyTextViewとかKurashiruTextViewとかでも良かったかもしれませんね(笑)

終わりに

今回クラシルAndroidではAppCompat系のViewの置き換えとして独自のViewを使いたかったのでAppCopatViewInflaterを使用しました。しかし、AppCompat系以外のものはこの方法ではできません。その場合は頑張ってLayoutInflater.Factoryを作りましょう

*1:なければブラウザバックしてどうぞ

*2:MaterialTextViewを使うとTextAppearanceでlineHeightを設定できます

*3:https://developer.android.com/guide/topics/ui/look-and-feel/themes?hl=ja#textappearance