MVI Pattern in Android

Jarin
Level Up Coding
Published in
6 min readMar 29, 2022

--

Photo by Christina Morillo on Pexels

What is MVI?

MVI is Model-View-Intent. In that, Intents are nothing but the actions that are getting triggered based on user interaction. Model consists of our business and data layers responsible for providing data to the View (UI) based on the fired Intent.

No more stories' we will start learning with an example.

What we are going to do?

We are going to develop a simple NEWS App to display list of news articles using MVI pattern. We are going to use Retrofit2 for handling Network Calls and StateFlow for managing states of the UI.

Where to start?

Add the following dependencies in the module-level build.gradle file

Alternatively you can copy/paste the dependencies by clicking here.

Enable view binding by adding the below code in the module-level build.gradle file

android {
...
buildFeatures {
viewBinding true
}
}
  1. Create the packages and files as shown below

2. Complete the UI as below inside activity_news.xml.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/item_background"
android:padding="8dp"
android:layout_marginBottom="8dp"
xmlns:app="http://schemas.android.com/apk/res-auto">

<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="18sp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"/>

<TextView
android:id="@+id/description"
app:layout_constraintTop_toBottomOf="@+id/title"
app:layout_constraintLeft_toLeftOf="@+id/title"
android:layout_marginTop="8dp"
android:textSize="16sp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>

</androidx.constraintlayout.widget.ConstraintLayout>

Create a file named item_background.xml under the drawable folder and copy/paste the below code.

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/teal_200" />
<corners android:radius="16dp"/>
</shape>

Create a file named news_item.xml under the drawable folder and copy/paste the below code.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/item_background"
android:padding="8dp"
android:layout_marginBottom="8dp"
xmlns:app="http://schemas.android.com/apk/res-auto">

<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="18sp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"/>

<TextView
android:id="@+id/description"
app:layout_constraintTop_toBottomOf="@+id/title"
app:layout_constraintLeft_toLeftOf="@+id/title"
android:layout_marginTop="8dp"
android:textSize="16sp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>

</androidx.constraintlayout.widget.ConstraintLayout>

3. The response from the API looks like below.

{
"totalArticles": 288478,
"articles": [
{
"title": "Tata IPL 2022 DC vs MI Live",
"description": "Delhi Capitals vs Mumbai Indians live score streaming",
"content": "Delhi Capitals vs Mumbai Indians Live update - Tata IPL 2022 DC vs MI Live cricket score , 2nd IPL Match Live Coverage: Mumbai Indians post 177/5! ",
"url": "https://www.firstpost.com/.html",
"image": "https://images.firstpost.com/40_Sportzpics.jpg",
"publishedAt": "2022-03-27T11:48:18Z",
"source": {
"name": "Firstpost",
"url": "https://www.firstpost.com"
}
}
]
}

We will write data classes for this response

data class NewsData(val totalArticles: Int, val articles: List<Articles>)

data class Articles(val title: String,val description: String,val content: String, val url: String,
val image:String, val publishedAt: String, val source: Source)

data class Source(val name:String, val url: String)

4. We will create Sealed Class for Intents and States.

sealed class NewsIntents {
object TopHeadlinesIntent : NewsIntents()
}
sealed class NewsStates {
data class Success(val news: NewsData) : NewsStates()
data class Error(val errorMessage: String) : NewsStates()
object Loading : NewsStates()
}

5. Next we will create the network layer

Let the Constants class contain our BASE_URL and API_KEY

object Constants {
const val API_KEY : String = "api-key" //Replace with your api key
const val BASE_URL : String = "https://gnews.io/api/v4/"
}

Create Retrofit client for handling API calls

import com.example.mvipattern.BuildConfig;
import com.example.mvipattern.Constants;

import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;


public class RetrofitClient {

private static final OkHttpClient.Builder httpClient =
new OkHttpClient.Builder();
private static RetrofitClient instance = null;
private static NewsService service = null;
private static final HttpLoggingInterceptor logging =
new HttpLoggingInterceptor();

private RetrofitClient() {
httpClient.interceptors().add(chain -> {
Request originalRequest = chain.request();
Request.Builder builder = originalRequest.newBuilder()
.method(originalRequest.method(), originalRequest.body());
return chain.proceed(builder.build());
});

if (BuildConfig.DEBUG) {
logging.setLevel(HttpLoggingInterceptor.Level.BODY);
// add logging as last interceptor
httpClient.addInterceptor(logging);
}
Retrofit retrofit = new Retrofit.Builder().client(httpClient.build()).
baseUrl(Constants.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build();
service = retrofit.create(NewsService.class);
}


public static RetrofitClient getInstance() {
if (instance == null) {
instance = new RetrofitClient();
}
return instance;
}

public NewsService getApiService() {
return service;
}


}

Create end points for API calls

interface NewsService {

@GET("top-headlines?lang=en&token=${Constants.API_KEY}")
suspend fun getTopHeadlines() : Response<NewsData>


}

Now its time for our repository layer

interface Repository {
suspend fun getTopHeadlines() : Flow<NewsStates>
}
class RepositoryImpl : Repository {

override suspend fun getTopHeadlines() = flow {

emit(NewsStates.Loading)

val response = RetrofitClient.getInstance().apiService.getTopHeadlines()
if (response.isSuccessful){
emit(NewsStates.Success(response.body()!!))
}else{
emit(NewsStates.Error(response.errorBody().toString()))
}

}
}

6. We are going to implement the most important part of MVI the ViewModel.

ViewModel get the INTENT from the VIEW, process them and then gives the STATE to the View.

Do you remember we have created SealedClasses for intents and states? We are going to use them in the view model.

We have used Coroutine components Channel and StateFlow for handling Intent and States.

To lean more about those visit

class NewsViewModel : ViewModel() {

val newsChannel = Channel<NewsIntents>()

private val _newsState = MutableStateFlow<NewsStates>(NewsStates.Loading)
val newsStates : StateFlow<NewsStates> get() = _newsState

}

We are having two objects to hold the state, one mutable and one immutable. We will expose only the immutable object to our View (Activity/Fragment). This prevents the states being changed from the View and only the viewmodel can change the state. This is a good practice.

We will see how to process intent and manage states.


init {
handleIntents()
}

private fun handleIntents() {
viewModelScope.launch {
newsChannel.consumeAsFlow().collect{
when(it){
NewsIntents.TopHeadlinesIntent -> getTopHeadlines()
}
}
}
}

private suspend fun getTopHeadlines() {
RepositoryImpl().getTopHeadlines().collect {
_newsState.value = it
}
}

The full code of the viewmodel is below.

class NewsViewModel : ViewModel() {

val newsChannel = Channel<NewsIntents>()

private val _newsState = MutableStateFlow<NewsStates>(NewsStates.Loading)
val newsStates : StateFlow<NewsStates> get() = _newsState

init {
handleIntents()
}

private fun handleIntents() {
viewModelScope.launch {
newsChannel.consumeAsFlow().collect{
when(it){
NewsIntents.TopHeadlinesIntent -> getTopHeadlines()
}
}
}
}

private suspend fun getTopHeadlines() {
RepositoryImpl().getTopHeadlines().collect {
_newsState.value = it
}
}
}

7. We have reached the final step. We have completed all the setup lets implement them in our UI.

Create Adapter class for showing news articles in RecyclerView

class NewsAdapter : RecyclerView.Adapter<NewsViewHolder>() {

private var articles = listOf<Articles>()

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NewsViewHolder {
val itemBinding = NewsItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return NewsViewHolder(itemBinding)
}

override fun onBindViewHolder(holder: NewsViewHolder, position: Int) {
holder.bind(articles[position])
}

override fun getItemCount(): Int {
return articles.size
}

fun addArticles(articles : List<Articles>){
this.articles = articles
notifyDataSetChanged()
}

}

The ViewHolder

class NewsViewHolder(private val newsItemBinding: NewsItemBinding) : RecyclerView.ViewHolder(newsItemBinding.root) {

fun bind(article : Articles) {
newsItemBinding.title.text = article.title
newsItemBinding.description.text = article.description
}
}

Open the NewsActivity and create the following objects

private val newsViewModel : NewsViewModel by viewModels()

private val newsAdapter = NewsAdapter()

private lateinit var binding: ActivityNewsBinding

Implement ViewBinding in to our acticity

binding = ActivityNewsBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)

Set up the RecyclerView

binding.rvNews.apply {
layoutManager = LinearLayoutManager(context)
setHasFixedSize(true)
adapter = newsAdapter
}

Fire intent and manage states

lifecycleScope.launch {
newsViewModel.newsChannel.send(NewsIntents.TopHeadlinesIntent)
}

lifecycleScope.launch {
lifecycleScope.launchWhenStarted {
newsViewModel.newsStates.collect {
when(it){
is NewsStates.Success -> {
binding.progressBar.visibility = View.GONE
newsAdapter.addArticles(it.news.articles)
}
is NewsStates.Error -> {
binding.progressBar.visibility = View.GONE
}
is NewsStates.Loading -> {
binding.progressBar.visibility = View.VISIBLE
}
}
}
}
}

The full code of the NewsActivity is below

class NewsActivity : AppCompatActivity() {

private val newsViewModel : NewsViewModel by viewModels()

private val newsAdapter = NewsAdapter()

private lateinit var binding: ActivityNewsBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

binding = ActivityNewsBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)

binding.rvNews.apply {
layoutManager = LinearLayoutManager(context)
setHasFixedSize(true)
adapter = newsAdapter
}

lifecycleScope.launch {
newsViewModel.newsChannel.send(NewsIntents.TopHeadlinesIntent)
}

lifecycleScope.launch {
lifecycleScope.launchWhenStarted {
newsViewModel.newsStates.collect {
when(it){
is NewsStates.Success -> {
binding.progressBar.visibility = View.GONE
newsAdapter.addArticles(it.news.articles)
}
is NewsStates.Error -> {
binding.progressBar.visibility = View.GONE
}
is NewsStates.Loading -> {
binding.progressBar.visibility = View.VISIBLE
}
}
}
}
}
}
}

If you like this article make sure to clap and do more claps more if you liked it even much.

The full source code can be found on GitHub using the below link

If you wish to run the app go to GNews API site and create your own api key and replace it in the API_KEY field of Constants file.

Level Up Coding

Thanks for being a part of our community! Before you go:

🚀👉 Join the Level Up talent collective and find an amazing job

--

--