Skip to content

Fragments

Albert Pinto i Gil edited this page Jul 14, 2021 · 2 revisions

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: The ViewModel associated with the fragment

In this documentation file, we will describe the steps to follow when implementing a fragment.

User interface

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>

Defining states

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()
}

ViewModel

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.

Dependency injection

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() {}

Base view model

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 to ExecutingUseCase, preparing its input and dealing with its result.

Input validation

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

Method names

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

Use case result handler

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)
            }
        }
    )

Implementing the method

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)
        }
    }
}

Fragment class

Next, we can implement the ExampleFragment. Since all the logic is in the ExampleViewModel, the fragment class will only deal with presentation issues.

Dependency injection

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() {}

Base 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() {}

Implementing properties

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 })
) {
    // ...
}

View binding

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

Implementing the android lifecycle methods

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()
}

Setting up views

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)
    }
}

Observing the view model

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 the viewLifecycleOwner property of the Fragment.
  • textInputLayout: the TextInputLayout 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) }
}