Έχεις ένα Fragment και θέλεις να εμφανίζεις ένα διαφορετικό layout ανάλογα με την περίσταση, κρατώντας τον τρόπο λειτουργίας του ακριβώς ίδιο και χωρίς να χρειάζεται να κάνεις duplicate τον κώδικα.
Το πρόβλημα είναι ότι, όταν χρησιμοποιείς ViewBinding ή DataBinding το Android παράγει binding κλάσεις διαφορετικού τύπου από κάθε layout. Έτσι, δεν μπορείς να έχεις ένα και μοναδικό field στο Fragment που να δέχεται οποιονδήποτε από αυτούς τους binding τύπους και ταυτόχρονα να σου δίνει πρόσβαση στα views τους.
Σε αυτό το άρθρο θα δούμε δύο τρόπους για να αντιμετωπίσεις αυτό το πρόβλημα, καθώς και τα πλεονεκτήματα και τα μειονεκτήματα του καθενός.
Τα εναλλακτικά layouts Link to heading
Ας υποθέσουμε ότι έχουμε τα παρακάτω δύο εναλλακτικά layouts για ένα 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>
Στον κώδικα που ακολουθεί υπάρχει η μέθοδος pickUI(), που για λόγους απλότητας επιστρέφει πάντα το ίδιο layout. Σε μια πραγματική εφαρμογή, όμως, εδώ θα έμπαινε το layout selection logic, για παράδειγμα μια κλήση σε μια μέθοδο του ViewModel.
Μέθοδος 1: Χρήση της μεθόδου ViewBinding.bind() Link to heading
Ακολουθεί ο ολοκληρωμένος κώδικας του Fragment που χρησιμοποιεί την bind() για να δείξει το σωστό 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()
}
}
Ορισμοί Link to heading
Πριν συνεχίσουμε, είναι χρήσιμο να δώσουμε τους παρακάτω ορισμούς που θα χρησιμοποιήσουμε για να εξηγήσουμε αυτή τη μέθοδο:
- binding layout: Είναι το XML layout file το οποίο χρησιμοποιεί το ViewBinding/DataBinding για να κάνει generate την αντίστοιχη binding κλάση.
Για παράδειγμα, από το fragment_sign_in.xml γίνεται generate η κλάση FragmentSignInBinding.
- view layout: Είναι το XML layout file το οποίο κάνουμε inflate για να δημιουργήσουμε το view object που περνάμε στην
bind().
Πώς λειτουργεί Link to heading
Το binding object λαμβάνεται μέσω της bind() αντί της συνήθους inflate():
_binding = FragmentSignInBinding.bind(view)
Κατά το build, το ViewBinding διαβάζει το binding layout και εντοπίζει όλα τα views που έχουν ID.
Στο runtime, η bind() κάνει τα εξής:
- Για κάθε ένα από τα παραπάνω IDs, καλεί την
findViewById()στο view που της περάσαμε ως παράμετρο. - Τα views που βρίσκει τα κάνει assign στα αντίστοιχα properties του binding object.
- Σε περίπτωση που η
findViewById()δεν βρει κάποιο view, κάνει throw έναNullPointerException.
Για να δουλέψει, λοιπόν, αυτή η μέθοδος, είναι πολύ σημαντικό το binding layout και το view layout να ταιριάζουν σε δύο σημεία:
- Πρέπει να έχουν views με τα ίδια IDs. Σε αντίθετη περίπτωση, θα πάρουμε
NullPointerException.
Αν, από την άλλη, το view layout έχει επιπλέον views σε σχέση με το binding layout, δεν υπάρχει κανένα πρόβλημα.
- Πρέπει τα views με τα ίδια IDs να έχουν συμβατό τύπο. Σε αντίθετη περίπτωση, θα πάρουμε
ClassCastException.
Πλεονεκτήματα Link to heading
- Χρησιμοποιεί υπάρχοντα Android APIs.
Η bind() τεκμηριώνεται στα επίσημα docs και εμφανίζεται και στα samples που παρέχει η Google (δες στην ενότητα Χρήσιμα links), αν και δεν χρησιμοποιείται με τον τρόπο που το κάνουμε εδώ.
Κανονικά, σύμφωνα με τα docs, τα binding και view layouts θα πρέπει να ταυτίζονται (δηλαδή να χρησιμοποιούμε το ίδιο αρχείο XML και για τις δύο περιπτώσεις). Μόνο σε αυτό το σενάριο αναφέρονται τα docs, και έτσι χρησιμοποιούνται στα samples.
Αντίθετα, με τη μέθοδο που ακολουθήσαμε παραπάνω, μπορεί να έχουμε διαφορετικά binding και view layouts. Αυτό, όπως είδαμε, θα δουλέψει. Απλά θα πρέπει να είμαστε προσεκτικοί ώστε να αποφύγουμε τα exceptions.
- Δεν απαιτεί extra κώδικα (όπως ο custom wrapper που θα δούμε παρακάτω), οπότε χρειάζεται λιγότερη συντήρηση.
Μειονεκτήματα Link to heading
Τυχόν ασυμφωνία ανάμεσα στο binding layout και το view layout δεν εντοπίζεται κατά το compile time, αλλά εκδηλώνεται ως crash στο runtime.
Δουλεύει μόνο με ViewBinding και όχι με DataBinding.
Σε περίπτωση που χρησιμοποιούμε DataBinding και έχουμε διαφορετικά binding και view layouts, θα πάρουμε ένα runtime exception ανάλογο με το ακόλουθο:
java.lang.IllegalArgumentException: The tag for fragment_sign_in is invalid.
Received: layout/fragment_sign_in_alternative_0
Αυτό συμβαίνει διότι κατά το build, το DataBinding προσθέτει αυτόματα ένα μοναδικό tag στο root view κάθε layout, το οποίο στο runtime δείχνει από ποιο layout προήλθε το view.
Έτσι, αν για παράδειγμα καλέσουμε FragmentSignInBinding.bind(view), ο DataBinding mapper περιμένει να βρει το tag layout/fragment_sign_in_0 (αφού η FragmentSignInBinding αντιστοιχεί στο fragment_sign_in.xml). Αν το view, όμως, που του δώσαμε έχει γίνει inflate από το fragment_sign_in_alternative.xml, τότε κουβαλάει το tag layout/fragment_sign_in_alternative_0. Σε αυτή την περίπτωση τα δύο tags δεν ταιριάζουν, και το DataBinding πετάει το παραπάνω exception.
- Όλα τα layouts πρέπει να έχουν root view του ίδιου τύπου.
Η bind() κάνει cast το view που της δίνουμε στον τύπο του root view του binding layout.
Στο παράδειγμά μας, και τα δύο layouts έχουν ως root view ένα LinearLayout. Αν, όμως, το binding layout είχε LinearLayout και το view layout κάποιο άλλο (π.χ. ConstraintLayout), το cast θα αποτύγχανε κατά το runtime με ClassCastException.
Μέθοδος 2: Custom Binding Wrapper Link to heading
Ακολουθεί ο ολοκληρωμένος κώδικας της ενδιάμεσης wrapper κλάσης και του Fragment που τη χρησιμοποιεί για να δείξει το σωστό 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()
}
}
Πώς λειτουργεί Link to heading
Εδώ δημιουργούμε μια ενδιάμεση wrapper κλάση.
Η κλάση αυτή κρατάει άμεσες αναφορές σε όσα views χρειαζόμαστε, χωρίς να νοιαζόμαστε από ποιο layout προήλθαν. Έτσι, εξαλείφεται η ανάγκη για διαφορετικούς τύπους binding και στο Fragment κρατάμε απλά μια αναφορά σε ένα object αυτού του τύπου:
private lateinit var _binding: BindingWrapper
private val binding get() = _binding
Πιο συγκεκριμένα, η inflate() του wrapper κάνει inflate το layout χρησιμοποιώντας την κατάλληλη binding κλάση (FragmentSignInBinding ή FragmentSignInAlternativeBinding). Έπειτα, καλεί την εκδοχή της from() που παίρνει αυτό το binding και κρατάει μόνο τα views που χρειαζόμαστε, επιστρέφοντας ένα BindingWrapper.
Πλεονεκτήματα Link to heading
- Λειτουργεί τόσο με ViewBinding όσο και με DataBinding.
- Αν ένα view προστεθεί στον wrapper αλλά λείπει από ένα από τα layouts, το σφάλμα εντοπίζεται κατά το compile time, αποφεύγοντας έτσι τα runtime crashes που έχουμε με την
bind(). - Μπορούμε να έχουμε διαφορετικά views σε κάθε layout. Στο παράδειγμά μας, κάθε layout μπορεί να έχει όσα views θέλει και με ό,τι IDs θέλει, αρκεί μέσα στην
from()να περνάμε στον constructor του wrapper όλα τα απαραίτητα views.
Μειονεκτήματα Link to heading
- Οι δύο μέθοδοι
from()είναι σχεδόν ίδιες. Το μόνο που αλλάζει είναι ο τύπος του binding που δέχονται ως παράμετρο. Αυτή η επανάληψη δεν μπορεί να εξαλειφθεί χωρίς απώλεια type safety, αφού το μόνο κοινό interface των δύο generated binding classes (ViewBinding) εκθέτει μόνο ένα σκέτοViewμέσω τηςgetRoot()και όχι τα typed views (getStartedButton,signInButton). - Ο wrapper πρέπει να ενημερώνεται χειροκίνητα, αν θέλουμε να αλλάξουμε τα views που χρειαζόμαστε.
Κλείνοντας Link to heading
Προσωπικά, όσες φορές βρέθηκα αντιμέτωπος με αυτό το πρόβλημα, επέλεξα τη δεύτερη μέθοδο για τρεις βασικούς λόγους:
- Νιώθω πιο άνετα να εντοπίζω τα προβλήματα κατά το compile time.
- Βρίσκω τον κώδικα πιο ξεκάθαρο και self-documenting.
- Δεν μου αρέσει η ιδέα να χρησιμοποιώ καταχρηστικά ένα επίσημο Android API, ακόμη κι αν αυτό δουλεύει χωρίς κανένα πρόβλημα.
Τέλος, αξίζει να αναφέρουμε ότι το πρόβλημα που περιγράψαμε σε αυτό το άρθρο αφορά μόνο τα Android Views. Με Jetpack Compose δεν χρειάζεται να ανησυχούμε για τίποτε απ’ όλα αυτά, και ο κώδικας θα ήταν πολύ πιο απλός.
Χρήσιμα 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
Το επόμενο link είναι το επίσημο Google sample που χρησιμοποιεί την bind() αντί της inflate(). Αξίζει να παρατηρήσεις ότι στο README.md, η ίδια η Google αναφέρει ότι η bind() δεν χρησιμοποιείται και τόσο συχνά: