ViewStub + Databinding: handle screen states easily
Nowadays we could not imagine our lives without the internet. We retrieve tons of bits using our phones, tablets, watches, and laptops every day. Our mood depends on internet connection stability: when the connection is poor we sometimes are getting crazy.
The developer's mission is not only to cover happy-path scenarios but also to implement graceful handling of the cases when something goes wrong.
Every regular screen that retrieves data from the internet potentially may be at least in 4 states:
- loading
- empty
- error
- displaying the retrieved data
Sometimes depending on the requirements, the number of states could be even bigger.
The idea behind the approach is not to use any custom libs and have pretty declarative layout XMLs to manage the desired screen states.
Coding part 💻
First of all, let’s introduce @BindingConversion
to let data binding understand how to applyboolean
for a View
visibility:
@BindingConversion
fun convertBooleanToVisibility(isVisible: Boolean): Int {
return if (isVisible) View.VISIBLE else View.GONE
}
Next, there is a pretty simple view model which acts like it is doing some background work and updates the state
:
class ViewModel {
val state = MutableLiveData<State>()
val retryClickListener = View.OnClickListener { loadData() }
private var counter: Int = 0
init {
loadData()
}
fun loadData() {
state.value = State.Loading
Handler(Looper.getMainLooper())
.postDelayed(
{
when (counter) {
0 -> state.value = State.Error
1 -> state.value = State.Empty
else -> state.value = State.Done
}
counter++
}, 3000
)
}
sealed class State {
object Loading : State()
object Empty : State()
object Done : State()
object Error : State()
}
}
Activity code is pretty simple and it is responsible only for the tying up data binding and the view model:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding: ActivityMainBinding = DataBindingUtil
.setContentView(this, R.layout.activity_main)
binding.viewModel = ViewModel()
binding.lifecycleOwner = this
}
}
In the View model the field val state = MutableLiveData<State>()
is responsible to keep the current screen state and activity layout relies on this field and shows/hides different views depending on it:
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
>
<data>
<variable
name="viewModel"
type="viewstub.databinding.ViewModel"
/>
<import
type="viewstub.databinding.ViewModel.State"
alias="State"
/>
</data>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:refreshing="@{viewModel.state == State.Loading.INSTANCE}"
app:onRefreshListener="@{() -> viewModel.loadData()}"
android:enabled="true"
>
<FrameLayout
android:layout_height="match_parent"
android:layout_width="match_parent"
>
<ViewStub
android:id="@+id/no_data_stub"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout="@layout/no_data"
android:visibility="@{viewModel.state == State.Empty.INSTANCE}"
app:inflatedVisibility="@{viewModel.state == State.Empty.INSTANCE}"
/>
<ViewStub
android:id="@+id/error_stub"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout="@layout/error"
android:visibility="@{viewModel.state == State.Error.INSTANCE}"
app:inflatedVisibility="@{viewModel.state == State.Error.INSTANCE}"
app:onClick="@{viewModel.retryClickListener}"
/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="@{viewModel.state == State.Done.INSTANCE}"
>
<ImageView
android:id="@+id/imageView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
app:layout_constraintWidth_percent="0.6"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintBottom_toTopOf="@id/textView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_twotone_data_usage"
android:tint="#2222BB22"
/>
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Some data"
android:textSize="36sp"
android:textAlignment="center"
android:layout_marginTop="24dp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/imageView"
app:layout_constraintBottom_toBottomOf="parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</layout>
In the layout, there are 2 ViewStub
s which potentially may inflate the following layouts: @layout/no_data
, @layout/error
.
What is worth to pay attention to, are those attributesandroid:visibility
and app:inflatedVisibility
. The thing is that ViewStub
inflates lazily a defined layout and the inflated layout replaces the ViewStub
itself. There are 2 ways to provoke the ViewStub
to be inflated: set visibility, call inflate()
. So android:visibility
is used to provoke the ViewStub
to inflate whereas app:inflatedVisibility
is used to control the visibility of the further inflated layout. That actually explains why android:visibility
and app:inflatedVisibility
attributes have the same expressions.
The typical XML layout of no data/error state:
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
>
<data>
<variable
name="inflatedVisibility"
type="Boolean"
/>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="@{inflatedVisibility}"
> ... </androidx.constraintlayout.widget.ConstraintLayout>
<layout>
That’s pretty much it!
The cool thing with this approach is that it is easy to reuse layouts that represent different states for any other screen and so we get the consistent experience from screen to screen. Another advantage of the approach that it allows passing any data binding variables to the state layouts.
Also, we are not limited by the number of states and there is no specific API to orchestrate the states: all the interactions are straightforward and are done through the conjunction of the LiveData
, data binding and ViewStub
.
Whole example project on GitHub
Happy coding!