Android App Architecture [Part 4] Presentation Layer
Join the DZone community and get the full member experience.
Join For FreeIn the previous articles, I described and implemented the data layer, domain layer, and data and domain modules for one of the features in our application, the WeatherApp. Now, it's time to implement the last missing layer of our app, the presentation layer.
What Is the Presentation Layer?
As the name suggests, this layer is responsible for presenting UI to the user. It's used to perform necessary UI logic based on the data received through the domain layer and user interactions. In the previous article, we described and implemented our domain layer. Our presentation layer depends on it, but the domain shouldn't know anything about the presentation layer. This presentation layer follows the MVVM architecture pattern that we described in our first article. Also, we'll use Android Jetpack to implement this pattern correctly.
First, we need to create a presentation module for our weather feature. In the directory feature, create the module feature_weather
. After that, the first step is to update Gradle dependency:
dependencies {
implementation project(':core')
implementation project(':domain_weather')
kapt deps.dagger.daggerCompiler
}
This module should implement necessary dependencies from the core module, and it'll depend on the domain_weather
module.
After we finish creating our Gradle file, the next step is to create a presentation model. This presentation model will be mapped from the domain model. Create a package called model inside main/java and a data class, WeatherView
, inside it.
package com.rostojic.weather.model
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
@Parcelizedata
class WeatherView(
val city: String,
val dateTime: String,
val weatherImage: String,
val temperature: String,
val feelsLike: String,
val description: String,
val precipitation: String,
val uvIndex: String
) : Parcelable
fun Weather.mapToView(): WeatherView = WeatherView(
this.city,
this.dateTime,
this.weatherImage,
this.temperature,
this.feelsLike,
this.description,
this.precipitation,
this.uvIndex
)
To parcel this WeatherView
data class, we need to modify the build.gradle of our presentation module. Below android and abode dependency add androidExtensions and set experimental to true:
androidExtensions {
experimental = true
}
Now, when we have our presentation model, we can create a design. The design won't be complicated because it's not an essential part of our application. We'll implement a simple screen using ConstraintLayout
to display data from the model:
Besides this main screen, we'll have one more screen for loading and errors. Create a new resource file called load_weather_fragment.xml:
<include
android:id="@+id/viewLoading"
layout="@layout/view_loading"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<include
android:id="@+id/viewError"
layout="@layout/view_error"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
This layout should include two views, view_loading
, and view_error
. The loading view should have just one rotating progress bar, and an error view should have a simple image viewer with an error image. Set error_view
to gone
for now.
The next step is to create two fragments, one for loading and one for displaying the weather. First, create a directory called UI under main/java. Inside the UI package, create two more packages, display and load. Inside display, create a file called DisplayWeatherFragment, and inside load create a file, LoadWeatherFragment.
DisplayWeatherFragment:
package com.rostojic.weather.ui.display
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.rostojic.weather.R
class DisplayWeatherFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.display_weather_fragment, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setUpViews()
}
private fun setUpViews() {}
}
LoadWeatherFragment:
package com.rostojic.weather.ui.load
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.rostojic.weather.R
class LoadWeatherFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.load_weather_fragment, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
loadWeather()
}
private fun loadWeather() {}
}
Now, when we have our fragments, we can create a navigation resource file on the app or feature level. For now, we'll create it on the app-level because we'll have only two screens in our application for now. In the Weatherapp module, create a navigation resource file called weather_navigation.xml, and inside it, set our navigation graph:
Inside weather_navigation.xml for DisplayWeatherFragment
add an argument for WeatherView
. We need to pass a WeatherView
object to the DisplayWeatherFragment
screen once it's loaded to display weather data to the user:
<argument
android:name="weatherView"
app:argType="com.rostojic.weather.model.WeatherView"
app:nullable="false" />
To pass a WeatherView
instance to the DisplayWeatherFragment
, we need to load it from the domain layer. For loading and direct communication to the domain layer, we'll create a view model class inside the load package called LoadWeatherViewModel
. First, we'll inject GetWeatherUseCase
from the domain layer through the constructor:
class LoadWeatherViewModel @Inject constructor(
private val getWeatherUseCase: GetWeatherUseCase
) : ViewModel()
To properly implement the observer pattern, we'll use a lifecycle-aware data holder from Android Jetpack, LiveData
. Before we use it in our view model, we need to create a LiveData
extension in our core module. Inside the core module, under package extensions, create a new file called LiveDataExtensions and add those two extensions:
fun <T> MutableLiveData<Resource<T>>.setSuccess(data: T) =
postValue(Resource(Resource.State.Success, data))
fun <T> MutableLiveData<Resource<T>>.setError(message: String? = null) =
postValue(Resource(Resource.State.Error, value?.data, message))
Once we add those extensions, we can create LiveData
for getting weather information in LoadWeatherViewModel
:
private var _getWeatherLiveData = MutableLiveData<Resource<WeatherView>>()
val getWeatherLiveData: LiveData<Resource<WeatherView>>
get() = _getWeatherLiveData
The next step is to create a public function that will run GetWeatherUseCase
and return a Weather
instance or error.
fun getWeatherData() {
getWeatherUseCase.run {
clear()
executeUseCase { handleResult(it) }
}
}
Before we create the handleResult()
function, we need to add a constant error string that'll be used in both view model and fragment to identify a connection error:
companion object {
const val CONNECTION_ERROR = "connection_error"
}
Now, we can implement the handleResult()
function:
private fun handleResult(status: GetWeatherUseCase.Status) {
when (status) {
is GetWeatherUseCase.Status.Success -> onGetWeatherSuccess(status)
is GetWeatherUseCase.Status.ConnectionError -> onGetWeatherConnectionError()
is GetWeatherUseCase.Status.UnknownError -> onGetWeatherUnknownError()
}
}
private fun onGetWeatherSuccess(status: GetWeatherUseCase.Status.Success) {
_getWeatherLiveData.setSuccess(status.weather.mapToView())
}
private fun onGetWeatherConnectionError() {
_getWeatherLiveData.setError(CONNECTION_ERROR)
}
private fun onGetWeatherUnknownError() {
_getWeatherLiveData.setError()
}
The last but important thing is to call clear on getWeatherUseCase()
in the overridden method, onCleared()
:
override fun onCleared() {
super.onCleared()
getWeatherUseCase.clear()
}
Before we implement LoadWeatherFragment
, we need to set up dependency injection for this module because the first thing we do in LoadWeatherFragment
will be injecting the LoadWeatherViewModel
. Under main/java, create a package called di, and under it, create the first file called WeatherScope:
package com.rostojic.weather.di
import javax.inject.Scope
@Scope
@Retention(AnnotationRetention.RUNTIME)
annotation class WeatherScope
After WeatherScope, in same package, create a file called WeatherComponent:
package com.rostojic.weather.di
import com.rostojic.core.presentation.ViewModelFactory
import com.rostojic.weather.ui.load.LoadWeatherViewModel
import dagger.Component
@WeatherScope
@Component(dependencies = [WeatherDomainComponent::class])
interface WeatherComponent {
fun getWeatherViewModelFactory(): ViewModelFactory<LoadWeatherViewModel>
}
This component depends on the domain component. Next, create a WeatherInjector
and call it from the Weatherapp module:
package com.rostojic.weather.di
object WeatherInjector {
lateinit var component: WeatherComponent
fun initialise() {
component =
DaggerWeatherComponent.builder().weatherDomainComponent(WeatherDomainInjector.component)
.build()
}
}
Once we're done with dependency injection, we can continue working on our fragments. Using WetherComponent, we can access our view model like this:
private val weatherViewModel by
viewModel(WeatherInjector.component.getWeatherViewModelFactory())
Add this line above onCreateView()
. Next, override onActivityCreated()
, and call a public method from the view model for getting weather and method to initialize observer inside it:
private val weatherViewModel by viewModel(WeatherInjector.component.getWeatherViewModelFactory())
/* Add this line above onCreateView(). Next step is to override onActivityCreated()
and inside it call public method from view model for getting weather and
method to initialise observer:*/
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
loadWeather()
initialiseViewModelObserver()
}
private fun loadWeather() {
weatherViewModel.getWeatherData()
}
private fun initialiseViewModelObserver() {
weatherViewModel.getWeatherLiveData.observe(this, Observer(::weatherReceived))
}
The weatherReceived
function is responsible for performing UI logic based on Resource.State
:
private fun weatherReceived(weatherResource: Resource<WeatherView>) {
weatherResource.let {
when (it.state) {
is Resource.State.Loading -> onWeatherFetchLoading()
is Resource.State.Success -> onWeatherFetchSuccess(it)
is Resource.State.Error -> onWeatherFetchError(it)
}
}
}
We need to show the loading view while the results load:
private fun onWeatherFetchLoading() {
viewLoading.visibility = View.VISIBLE
viewError.visibility = View.GONE
}
When we get an error as a result, we need to preview the error view:
private fun onWeatherFetchError(weatherResource: Resource<WeatherView>) {
when (weatherResource.message) {
CONNECTION_ERROR -> setConnectionError()
else -> setUnknownError()
}
}
If the error is CONNECTION_ERROR
, show the connection error view; otherwise, show the unknown error:
private fun setConnectionError() {
viewLoading.visibility = View.GONE
viewError.apply {
setConnectionError()
visibility = View.VISIBLE
}
}
private fun setUnknownError() {
viewLoading.visibility = View.GONE
viewError.apply {
setUnknownError()
visibility = View.VISIBLE
}
}
Currently, we don't have different UIs for unknown and connection errors, but it can be easily extended with this logic.
If the result is a success, we'll get a WeatherView
instance, navigate to DisplayWeatherFragment
, and pass this WeatherView
object as an argument:
private fun onWeatherFetchSuccess(weatherResource: Resource<WeatherView>) {
navigateToWeather(weatherResource.data ?: return)
}
private fun navigateToWeather(weatherView: WeatherView) {
val bundle = bundleOf("weather" to weatherView)
findNavController().navigate(
R.id.action_loadWeatherFragment_to_displayWeatherFragment,
bundle
)
}
With this, we're finished with loading data from the view model, and we're done with LoadWeatherFrament
. The final step is to extend the function, setUpView()
, inside DisplayWeatherFragment
to display actual data from our WeatherView
object that we received from LoadWeatherFragment
:
private fun setUpViews() {
val weatherView: WeatherView = arguments?.get("weather") as WeatherView
weatherView.let {
textCity.text = weatherView.city
textDateTime.text = weatherView.dateTime
Glide.with(this).load(weatherView.weatherImage).into(imageWeather)
textDescription.text = weatherView.description
textFeelsLike.text = weatherView.feelsLike
textPercipitation.text = weatherView.precipitation
textTemperature.text = weatherView.temperature
textUvIndex.text = weatherView.uvIndex
}
}
That is all. We have implemented the last layer of our architecture. In this article, we implemented the presentation layer for weather features following the MVVM design pattern and with the help of Android Jetpack.
Further Reading
Published at DZone with permission of Radivoje Ostojic. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments