-
Notifications
You must be signed in to change notification settings - Fork 1
Fragments
According to the application architecture, there is only one single Activity
in the application, and all the other screens must be fragments. In the following documentation, we will assume we have a fragment named Example. The fragment will have a TextInputLayout
to enter a name and a MaterialButton
that renders a dialogue when pressed.
Each fragment consists of three different files:
-
ExampleFragment
: The fragment class itself with the slightest possible logic -
ExampleState
: The different states of the fragment -
ExampleViewModel
: TheViewModel
associated with the fragment
In this documentation file, we will describe the steps to follow when implementing a fragment.
The first step is to implement the user interface of the fragment in its corresponding XML file, being fragment_example.xml in this case. In order to ease the reading we have to use significant names in cammel case, which have to start with a given prefix depending on the View
we are using. For instance, all MaterialButton
must start with btn and all TextInputLayout
, with txt. Warning: we must never set an id for the TextInputEditText
contained in the ThextInputLayout
.
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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:padding="@dimen/app_padding"
tools:context=".features.example.ExampleFragment">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/txtInputName"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/input_your_name">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="match_parent" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnOk"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/app_padding"
android:text="@android:string/ok" />
</LinearLayout>
</FrameLayout>
The next step is to define the states of the fragment. There are already three defined states which describe usual events that take place in the fragment:
-
Loading
: The fragment is being loaded -
NoInternet
: There is no internet connection -
OtherError
: There is an unexpected error -
ExecutingUseCase
: A use case is currently being executed
We will create a sealed class called ExampleState
which extends from ScreenState
. Inside the class, we will create as many subclasses of ExampleState
as specific events can occur in the fragment. If the state does not have any parameter, it must be an object
, while if it has at least one, it must be a class
. We have to consider that any state either shows a dialogue or changes the fragment , but it will never verify the input of the data. For instance, there will never be a state when the TextInputLayout
is empty.
sealed class ExampleState : ScreenState() {
class NameExists(val name: String) : ExampleState()
class NameDisplayed(val name: String) : ExampleState()
}
A ViewModel
is a class that contains the logic of the fragment, being aware of the Android lifecycle. There are some features we must take into consideration when implementing them.
Each ViewModel
has to be annotated with @HiltViewModel
and has a constructor which allows us to inject the dependencies. If we have a use case called SendName
, then the class would be like this:
@HiltViewModel
class MainViewModel @Inject constructor(
private val sendName: SendName
) : ViewModel() {}
The ViewModel of the fragments has to extend from BaseViewModel
instead of ViewModel
. This class has a LiveData
containing the ScreenState
of the fragment and the following protected methods:
-
loadState()
: sets the current state of the fragment. -
executeUseCase()
: executes a use case, setting the state toExecutingUseCase
, preparing its input and dealing with its result.
In order to deal with input validation that shows an error in the TextInputLayout
we have use the ViewModel
. For each one we will have a private MutableLiveData
and its corresponding LiveData
and a method with one parameter that validates the input and returns the validation result.
@HiltViewModel
class MainViewModel @Inject constructor(
private val sendName: SendName
) : ViewModel() {
private val _isNameEmpty = MutableLiveData<Boolean>()
val isNameEmpty: LiveData<Boolean>
get() = _isNameEmpty
fun verifyName(name: String): Boolean {
_isNameEmpty.value = name.isEmpty()
return _isNameEmpty.value!!
}
}
In a ViewModel
all public methods have to start with the prefix on, including the verification ones. We will create a method to deal with the input name called onNameIntroduced()
.
@HiltViewModel
class MainViewModel @Inject constructor(
private val sendName: SendName
) : ViewModel() {
private val _isNameEmpty = MutableLiveData<Boolean>()
val isNameEmpty: LiveData<Boolean>
get() = _isNameEmpty
fun onNameIntroduced(name: String) {}
fun onVerifyName(name: String): Boolean {
_isNameEmpty.value = name.isEmpty()
return _isNameEmpty.value!!
}
}
Next we have to implement a UseCaseResultHandler
to deal with the use case result. The handler needs to be passed a method for loading the ScreenState when succeeding and another one when it fails. We can assume that the SendName
use case returns an exception when the name already exists in a database. The onError
method must not take into consideration the common exceptions. If you do not consider all the exceptions that can be returned by the use case an else field might be required, being OtherError
the state you should load in that case.
private val sendNameResultHandler = UseCaseResultHandler<SendName.Result>(
onSuccess = { result -> ExampleState.NameDisplayed(result.name) },
onError = { exception ->
when (exception) {
is UserException.NameExists -> ExampleState.NameExists(exception.name)
}
}
)
Now it is time to implement the method that executes the use case. All the logic of the ViewModel has to be used within a coroutine, which can be launched with the viewModelScope
property.
fun onNameIntroduced(name: String) {
viewModelScope.launch {
// ...
}
}
Next we need to perform the input validation by calling the onVerifyName()
method. If the field is invalid, then we have to return from the launch
method.
fun onNameIntroduced(name: String) {
viewModelScope.launch {
if (!onVerifyName(name)) return@launch
// ...
}
}
Finally, if the input is valid we can execute the use case with the executeUseCase
method. We have to pass the sendName
, the sendNameResultHandler
and a method to create the input of the use case.
fun onNameIntroduced(name: String) {
viewModelScope.launch {
if (!onVerifyName(name)) return@launch
executeUseCase(sendName, sendNameResultHandler) {
SendName.Request(name)
}
}
}
Next, we can implement the ExampleFragment
. Since all the logic is in the ExampleViewModel
, the fragment class will only deal with presentation issues.
Each Fragment
has to be annotated with @AndroidEntryPoint
. In contrast with view models, we do not need to use a constructor with @Inject
.
@AndroidEntryPoint
class ExampleFragment() : Fragment() {}
The fragments of the application have to extend from BaseFragment
instead of the usual Fragment
. This abstract class has two abstract properties to be defined in the fragment:
- `ViewModel: the ViewModel of the fragment
-
screenStateHandler
: the handler for dealing with the current screen state
In addition, the class overrides the onCreateView()
and onViewCreated()
methods, in order to set up the context of the ScreenStatehandler
and to observe the changes of the screen state.
@AndroidEntryPoint
class ExampleFragment() : BaseFragment() {}
Next, we have to implement the ViewModel
and screenStateHandler
properties. On the one hand, to create the ViewModel
, we need to initialise it lazily by delegating it to the ViewModel()
method. You have to specify the view model class either when declaring the variable or when calling the viewModels()
method. We strongly recommend using the first option since it makes the reading easier.
override val viewModel: MainViewModel by viewModels()
On the other hand, to implement the screenStateHandler
property we need to pass a method that receives as its parameters the context and the current state, and that performs an action depending on the state that has been loaded. Here we are using the Context.showDialog()
extension function. If you need to use the context of the application, you have to use the first lambda parameter, which is recommended to be called context
. If you need to use any resource, you should use these variables instead of requireContext()
, since the handler will be created before the context is assigned.
override val screenStateHandler = ScreenStateHandler<ExampleState> { context, state ->
val (title, message) = when (state) {
is ExampleState.NameExists -> {
context.getString(R.string.name_exists_title) to
context.getString(R.string.name_exists_description, state.name)
}
is ExampleState.NameDisplayed -> {
context.getString(R.string.name_displayed_title) to
context.getString(R.string.name_displayed_description, state.name)
}
}
context.showDialog(title, message)
}
For the common states, there is a default implementation:
-
Loading
: it does nothing -
NoInternet
: it displays a dialogue asking to check the internet connection -
OtherError
: it displays a dialogue saying that there has been an unexpected error and asking to contact the support service -
UseCaseExecuting
: it displays an indeterminate circular progress indicator and disables the interface
In order to change these implementations you can pass some methods as parameters of the ScreenStateHandler
constructor. Here we are using the Context.toast()
extension function.
override val screenStateHandler = ScreenStateHandler<ExampleState>(
onLoading = { context -> context.toast(R.string.loading) },
onNoInternet = { context -> context.toast(R.string.no_internet) },
onOtherError = { context -> context.toast(R.string.other_error) },
onUseCaseExecuting = { context -> context.toast(R.string.use_case_executing })
) {
// ...
}
To get the views of the fragment, we have to use view binding. It generates a class called FragmentExampleBinding
, which has an attribute for each view with an id in the fragment. The use of findViewById()
and kotlin synthetic is not allowed.
We have to declare a property with private lateinit var
as we need the fragment to be attached to an activity to create the binding in the onCreateView()
method.
private lateinit var binding: FragmentExampleBinding
In the fragment we have to override the onCreateView()
and onViewCreated()
methods. We have to call the superclass respective methods as the first instruction of each method to initialise the fragment correctly.
On the one hand, in onCreateView()
we have to create the binding by calling the FragmentExampleBinding.inflate()
method. The View
that will be returned by this method is binding.root
. We should not make anything else in this method.
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
super.onCreateView(inflater, container, savedInstanceState)
binding = FragmentExampleBinding.inflate(inflater, container,)
return binding.root
}
On the other hand, in onViewCreated()
method we should call the FragmentExampleBinding.bind()
and the ExampleViewModel.observe()
extensions functions, which will be created in the following sections. You are allowed to introduce here as much code as you want, but it is strongly advisable to only call these two functions.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.bind()
viewModel.observe()
}
In order to set up the views of the fragment, we have to create the FragmentExampleBinding.bind()
extension function. It will have access to all the views of the fragment, thus it is easier to implement its listeners. In this case, we will call the onNameIntroduced()
method from the ExampleViewModel
when the user clicks on the button.
private fun FragmentExampleBinding.bind() {
btnOk.setOnClickListener {
val name = txtInputName.editText?.text.toString()
viewModel.onNameIntroduced(name)
}
}
If you have used some LiveData
in ExampleViewModel
, you have to create the ExampleViewModel.observe()
extension function to observe the changes of those LiveData
.
private fun ExampleViewModel.observe() {
isNameEmpty.observe(viewLifecycleOwner) {
// ...
}
}
However, since field validation is a common operation and depending on how its implemented can be quite complex and might introduce some boilerplate code, we can use the LiveData<Boolean>.observeInvalidField()
extension function. This will create a handler that responds to the TextInputLayout
and observes all its changes. We need to pass four arguments to observe the invalid field:
-
lifecycleOwner
: the owner of the lifecycle stored in theviewLifecycleOwner
property of theFragment
. -
textInputLayout
: theTextInputLayout
where the input is validated. -
errorMessage
: the error message that has to be displayed when the field is invalid. -
checkFunction
: a one-argument function to check whether the text is still invalid, which has to be implemented in the ViewModel.
private fun ExampleViewModel.observe() {
isNameEmpty.observeInvalidField(
viewLifecycleOwner,
binding.txtInputName,
getString(R.string.name_cannot_be_empty),
viewModel::onVerifyName
)
}
The viewModel::onVerifyName
is used to pass a reference to the method onVerifyName()
in viewModel
property. It is a cleaner way to pass the reference and in this case it will be equivalent to the following code.
private fun ExampleViewModel.observe() {
isNameEmpty.observeInvalidField(
viewLifecycleOwner,
binding.txtInputName,
getString(R.string.name_cannot_be_empty)
) { name -> viewModel.onVerifyName(name) }
}
Table of contents
Updates