tl;dr
-
LiveData<Hoge>
であってもLiveData#getValue
の初期の戻り値はnull - 条件式次第ではデータバインディング時にnullチェックを忘れるとUI要素のちらつきの原因になるよ
ボタンがちらつく
通常非表示のボタンを、ある条件を満たす時だけ表示させるよう振る舞わせようとしていた時のことです。
データバインディングとLiveDataを使ってButtonのvisibilityを制御しようと考えました。
visibilityなら普通Booleanのデータで制御するのが普通ですが、今回はString型のデータで制御する必要がありました。
1文字以上の文字列なら表示、空文字なら非表示といった具合です。
data class Entity(val value: String)
class Repository {
fun getData(): LiveData<Entity> {
return liveData(GlobalScope.coroutineContext) {
// 時間のかかる処理
val data = ...
emit(data)
}
}
}
class MainViewModel(repository: Repository) : ViewModel() {
...
val data: LiveData<Entity> = repository.getData()
...
}
<data>
<import type="android.view.View" />
<variable
name="viewModel"
type="qchr.nonnulllivedata.ui.main.MainViewModel" />
</data>
...
<Button
...
android:visibility="@{!viewModel.data.value.empty? View.VISIBLE: View.GONE}"
...
/>
...
private val viewModel: MainViewModel by viewModels {
MainViewModel.Factory(Repository())
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View {
return MainFragmentBinding.inflate(inflater, container, false).apply {
lifecycleOwner = viewLifecycleOwner
viewModel = this@MainFragment.viewModel
}.root
}
...
そして、実際に空文字のデータを与えて画面を表示させてみました。
ボタンが非表示なることを期待していたところ……実際にはボタンがちらつく。
最終的にボタンは非表示になっているのですが、画面が表示された一瞬だけボタンが表示されてしまう。
ちょうど、冒頭に貼ったgifの上側にあるNGボタンみたいな挙動をしてしまいます(一瞬だとgif化できなかったので表示されている時間は誇張しています)。
なぜちらついた?
なぜちらついたのでしょうか?
原因はfragment_main.xml
における条件式内でのviewModel.data
(MainViewModel#data
)変数の取り扱いにありました。
1つずつ見ていきましょう。
MainViewModel#data
はRepository#getData
から取得していますが、その中ではREST APIを叩き返ってきたデータを整形し、……といくらか時間のかかる処理を行っていました。
class Repository {
fun getData(): LiveData<Entity> {
return liveData(GlobalScope.coroutineContext) {
// 時間のかかる処理
val data = ...
emit(data)
}
}
}
emit(data)
が実行されるまで、MainViewModel#data
は初期状態にあるため、data
のgetValue
はnullを返します。
それを念頭においてfragment_main.xml
をもう一度見てみましょう。
...
<Button
...
android:visibility="@{!viewModel.data.value.empty? View.VISIBLE: View.GONE}"
...
/>
viewModel.data
のgetValue
がnullを返すとき、!viewModel.data.value.empty
はどう評価されるのでしょうか?
答えは評価が中断されるです。
データバインディングで生成されるコードではgetValueのnullチェックを行っています。
nullチェックに引っかかった場合、そこで評価が中断され部分式の結果(viewModel.data.value.empty
)としては初期値であるfalseを返します。
今回の条件式は頭に否定(!
)が入っているのでfalseが反転してtrueになり、最終的な結果としてvisibilityはView.VISIBLEとなります。
これがちらつきの原因です。
どうすればいいか
ではどうすればいいのでしょうか?
答えは単純で、viewModel.data
のgetValue
がnullになることを見越した条件式にすればよいのです。
...
<Button
...
android:visibility="@{(viewModel.data != null && !viewModel.data.value.empty)? View.VISIBLE: View.GONE}"
...
/>
<!-- レイアウトファイルはxmlなので&&が&&にエスケープされています。 -->
こうすると生成されたコードは最初に部分式viewModel.data != null
を評価してくれます。
viewModel.data
のgetValue
がnullを返す場合、上記部分式はfalseになるので、条件式全体としてもfalseを返すようになり、visibilityはView.GONEとなります。
振る舞いとしては、冒頭のgifの下側になります(常にボタンが非表示なのでわかりにくいですが……)
雑感
データバインディングは便利なんですが、その便利な部分を担っている自動生成コードが隠されてしまっているために落とし穴にはまりやすく、イヤーな思いをすることも多いですね。