eaglesakuraの技術ブログ

技術的な話題とか、メモとか。

RangeBarで始めるCustomViewとDataBindingとMVVM

どういうことがしたいのか

こんなUIをMVVMで簡潔に実現したい。

f:id:eaglesakura:20180608174849g:plain

  • ユーザーに心拍数(下限、上限)等の一定範囲データを設定させる
  • その際、実装コストを減らしたい
    • なるべく現代的に仕上げたい
  • UIは RangeBar を利用する
    • RangeBarを操作すると、対応する値が表示される
    • RangeBarでは最大値1000のようなインデックス値のみ設定でき、min-maxのような範囲は指定できない
  • ViewとModel分離の利点として、UIに設定可能な値と実際の値を分離することができる
    • Modelを単純値のシンプルなまま、UIをカスタムすることができる
    • 実際に設定されるViewの変数と保存される値の取次をViewModelが担当する

設計方針

  • Android Architecture ComponentsのMVVMを使用
  • Viewはほぼレイアウトファイルのみ
  • 入力値は範囲を絞る
    • 人間にとって心拍0はありえないので、入力値を限定させる
  • データは整数値で保存する

RangeBarをDataBinding対応にする

DataBindingを行うための拡張

  • @BindingAdapter でViewModel -> Viewの一方通行
  • @InverseBindingAdapter で View <-> ViewModel の双方向
    • ここではViewModelにRangeBarのインデックス値を設定させている
@InverseBindingAdapter(attribute = "range")
fun RangeBar.getRange(): Range<Int> {
    return Range.create(leftIndex, rightIndex)
}

@BindingAdapter("range")
fun RangeBar.setRange(range: Range<Int>?) {
    @Suppress("NAME_SHADOWING")
    val range = range ?: return

    if (range.upper <= range.lower      // validation error.
            || range.lower == leftIndex && range.upper == rightIndex    // not modified.
    ) {
        return
    }
    setThumbIndices(range.lower, range.upper)
}

@BindingAdapter("tickCount")
fun RangeBar.bindTickCount(value: Int) {
    this.setTickCount(value)
}

ViewModel

  • val heartrateRange = MutableLiveData<Range<Int>>() にViewが持つIndex値を保存する
  • commitHeartrateRange() で保存されるときにIndex値を変更してあげる
  • このViewModelでは、実際に保存するときとViewに渡される値に HEARTRATE_RANGE_OFFSET ぶんの差が発生するので、ViewModelがソレを吸収する
class FitnessViewModel : AppViewModel() {
    private val fitnessRepository: FitnessRepository by lazy { FitnessRepository.getInstance() }

    private val serivce: FitnessService by lazy { FitnessService() }

    val heartrateRange = MutableLiveData<Range<Int>>()

    override fun attach(owner: ViewModelOwner) {
        super.attach(owner)

        heartrateRange.value = serivce.heartrateRange.let {
            Range.create(it.min - HEARTRATE_RANGE_OFFSET, it.max - HEARTRATE_RANGE_OFFSET)
        }
    }

    fun commitHeartrateRange() {
        val current = heartrateRange.value ?: return
        launch {
            // HeartrateRangeはRaw値のため、ここで値を変換する
            serivce.setHeartrateRange(HeartrateRange(
                    current.lower + HEARTRATE_RANGE_OFFSET,
                    current.upper + HEARTRATE_RANGE_OFFSET
            ))
        }
    }

    companion object {
        @JvmStatic
        val HEARTRATE_RANGE_OFFSET = 50

        @JvmStatic
        val HEARTRATE_RANGE_MAX = 200
    }
}

XML設定

  • app:tickCount 固定値だが、ViewModelの定数を計算で表示している
  • app:range は双方向なので、 @={ViewModelの変数名} を使用する
<com.edmodo.rangebar.RangeBar2
    app:tickCount="@{fitness.HEARTRATE_RANGE_MAX - fitness.HEARTRATE_RANGE_OFFSET}"
    app:range="@={fitness.heartrateRange}"
    android:id="@+id/HeartrateRange"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"/>

RangeBarを継承せずにカスタムイベントを追加する

  • RangeBarの仕様上、値が変更されるたびにコールバックが飛んでくる
    • RangeBarの値を50から100に移動させると、50,51,52.....100のようにすべての値でコールバックが飛んでくる
  • そのたびにストレージに書き込むと、無駄な書き込みが発生する
  • ユーザーが値を決定した(指を離した)際のListenerは定義されていない
  • カスタムで作り、DataBindingにも反映させる
  • app:onRangeComplete というAttributeを作成した
  • OnCompleteListener には引数付きだが、DataBinding側では引数なしのラムダ式を書いても問題なくコード生成してくれる
  • これにより、 ViewModelの commitHeartrateRange() を確定タイミングでのみ行わせることができる
interface OnCompleteListener {
    fun onIndexChangeCompleted(view: RangeBar, leftThumbIndex: Int, rightThumbIndex: Int)
}

@BindingAdapter("onRangeComplete")
fun RangeBar.setOnCompleteListener(listener: OnCompleteListener?) {
    if (listener == null) {
        setOnTouchListener(null)
    } else {
        setOnTouchListener { _, event ->
            if ((event.action and MotionEvent.ACTION_UP) != 0) {
                listener.onIndexChangeCompleted(this, leftIndex, rightIndex)
            }
            return@setOnTouchListener false
        }
    }
}
<com.edmodo.rangebar.RangeBar2
        app:onRangeComplete="@{() -> fitness.commitHeartrateRange()}"
        />

RangeBarの変更を別なViewへ反映する

  • RangeBarが変更されたら、実際の設定値をユーザーに表示させなければならない
  • これもレイアウトXMLに記述できる
    • 変更通知はLiveDataが受け持っているため、複雑な設定は不要
  • app:valueText で、テキストを設定している
    • このとき、string.xmlのフォーマッタを適用している
      • @string/HogeFuga(値)で参照できる
      • safeUnbox() で囲わないとLintに怒られる場合がある
    • 計算もできるので、表示するときには fitness.HEARTRATE_RANGE_OFFSET を加算してもとの値にしている
  • 計算がダルければ、表示テキスト用のLiveDataをViewModel側に用意しても良いかもしれない
<org.andriders.ace.component.activity.widget.AppKeyValueView
        app:keyText="Heartrate"
        app:valueText="@{@string/BodyHeartrateFormat(safeUnbox(fitness.heartrateRange.lower) + fitness.HEARTRATE_RANGE_OFFSET, safeUnbox(fitness.heartrateRange.upper) + fitness.HEARTRATE_RANGE_OFFSET)}"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>
    <string name="BodyHeartrateFormat">%1$d bpm - %2$d bpm</string>

発生した問題

LiveDataの変更をしても値が反映されない場合

  • 生成されるLayoutBindingクラスにLifecycleOwnerを渡していないとそうなる
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        binding = ProfileBinding.inflate(inflater, container, false).also { binding ->
            binding.setLifecycleOwner(activity)
            binding.fitness = fitnessViewModel
        }
        return binding.root
    }