どういうことがしたいのか
こんなUIをMVVMで簡潔に実現したい。
- ユーザーに心拍数(下限、上限)等の一定範囲データを設定させる
- その際、実装コストを減らしたい
- なるべく現代的に仕上げたい
- 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
を加算してもとの値にしている
- このとき、string.xmlのフォーマッタを適用している
- 計算がダルければ、表示テキスト用の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 }