Custom View 探求記（TextView 継承編 その２）

# 概要
- custom view に対して、レイアウトファイルから custom attribute の設定を可能とする。
- custom attribute の設定を Android Studio のレイアウトエディタ上に反映させる。

# 前回のあらずじ
前回は、[AppCompatTextView](https://developer.android.com/reference/kotlin/androidx/appcompat/widget/AppCompatTextView) を継承して custom view を作成しました。

# 前回の不満点
**レイアウトファイルから値の指定ができない**
SDK 等から提供される多くの View は、レイアウトファイルから属性という形で初期値が設定できます。たとえば TextView であれば、レイアウトファイルから `android:text="10"` のような指定ができます。しかし、 [前回作成した UnixEpochTextView](https://qiita.com/beyondseeker/items/612b6bcee2937c98aedb#-unixepochtextview) ではできませんでした。

**アプリに組み込んで実行しないと動作を確認できない**
SDK 等から提供される多くの View はレイアウトエディタ上で動作を確認できますが、[前回作成した UnixEpochTextView](https://qiita.com/beyondseeker/items/612b6bcee2937c98aedb#-unixepochtextview) ではできませんでした。

# 今回の課題
- レイアウトファイルにて ***unixEpoch*** を設定できるようにし、レイアウトエディタ上で表示を確認する。

# 実現方法
[Define Custom Attributes](https://developer.android.com/training/custom-views/create-view#customattr) の方法により、下記のように custom attribute を指定できるようにします。

```xml
<com.objectfanatics.chrono10.ex_cv.UnixEpochTextView
    app:unixEpoch="1577804400000"/>
```

# 実装
## ◆ レイアウトファイル

unixEpoch=1577804400000 (2020-01-01 00:00:00.000 JST) を指定した場合に、以下のように表示されるレイアウトファイルを作成します。
<img width="240px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/1c788862-ab89-4da2-63ba-5ffe1c427488.png">

```xml:sample_activity.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"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <com.objectfanatics.chrono10.ex_cv.UnixEpochTextView
        android:id="@+id/unix_epoch_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:textSize="30sp"
        app:layout_constraintBottom_toTopOf="@id/show_current_time_button"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed"
        app:unixEpoch="1577804400000" />
    <Button
        android:id="@+id/show_current_time_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="show current time"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/unix_epoch_text_view" />
</androidx.constraintlayout.widget.ConstraintLayout>
```

## ◆ attrs.xml
レイアウトファイルから指定する custom attribute を定義します。
※ *styleable は Long 型をサポートしていないので String で代用しています。*

```xml:attrs.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="UnixEpochTextView">
        <attr name="unixEpoch" format="string" />
    </declare-styleable>
</resources>
```

## ◆ Custom View クラス
- [AppCompatTextView](https://developer.android.com/reference/kotlin/androidx/appcompat/widget/AppCompatTextView) を継承
- 全コンストラクタから *initAttrs(AttributeSet?)* を呼び出す
- *initAttrs(AttributeSet?)* にて custom attributes を読み出し custom view に値をセットする

```kotlin:UnixEpochTextView.kt
class UnixEpochTextView : AppCompatTextView {
    constructor(context: Context?) : super(context) {
        initAttrs(null)
    }

    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
        initAttrs(attrs)
    }

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

    private fun initAttrs(attrs: AttributeSet?) {
        getLongAttr(attrs, R.styleable.UnixEpochTextView, R.styleable.UnixEpochTextView_unixEpoch, 0, this::setUnixEpoch)
    }

    fun setUnixEpoch(unixEpoch: Long) {
        text = unixEpoch.unixEpochString
    }

    companion object {
        private val Long.unixEpochString: String
            get() = SimpleDateFormat("yyyy-MM-dd\nHH:mm:ss.SSS", Locale.US)
                .apply { timeZone = TimeZone.getTimeZone("Asia/Tokyo") }
                .format(Date(this))
    }
}
```

## ◆ Ext.kt
custom attribute を取得するためのユーティリティクラス。

```kotlin:Ext.kt
package com.objectfanatics.infra.android.view

import android.content.res.Resources
import android.content.res.TypedArray
import android.util.AttributeSet
import android.view.View

// @formatter:off

// boolean
        fun View.getBooleanAttr                     (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Boolean      , callback: (Boolean       ) -> Unit): Boolean        = getBooleanAttr                     (attrs, styleable, index, defValue).apply(callback)
        fun View.getBooleanAttr                     (attrs: AttributeSet?, styleable: IntArray, index: Int                         , callback: (Boolean       ) -> Unit): Boolean        = getBooleanAttr                     (attrs, styleable, index          ).apply(callback)
        fun View.getNullableBooleanAttr             (attrs: AttributeSet?, styleable: IntArray, index: Int                         , callback: (Boolean?      ) -> Unit): Boolean?       = getNullableBooleanAttr             (attrs, styleable, index          ).apply(callback)

        fun View.getBooleanAttr                     (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Boolean                                          ): Boolean        = getNullableBooleanAttrInternal     (attrs, styleable, index, defValue)!!
        fun View.getBooleanAttr                     (attrs: AttributeSet?, styleable: IntArray, index: Int                                                             ): Boolean        = getNullableBooleanAttrInternal     (attrs, styleable, index, null    ) ?: throw AttributeValueNotFoundException()
        fun View.getNullableBooleanAttr             (attrs: AttributeSet?, styleable: IntArray, index: Int                                                             ): Boolean?       = getNullableBooleanAttrInternal     (attrs, styleable, index, null    )

// integer
        fun View.getIntegerAttr                     (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Int          , callback: (Int           ) -> Unit): Int            = getIntegerAttr                     (attrs, styleable, index, defValue).apply(callback)
        fun View.getIntegerAttr                     (attrs: AttributeSet?, styleable: IntArray, index: Int                         , callback: (Int           ) -> Unit): Int            = getIntegerAttr                     (attrs, styleable, index          ).apply(callback)
        fun View.getNullableIntegerAttr             (attrs: AttributeSet?, styleable: IntArray, index: Int                         , callback: (Int?          ) -> Unit): Int?           = getNullableIntegerAttr             (attrs, styleable, index          ).apply(callback)

        fun View.getIntegerAttr                     (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Int                                              ): Int            = getNullableIntegerAttrInternal     (attrs, styleable, index, defValue)!!
        fun View.getIntegerAttr                     (attrs: AttributeSet?, styleable: IntArray, index: Int                                                             ): Int            = getNullableIntegerAttrInternal     (attrs, styleable, index, null    ) ?: throw AttributeValueNotFoundException()
        fun View.getNullableIntegerAttr             (attrs: AttributeSet?, styleable: IntArray, index: Int                                                             ): Int?           = getNullableIntegerAttrInternal     (attrs, styleable, index, null    )

// float
        fun View.getFloatAttr                       (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Float        , callback: (Float         ) -> Unit): Float          = getFloatAttr                       (attrs, styleable, index, defValue).apply(callback)
        fun View.getFloatAttr                       (attrs: AttributeSet?, styleable: IntArray, index: Int                         , callback: (Float         ) -> Unit): Float          = getFloatAttr                       (attrs, styleable, index          ).apply(callback)
        fun View.getNullableFloatAttr               (attrs: AttributeSet?, styleable: IntArray, index: Int                         , callback: (Float?        ) -> Unit): Float?         = getNullableFloatAttr               (attrs, styleable, index          ).apply(callback)

        fun View.getFloatAttr                       (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Float                                            ): Float          = getNullableFloatAttrInternal       (attrs, styleable, index, defValue)!!
        fun View.getFloatAttr                       (attrs: AttributeSet?, styleable: IntArray, index: Int                                                             ): Float          = getNullableFloatAttrInternal       (attrs, styleable, index, null    ) ?: throw AttributeValueNotFoundException()
        fun View.getNullableFloatAttr               (attrs: AttributeSet?, styleable: IntArray, index: Int                                                             ): Float?         = getNullableFloatAttrInternal       (attrs, styleable, index, null    )

// string
        fun View.getStringAttr                      (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: String       , callback: (String        ) -> Unit): String         = getStringAttr                      (attrs, styleable, index, defValue).apply(callback)
        fun View.getStringAttr                      (attrs: AttributeSet?, styleable: IntArray, index: Int                         , callback: (String        ) -> Unit): String         = getStringAttr                      (attrs, styleable, index          ).apply(callback)
        fun View.getNullableStringAttr              (attrs: AttributeSet?, styleable: IntArray, index: Int                         , callback: (String?       ) -> Unit): String?        = getNullableStringAttr              (attrs, styleable, index          ).apply(callback)

        fun View.getStringAttr                      (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: String                                           ): String         = getNullableStringAttrInternal      (attrs, styleable, index, defValue)!!
        fun View.getStringAttr                      (attrs: AttributeSet?, styleable: IntArray, index: Int                                                             ): String         = getNullableStringAttrInternal      (attrs, styleable, index, null    ) ?: throw AttributeValueNotFoundException()
        fun View.getNullableStringAttr              (attrs: AttributeSet?, styleable: IntArray, index: Int                                                             ): String?        = getNullableStringAttrInternal      (attrs, styleable, index, null    )

// integer array
        fun View.getIntegerArrayAttr                (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: IntArray     , callback: (IntArray      ) -> Unit): IntArray       = getIntegerArrayAttr                (attrs, styleable, index, defValue).apply(callback)
        fun View.getIntegerArrayAttr                (attrs: AttributeSet?, styleable: IntArray, index: Int                         , callback: (IntArray      ) -> Unit): IntArray       = getIntegerArrayAttr                (attrs, styleable, index          ).apply(callback)
        fun View.getNullableIntegerArrayAttr        (attrs: AttributeSet?, styleable: IntArray, index: Int                         , callback: (IntArray?     ) -> Unit): IntArray?      = getNullableIntegerArrayAttr        (attrs, styleable, index          ).apply(callback)

        fun View.getIntegerArrayAttr                (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: IntArray                                         ): IntArray       = getNullableIntegerArrayAttrInternal(attrs, styleable, index, defValue)!!
        fun View.getIntegerArrayAttr                (attrs: AttributeSet?, styleable: IntArray, index: Int                                                             ): IntArray       = getNullableIntegerArrayAttrInternal(attrs, styleable, index, null    ) ?: throw AttributeValueNotFoundException()
        fun View.getNullableIntegerArrayAttr        (attrs: AttributeSet?, styleable: IntArray, index: Int                                                             ): IntArray?      = getNullableIntegerArrayAttrInternal(attrs, styleable, index, null    )

// string array
        fun View.getStringArrayAttr                 (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Array<String>, callback: (Array<String> ) -> Unit): Array<String>  = getStringArrayAttr                 (attrs, styleable, index, defValue).apply(callback)
        fun View.getStringArrayAttr                 (attrs: AttributeSet?, styleable: IntArray, index: Int                         , callback: (Array<String> ) -> Unit): Array<String>  = getStringArrayAttr                 (attrs, styleable, index          ).apply(callback)
        fun View.getNullableStringArrayAttr         (attrs: AttributeSet?, styleable: IntArray, index: Int                         , callback: (Array<String>?) -> Unit): Array<String>? = getNullableStringArrayAttr         (attrs, styleable, index          ).apply(callback)

        fun View.getStringArrayAttr                 (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Array<String>                                    ): Array<String>  = getNullableStringArrayAttrInternal (attrs, styleable, index, defValue)!!
        fun View.getStringArrayAttr                 (attrs: AttributeSet?, styleable: IntArray, index: Int                                                             ): Array<String>  = getNullableStringArrayAttrInternal (attrs, styleable, index, null    ) ?: throw AttributeValueNotFoundException()
        fun View.getNullableStringArrayAttr         (attrs: AttributeSet?, styleable: IntArray, index: Int                                                             ): Array<String>? = getNullableStringArrayAttrInternal (attrs, styleable, index, null    )

// long by string
        fun View.getLongAttr                        (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Long         , callback: (Long          ) -> Unit): Long            = getLongAttr                       (attrs, styleable, index, defValue).apply(callback)
        fun View.getLongAttr                        (attrs: AttributeSet?, styleable: IntArray, index: Int                         , callback: (Long          ) -> Unit): Long            = getLongAttr                       (attrs, styleable, index          ).apply(callback)
        fun View.getNullableLongAttr                (attrs: AttributeSet?, styleable: IntArray, index: Int                         , callback: (Long?         ) -> Unit): Long?           = getNullableLongAttr               (attrs, styleable, index          ).apply(callback)

        fun View.getLongAttr                        (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Long                                             ): Long            = getStringAttr                     (attrs, styleable, index, defValue.toString()).toLong()
        fun View.getLongAttr                        (attrs: AttributeSet?, styleable: IntArray, index: Int                                                             ): Long            = getStringAttr                     (attrs, styleable, index                     ).toLong()
        fun View.getNullableLongAttr                (attrs: AttributeSet?, styleable: IntArray, index: Int                                                             ): Long?           = getNullableStringAttr             (attrs, styleable, index                     )?.toLong()

// internal non-array
private fun View.getNullableBooleanAttrInternal     (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Boolean?                                         ): Boolean?       = getNullableAttrInternal            (attrs, styleable, index, defValue) { typedArray: TypedArray -> typedArray.getBoolean   (index, DUMMY_BOOLEAN) }
private fun View.getNullableIntegerAttrInternal     (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Int?                                             ): Int?           = getNullableAttrInternal            (attrs, styleable, index, defValue) { typedArray: TypedArray -> typedArray.getInteger   (index, DUMMY_INT    ) }
private fun View.getNullableFloatAttrInternal       (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Float?                                           ): Float?         = getNullableAttrInternal            (attrs, styleable, index, defValue) { typedArray: TypedArray -> typedArray.getFloat     (index, DUMMY_FLOAT  ) }
private fun View.getNullableStringAttrInternal      (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: String?                                          ): String?        = getNullableAttrInternal            (attrs, styleable, index, defValue) { typedArray: TypedArray -> typedArray.getString    (index               ) }
private fun View.getNullableReferenceAttrInternal   (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Int?                                             ): Int?           = getNullableAttrInternal            (attrs, styleable, index, defValue) { typedArray: TypedArray -> typedArray.getResourceId(index, DUMMY_INT    ) }

// internal array
private fun View.getNullableIntegerArrayAttrInternal(attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: IntArray?                                        ): IntArray?      = getNullableArrayAttrInternal       (attrs, styleable, index, defValue) { resources, resId -> resources.getIntArray(resId) }
private fun View.getNullableStringArrayAttrInternal (attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: Array<String>?                                   ): Array<String>? = getNullableArrayAttrInternal       (attrs, styleable, index, defValue) { resources, resId -> resources.getStringArray(resId) }

// @formatter:on

private fun <T> View.getNullableAttrInternal(attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: T?, getValue: (TypedArray) -> T?): T? =
    when (attrs) {
        null -> defValue
        else -> context.theme.obtainStyledAttributes(attrs, styleable, 0, 0).run {
            try {
                when {
                    !hasValue(index) -> defValue
                    else             -> getValue(this)
                }
            } finally {
                recycle()
            }
        }
    }

private fun <T> View.getNullableArrayAttrInternal(attrs: AttributeSet?, styleable: IntArray, index: Int, defValue: T?, getValue: (Resources, Int) -> T?): T? =
    when (val resId: Int? = getNullableReferenceAttrInternal(attrs, styleable, index, null)) {
        null -> defValue
        else -> getValue(resources, resId)
    }

class AttributeValueNotFoundException : IllegalStateException("This attr must be set. Please set attr in layout file or set default value")

private const val DUMMY_BOOLEAN: Boolean = false
private const val DUMMY_INT: Int = 0
private const val DUMMY_FLOAT: Float = 0f
```

# 結果
**レイアウトファイル上から custom view に custom attribute を指定できた。**

```xml
<com.objectfanatics.chrono10.ex_cv.UnixEpochTextView
    app:unixEpoch="1577804400000"/>
```

**レイアウトエディタ上で custom view の動作が確認できた。**
<img width="320px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/8375401b-b716-30e2-bea4-139d0a027183.png">

# 考察
## ◆ 今回やったこと
今回は、UnixEpochTextView に custom attribute を追加し、レイアウトエディタ上から表示の確認することができました。

## ◆ styleable と Long 型
styleable は Long 型をサポートしていないため、styleable 側は String 型で定義し、プログラム側で Long 型に変換して対応しました。

これにより、実行時エラーの可能性が出てくるわけですが、現状ではこれよりベターな対策が見当たらないのでやむなしと考えています。

なお、間違った値を入れるとアプリが落ちてしまうという点については、あえてそうしています。[^1]

## ◆ その他
Ext.kt は color とか dimension とかもっと拡張すると汎用的になりそう。

## ◆ 問題点
- 画面回転などにより値がリセットされてしまう。

## ◆ 次回
次回は、意図せず画面の内容がリセットされてしまうことがないように対策してみようと思います。

# Links
第１回： [Custom View 探求記（TextView 継承編 その１）](https://qiita.com/beyondseeker/items/612b6bcee2937c98aedb)
第２回： [Custom View 探求記（TextView 継承編 その２）](https://qiita.com/beyondseeker/items/119873801b4bb07d4d5c)← <font color="red">いまココ！</font>
第３回： [Custom View 探求記（TextView 継承編 その３）](https://qiita.com/beyondseeker/items/e9e04a6fe96be9e65bda)
第４回： [Custom View 探求記（TextView 継承編 その４）](https://qiita.com/beyondseeker/items/64ca710fd408e559840d)
第５回： [Custom View 探求記（DataBindingを使うべきか使わぬべきかそれが問題だ編 その１）](https://qiita.com/beyondseeker/items/92c7598e6c55f23b1018)

[^1]: バグのための対策コードは指数関数的にコードの負債を増していくのは自明ですから。