MVI Pattern in Android
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
}
}
- 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:
- 👏 Clap for the story and follow the author 👉
- 📰 View more content in the Level Up Coding publication
- 🔔 Follow us: Twitter | LinkedIn | Newsletter
🚀👉 Join the Level Up talent collective and find an amazing job