ViewStub + Databinding: handle screen states easily

Max Diland
3 min readOct 1, 2020

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.

Screen with multi states example

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 ViewStubs 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!

--

--