You have a Fragment and you want to display a different layout depending on the situation, while keeping its behavior exactly the same and without having to duplicate the code.
The problem is that, when you use ViewBinding or DataBinding, Android generates binding classes of a different type from each layout. As a result, you can’t have a single field in the Fragment that accepts any of these binding types and at the same time gives you access to their views.
In this article we’ll look at two ways to tackle this problem, along with the advantages and disadvantages of each.
The alternative layouts Link to heading
Let’s assume we have the following two alternative layouts for a sign-in screen:
fragment_sign_in.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/sign_in_background"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp">
<ImageView
android:layout_width="96dp"
android:layout_height="96dp"
android:src="@drawable/ic_sign_in_logo"
tools:ignore="ContentDescription" />
<com.google.android.material.button.MaterialButton
android:id="@+id/get_started_button"
style="@style/Widget.SignIn.Button.Filled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
android:text="@string/get_started_button_text" />
<com.google.android.material.button.MaterialButton
android:id="@+id/sign_in_button"
style="@style/Widget.SignIn.Button.Outlined"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/sign_in_button_text" />
</LinearLayout>
fragment_sign_in_alternative.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/sign_in_alt_background"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp">
<ImageView
android:layout_width="96dp"
android:layout_height="96dp"
android:src="@drawable/ic_sign_in_alt_logo"
tools:ignore="ContentDescription" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/get_started_button"
style="@style/Widget.SignInAlt.Button.Filled"
android:layout_width="130dp"
android:layout_height="130dp"
android:text="@string/get_started_button_text" />
<com.google.android.material.button.MaterialButton
android:id="@+id/sign_in_button"
style="@style/Widget.SignInAlt.Button.Outlined"
android:layout_width="130dp"
android:layout_height="130dp"
android:layout_marginStart="16dp"
android:text="@string/sign_in_button_text" />
</LinearLayout>
</LinearLayout>
The code that follows contains the pickUI() method, which for simplicity always returns the same layout. In a real app, though, this is where the layout selection logic would go, for example a call to a ViewModel method.
Method 1: Using the ViewBinding.bind() method Link to heading
Below is the complete code of the Fragment that uses bind() to show the correct layout:
BindSolutionFragment.kt
package dev.anastasioscho.multipleinterfacefiles.bindsolution
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.LayoutRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import dev.anastasioscho.multipleinterfacefiles.R
import dev.anastasioscho.multipleinterfacefiles.databinding.FragmentSignInBinding
class BindSolutionFragment : Fragment() {
private lateinit var _binding: FragmentSignInBinding
private val binding get() = _binding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
setupBinding(inflater, container)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupButtons()
}
private fun setupBinding(inflater: LayoutInflater, container: ViewGroup?) {
val resource = pickUI()
val view = inflater.inflate(resource, container, false)
_binding = FragmentSignInBinding.bind(view)
}
@LayoutRes
private fun pickUI(): Int {
return R.layout.fragment_sign_in
}
private fun setupButtons() {
binding.getStartedButton.setOnClickListener {
showToast(R.string.get_started_toast_message)
}
binding.signInButton.setOnClickListener {
showToast(R.string.sign_in_toast_message)
}
}
private fun showToast(@StringRes stringRes: Int) {
val text = resources.getString(stringRes)
Toast.makeText(requireContext(), text, Toast.LENGTH_SHORT).show()
}
}
Definitions Link to heading
Before we continue, it’s useful to give the following definitions that we’ll use to explain this method:
- binding layout: It’s the XML layout file that ViewBinding/DataBinding uses to generate the corresponding binding class.
For example, the FragmentSignInBinding class is generated from fragment_sign_in.xml.
- view layout: It’s the XML layout file that we inflate to create the view object we pass to
bind().
How it works Link to heading
The binding object is obtained through bind() instead of the usual inflate():
_binding = FragmentSignInBinding.bind(view)
At build time, ViewBinding reads the binding layout and finds all the views that have an ID.
At runtime, bind() does the following:
- For each of the above IDs, it calls
findViewById()on the view we passed to it as a parameter. - It assigns the views it finds to the corresponding properties of the binding object.
- If
findViewById()doesn’t find a view, it throws aNullPointerException.
So, for this method to work, it’s very important that the binding layout and the view layout match in two respects:
- They must have views with the same IDs. Otherwise, we’ll get a
NullPointerException.
If, on the other hand, the view layout has extra views compared to the binding layout, there’s no problem at all.
- The views with the same IDs must have a compatible type. Otherwise, we’ll get a
ClassCastException.
Advantages Link to heading
- It uses existing Android APIs.
bind() is documented in the official docs and also appears in the samples Google provides (see the Useful links section), although it isn’t used the way we do here.
Normally, according to the docs, the binding and view layouts should be identical (that is, we use the same XML file for both cases). The docs only refer to this scenario, and that’s how they’re used in the samples.
By contrast, with the method we followed above, we can have different binding and view layouts. This, as we saw, will work. We just need to be careful to avoid the exceptions.
- It doesn’t require extra code (like the custom wrapper we’ll see below), so it needs less maintenance.
Disadvantages Link to heading
Any mismatch between the binding layout and the view layout isn’t caught at compile time, but shows up as a crash at runtime.
It works only with ViewBinding and not with DataBinding.
If we use DataBinding and have different binding and view layouts, we’ll get a runtime exception similar to the following:
java.lang.IllegalArgumentException: The tag for fragment_sign_in is invalid.
Received: layout/fragment_sign_in_alternative_0
This happens because at build time, DataBinding automatically adds a unique tag to the root view of each layout, which at runtime indicates which layout the view came from.
So, if for example we call FragmentSignInBinding.bind(view), the DataBinding mapper expects to find the tag layout/fragment_sign_in_0 (since FragmentSignInBinding corresponds to fragment_sign_in.xml). But if the view we gave it was inflated from fragment_sign_in_alternative.xml, then it carries the tag layout/fragment_sign_in_alternative_0. In this case the two tags don’t match, and DataBinding throws the above exception.
- All layouts must have a root view of the same type.
bind() casts the view we give it to the type of the binding layout’s root view.
In our example, both layouts have a LinearLayout as their root view. But if the binding layout had a LinearLayout and the view layout had a different one (e.g. a ConstraintLayout), the cast would fail at runtime with a ClassCastException.
Method 2: Custom Binding Wrapper Link to heading
Below is the complete code of the intermediate wrapper class and of the Fragment that uses it to show the correct layout:
BindingWrapper.kt
package dev.anastasioscho.multipleinterfacefiles.wrappersolution
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import com.google.android.material.button.MaterialButton
import dev.anastasioscho.multipleinterfacefiles.R
import dev.anastasioscho.multipleinterfacefiles.databinding.FragmentSignInAlternativeBinding
import dev.anastasioscho.multipleinterfacefiles.databinding.FragmentSignInBinding
class BindingWrapper(
val root: View,
val getStartedButton: MaterialButton,
val signInButton: MaterialButton
) {
companion object {
fun inflate(
inflater: LayoutInflater,
container: ViewGroup?,
@LayoutRes resource: Int
): BindingWrapper {
return when (resource) {
R.layout.fragment_sign_in -> {
val binding = FragmentSignInBinding.inflate(
inflater,
container,
false
)
from(binding)
}
else -> {
val binding = FragmentSignInAlternativeBinding.inflate(
inflater,
container,
false
)
from(binding)
}
}
}
private fun from(binding: FragmentSignInBinding) = BindingWrapper(
root = binding.root,
getStartedButton = binding.getStartedButton,
signInButton = binding.signInButton
)
private fun from(binding: FragmentSignInAlternativeBinding) = BindingWrapper(
root = binding.root,
getStartedButton = binding.getStartedButton,
signInButton = binding.signInButton
)
}
}
WrapperSolutionFragment.kt
package dev.anastasioscho.multipleinterfacefiles.wrappersolution
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.LayoutRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import dev.anastasioscho.multipleinterfacefiles.R
class WrapperSolutionFragment : Fragment() {
private lateinit var _binding: BindingWrapper
private val binding get() = _binding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
setupBinding(inflater, container)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupButtons()
}
private fun setupBinding(inflater: LayoutInflater, container: ViewGroup?) {
val resource = pickUI()
_binding = BindingWrapper.inflate(inflater, container, resource)
}
@LayoutRes
private fun pickUI(): Int {
return R.layout.fragment_sign_in_alternative
}
private fun setupButtons() {
binding.getStartedButton.setOnClickListener {
showToast(R.string.get_started_toast_message)
}
binding.signInButton.setOnClickListener {
showToast(R.string.sign_in_toast_message)
}
}
private fun showToast(@StringRes stringRes: Int) {
val text = resources.getString(stringRes)
Toast.makeText(requireContext(), text, Toast.LENGTH_SHORT).show()
}
}
How it works Link to heading
Here we create an intermediate wrapper class.
This class holds direct references to all the views we need, without caring which layout they came from. This eliminates the need for different binding types, and in the Fragment we simply keep a reference to an object of this type:
private lateinit var _binding: BindingWrapper
private val binding get() = _binding
More specifically, the wrapper’s inflate() inflates the layout using the appropriate binding class (FragmentSignInBinding or FragmentSignInAlternativeBinding). Then it calls the version of from() that takes this binding and keeps only the views we need, returning a BindingWrapper.
Advantages Link to heading
- It works with both ViewBinding and DataBinding.
- If a view is added to the wrapper but is missing from one of the layouts, the error is caught at compile time, thus avoiding the runtime crashes we have with
bind(). - We can have different views in each layout. In our example, each layout can have as many views as it wants and with whatever IDs it wants, as long as inside
from()we pass all the necessary views to the wrapper’s constructor.
Disadvantages Link to heading
- The two
from()methods are almost identical. The only thing that changes is the type of binding they take as a parameter. This repetition can’t be eliminated without losing type safety, since the only common interface of the two generated binding classes (ViewBinding) exposes only a plainViewthroughgetRoot()and not the typed views (getStartedButton,signInButton). - The wrapper has to be updated manually if we want to change the views we need.
Wrapping up Link to heading
Personally, every time I’ve faced this problem, I’ve chosen the second method for three main reasons:
- I feel more comfortable catching problems at compile time.
- I find the code clearer and more self-documenting.
- I don’t like the idea of misusing an official Android API, even if it works without any problem.
Finally, it’s worth mentioning that the problem we described in this article concerns only Android Views. With Jetpack Compose we don’t need to worry about any of this, and the code would be much simpler.
Useful links Link to heading
- https://developer.android.com/topic/libraries/view-binding
- https://developer.android.com/topic/libraries/data-binding
- https://developer.android.com/topic/libraries/data-binding/generated-binding
The next link is the official Google sample that uses bind() instead of inflate(). It’s worth noting that in the README.md, Google itself mentions that bind() isn’t used all that often: