どうもクラシル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でlineHeight
やfakeBold
の指定を行えたり、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