Blog Infos
Author
Published
Topics
,
Published
Topics
,

Photo by mostafa meraji on Unsplash

At GityMarket, the main initial architecture was almost CLEAN. It had domain, data, and presentation layers. For the implementation of the presentation layer, the MVVM architecture recommended by Google had been used. The Model layer of the MVVM architecture was mapped to the domain + data layers of the CLEAN architecture and the View and ViewModel layers of the MVVM architecture were mapped to the presentation layer of the CLEAN architecture.

The MVVM diagram

The problem

Unidirectional Data Flow architecture recommended by Google

The Solution
  1. The Intent-based interaction between UI and ViewModel has more restrictions for comply Single responsibility.
  2. All ViewModel’s methods become private. This means The Encapsulation (one of the principles of OOP) has been observed more. From now, you less care about the actual implementation of ViewModel; You just fire intent from UI (That’s soooo beautiful! 😍).
  3. The second benefit of Intent-based interaction is increasing Abstraction (the other principle of OOP) of communication between UI and ViewModel.
  4. Finally, I founded MVI more suitable for using Jetpack Compose than MVVM. I know that you can have multiple states in old fashion of MVVM’s ViewModel and pass ViewModel’s method call as lambda to Composable functions, But when there is MVI, why you force yourself to use MVVM?!?😁

Me after getting familiar with the MVI! 😍🥰

 

The Implementation
interface NewsListContract :
    UnidirectionalViewModel<NewsListContract.State, NewsListContract.Event,
        NewsListContract.Effect> {

    data class State(
       val news: List<News> = listOf(),
       val refreshing: Boolean = false,
       val showFavoriteList: Boolean = false,
    )

    sealed class Event {
      data class OnFavoriteClick(val news: News) : Event()
      data class OnGetNewsList(val showFavoriteList: Boolean) : Event()
      data class OnSetShowFavoriteList(val showFavoriteList: Boolean) : Event()
      object OnRefresh: Event()
      object OnBackPressed : Event()
      data class ShowToast(val message: String) : Event()
    }

    sealed class Effect {
      object OnBackPressed : Effect()
      data class ShowToast(val message: String) : Effect()
    }
}

Note: The UnidirectionalViewModel is an Interface:

interface UnidirectionalViewModel<STATE, EVENT, EFFECT> {
    val state: StateFlow<STATE>
    val effect: SharedFlow<EFFECT>
    fun event(event: EVENT)
}
  1. A variable for holding the State
  2. A flow for the Effect
  3. And override the event function
@HiltViewModel
class NewsListViewModel @Inject constructor(
    private val getNewsUseCase: GetNewsUseCase,
    private val getFavoriteNewsUseCase: GetFavoriteNewsUseCase,
    private val toggleFavoriteNewsUseCase: ToggleFavoriteNewsUseCase,
) : NewsListContract {

    private val mutableState = MutableStateFlow(NewsListContract.State())
    override val state: StateFlow<NewsListContract.State> = 
          mutableState.asStateFlow()

    private val effectFlow = MutableSharedFlow<NewsListContract.Effect>()
    override val effect: SharedFlow<NewsListContract.Effect> = 
          effectFlow.asSharedFlow()

    override fun event(event: NewsListContract.Event) = when (event) {
        is NewsListContract.Event.OnSetShowFavoriteList -> 
            onSetShowFavoriteList(showFavoriteList = event.showFavoriteList)
        is NewsListContract.Event.OnGetNewsList -> 
            getData(showFavoriteList = mutableState.value.showFavoriteList)
        is NewsListContract.Event.OnFavoriteClick -> 
            onFavoriteClick(news = event.news)
        NewsListContract.Event.OnRefresh -> getData(isRefreshing = true)
        NewsListContract.Event.OnBackPressed -> onBackPressed()
        is NewsListContract.Event.ShowToast -> showToast(event.message)
    }

    private fun onSetShowFavoriteList(showFavoriteList: Boolean) {
        mutableState.update {
            it.copy(showFavoriteList = showFavoriteList)
        }
    }

    private fun getData(
        isRefreshing: Boolean = false, 
        showFavoriteList: Boolean = false,
    ) {
        if (isRefreshing)
            mutableState.update {
                NewsListContract.State(
                    refreshing = true,
                )
            }
        viewModelScope.launch {
            if (showFavoriteList)
                getFavoriteNews()
            else
                getNewsList()
        }
    }

    private suspend fun getNewsList() = getNewsUseCase()
        .catch { exception ->
            mutableBaseState.update {
                BaseContract.BaseState.OnError(
                    errorMessage = exception.localizedMessage 
                                          ?: "An unexpected error occurred."
                )
            }
        }
        .onEach { result ->
            mutableState.update {
                NewsListContract.State(news = result)
            }
        }
        .launchIn(viewModelScope)

    private fun getFavoriteNews() = getFavoriteNewsUseCase()
    .onEach { newList ->
        mutableState.update {
            it.copy(news = newList)
        }
    }.launchIn(viewModelScope)

    private fun onFavoriteClick(news: News) {
        viewModelScope.launch(Dispatchers.IO) {
            toggleFavoriteNewsUseCase(news)
        }
    }

    private fun onBackPressed() {
        viewModelScope.launch {
            effectFlow.emit(NewsListContract.Effect.OnBackPressed)
        }
    }

    private fun showToast(message: String) {
        viewModelScope.launch {
            effectFlow.emit(
                NewsListContract.Effect.ShowToast(message = message)
            )
        }
    }

}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

  1. The data in the State pass to every Composable function which needed
  2. The Effect flow begins to be collected
  3. And the event function be invoked anywhere needed and passed the corresponding event
@Composable
fun NewsListRoute(
    viewModel: NewsListViewModel = hiltViewModel(),
    showFavoriteList: Boolean = false,
    onNavigateToDetailScreen: (news: News) -> Unit,
) {
    // Implementation of `use` in the note section
    val (state, effect, event) = use(viewModel = viewModel)
    val activity = LocalContext.current as? Activity

    /*
      Get initial data
      Don't use the init block in ViewModel as much as possible
      This makes testing harder!
    */
    LaunchedEffect(key1 = Unit) {
        event.invoke(
            NewsListContract.Event.OnSetShowFavoriteList(
                showFavoriteList = showFavoriteList,
            )
        )
        event.invoke(
            NewsListContract.Event.OnGetNewsList(
                showFavoriteList = showFavoriteList,
            )
        )
    }

    // Implementation of `collectInLaunchedEffect` in the note section
    effect.collectInLaunchedEffect {
        when (it) {
            NewsListContract.Effect.OnBackPressed -> {
                activity?.onBackPressed()
            }
            is NewsListContract.Effect.ShowToast -> {
                Toast.makeText(activity, it.message, Toast.LENGTH_LONG).show()
            }
        }
    }

    NewsListScreen(
        newsListState = state,
        onNavigateToDetailScreen = onNavigateToDetailScreen,
        onFavoriteClick = { news ->
            event.invoke(NewsListContract.Event.OnFavoriteClick(news = news))
        },
        onRefresh = {
            event.invoke(NewsListContract.Event.OnRefresh)
        },
        onBackPressed = {
            event.invoke(NewsListContract.Event.OnBackPressed)
        },
        showToast = { message ->
            event.invoke(NewsListContract.Event.ShowToast(message))
        },
    )
}

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun NewsListScreen(
    newsListState: NewsListContract.State,
    onNavigateToDetailScreen: (news: News) -> Unit,
    onFavoriteClick: (news: News) -> Unit,
    onRefresh: () -> Unit,
    onBackPressed: () -> Unit,
    showToast: (message: String) -> Unit,
) {
    val refreshState = rememberPullRefreshState(
        refreshing = newsListState.refreshing,
        onRefresh = onRefresh,
    )

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .pullRefresh(refreshState)
    ) {
        AnimatedVisibility(
            visible = !newsListState.refreshing,
            enter = fadeIn(),
            exit = fadeOut(),
        ) {
            Row {
              Button(onClick = {
                  onBackPressed()
              }) {
                  Text(text = "onBackPressed")
              }

              Spacer(modifier = Modifier.width(16.dp))

              Button(onClick = {
                  showToast(message = "Hiiiiiii!")
              }) {
                  Text(text = "Show Toast")
              }
            }
            LazyColumn(modifier = Modifier.fillMaxWidth()) {
                items(newsListState.news) { news ->
                    NewsListItem(
                        news = news,
                        onItemClick = {
                            onNavigateToDetailScreen(news)
                        },
                        onFavoriteClick = {
                            onFavoriteClick(news)
                        }
                    )
                }
            }
        }
        PullRefreshIndicator(
            newsListState.refreshing,
            refreshState,
            Modifier.align(Alignment.TopCenter)
        )
    }
}

@SuppressLint("UnusedMaterialScaffoldPaddingParameter")
@ThemePreviews
@Composable
private fun NewsListScreenPrev(
    @PreviewParameter(NewsListStateProvider::class)
    newsListState: NewsListContract.State
) {
    ComposeNewsTheme {
        Scaffold {
            NewsListScreen(
                newsListState = newsListState,
                onNavigateToDetailScreen = {},
                onFavoriteClick = {},
                onRefresh = {},
                onBackPressed = {},
                showToast = {},
            )
        }
    }
}

Note: The implementation of use:

@Composable
inline fun <reified STATE, EVENT, EFFECT> use(
    viewModel: UnidirectionalViewModel<STATE, EVENT, EFFECT>,
): StateDispatchEffect<STATE, EVENT, EFFECT> {
    val state by viewModel.state.collectAsStateWithLifecycle()

    val dispatch: (EVENT) -> Unit = { event ->
        viewModel.event(event)
    }

    return StateDispatchEffect(
        state = state,
        effectFlow = viewModel.effect,
        dispatch = dispatch,
    )
}

data class StateDispatchEffect<STATE, EVENT, EFFECT>(
    val state: STATE,
    val dispatch: (EVENT) -> Unit,
    val effectFlow: SharedFlow<EFFECT>,
)

And the implementation of collectInLaunchedEffect :

@Suppress("ComposableNaming")
@Composable
fun <T> SharedFlow<T>.collectInLaunchedEffect(function: suspend (value: T) -> Unit) {
    val sharedFlow = this
    LaunchedEffect(key1 = sharedFlow) {
        sharedFlow.collectLatest(function)
    }
}

GitHub – Kaaveh/ComposeNews

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Three years ago I wrote an article about binding a list of items to…
READ MORE
blog
This article is a follow-up to one I published in April 2019. You can…
READ MORE
blog
This is the first article of the ‘MVI with state-machine’ series that describes the…
READ MORE
blog
This is the second part of the ‘MVI with state-machine’ series that describes some…
READ MORE
Menu