Say hello to Android's latest architecture, the Model-View-Intent design pattern. Learn how this cycle.js-inspired framework can become a great addition to your coding toolbox.
//T represents our ViewState
sealed class DataState<out T> {
data class Success<T>(val data: T?) : DataState<T>()
data class Error<T>(val data: T?, val exception: Exception) : DataState<T>()
data class Loading<T>(val data: T?) : DataState<T>()
//Helper methods that make it easy to access the sealed class contents in xml data binding
fun toData(): T? = when (this) {
is Success -> this.data
is Error -> this.data
is Loading -> this.data
}
fun isLoading(): Boolean? = if (this is Loading) true else null
fun toErrorMessage(): String? = if (this is Error) this.exception.message else null
}
//T represents our ViewState
//U represents our ViewEffect
abstract class BaseViewModel<T, U> : ViewModel() {
//It will be accessed by child classes to update the view states
protected val _dataStates: MutableLiveData<DataState<T>> = MutableLiveData()
//Will be accessed by the view to observe view state updates
val dataStates: LiveData<DataState<T>> = _dataStates
//It will be accessed by child classes to update the view effects
protected val _viewEffects: SingleLiveEvent<U> = SingleLiveEvent()
//It will be accessed by the view to observe view effects updates
val viewEffects: SingleLiveEvent<U> = _viewEffects
//This is a wrapper for making network/room calls (use the latest version of room and retrofit that supports suspend functions)
//This way we don't have to manually set our error and loading states in our child view models.
protected fun launchRequest(block: suspend () -> Unit): Job {
val currentViewState = getViewState()
return viewModelScope.launch {
try {
_dataStates.postValue(DataState.Loading(currentViewState))
block()
} catch (exception: Exception) {
_dataStates.postValue(DataState.Error(currentViewState, exception))
}
}
}
protected fun getViewState() : T? = _dataStates.value?.toData()
}
class SingleLiveEvent<T> : MutableLiveData<T>() {
private val mPending = AtomicBoolean(false)
@MainThread
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
if (hasActiveObservers()) {
Log.w(TAG, "Multiple observers registered but only one will be notified of changes.")
}
// Observe the internal MutableLiveData
super.observe(owner, Observer { t ->
if (mPending.compareAndSet(true, false)) {
observer.onChanged(t)
}
})
}
@MainThread
override fun setValue(t: T?) {
mPending.set(true)
super.setValue(t)
}
/**
* Used for cases where T is Void, to make calls cleaner.
*/
@MainThread
fun call() {
value = null
}
operator fun invoke() {
call()
}
operator fun invoke(data: T) {
value = data
}
companion object {
private val TAG = "SingleLiveEvent"
}
}
data class WalletListItem (
val amount: String,
val type: String
)
interface WalletService {
@GET
suspend fun getWalletAmount(): String
@GET
suspend fun getWalletList(): List<WalletListItem>
}
sealed class WalletIntent {
object GetWalletListEvent(): WalletIntent()
object GetWalletAmountEvent(): WalletIntent()
object OnNextEvent(): WalletIntent()
}
data class WalletViewState(
val walletItems: List<WalletListItem>? = null,
val walletAmount: String? = null
)
sealed class WalletViewEffect {
object NavigateToNextScreen : WalletViewEffect()
}
class WalletViewModel constructor(private val walletService: WalletService) : BaseViewModel<WalletViewState, WalletViewEffect>() {
init { //set default for the view state
_dataStates.postValue(DataState.Success(WalletViewState()))
}
//User related actions will trigger this method
fun setIntent(intent: WalletIntent) {
when (intent) {
is WalletIntent.GetWalletListEvent -> getWalletList()
is WalletIntent.GetWalletAmountEvent -> getWalletAmount()
is WalletIntent.OnNextEvent -> navigateToNextScreen()
}
}
private fun getWalletList() {
val currentViewState = getViewState()
launchRequest {
val walletItems = walletService.getWalletList()
val newViewState = currentViewState?.copy(walletItems = walletItems)
_dataStates.postValue(DataState.Success(newViewState))
}
}
private fun getWalletAmount() {
val currentViewState = getViewState()
launchRequest {
val walletAmount = walletService.getWalletAmount()
val newViewState = currentViewState?.copy(walletAmount = walletAmount)
_dataStates.postValue(DataState.Success(newViewState))
}
}
private fun navigateToNextScreen() {
_viewEffects.postValue(WalletViewEffect.NavigateToNextScreen)
}
}
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:tools="http://schemas.android.com/tools" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <data> <import type="android.view.View" /> <variable name="viewModel" type="com.example.WalletViewModel" /> <variable name="onGetWalletAmountEvent" type="com.example.WalletIntent.GetWalletAmountEvent" /> <variable name="onGetWalletListEvent" type="com.example.WalletIntent.GetWalletListEvent" /> <variable name="onNextEvent" type="com.example.WalletIntent.OnNextEvent" /> </data> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".ui.WalletFragment"> <Button android:id="@+id/button_get_amount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="28dp" android:layout_marginTop="40dp" android:onClick="@{() -> viewModel.setIntent(onGetWalletAmountEvent)}" android:text="Get Amount" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/amountTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="56dp" android:text="@{viewModel.dataStates.toData().walletAmount}" app:layout_constraintStart_toEndOf="@+id/button_get_amount" app:layout_constraintTop_toTopOf="@+id/button_get_amount" /> <Button android:id="@+id/button_get_wallet_list" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="64dp" android:onClick="@{() -> viewModel.setIntent(onGetWalletListEvent)}" android:text="Get Wallet List" app:layout_constraintStart_toStartOf="@+id/button_get_amount" app:layout_constraintTop_toBottomOf="@+id/button_get_amount" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView" android:layout_width="wrap_content" android:layout_height="wrap_content" app:data="@{viewModel.dataStates.toData().walletItems}" app:layout_constraintStart_toStartOf="@+id/button_get_wallet_list" app:layout_constraintTop_toBottomOf="@+id/button_get_wallet_list" /> <Button android:id="@+id/button_next" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="128dp" android:onClick="@{() -> viewModel.setIntent(onNextEvent)}" android:text="Next" app:layout_constraintStart_toStartOf="@+id/recyclerView" app:layout_constraintTop_toBottomOf="@+id/recyclerView" /> <TextView android:id="@+id/label_error" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="48dp" android:text="@{viewModel.dataStates.toErrorMessage()}" app:layout_constraintStart_toStartOf="@+id/button_next" app:layout_constraintTop_toBottomOf="@+id/button_next" /> <ProgressBar android:id="@+id/progressBar" style="?android:attr/progressBarStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:visibility="@{viewModel.dataStates.isLoading() ? View.VISIBLE : View.GONE}" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout> </layout>
class WalletFragment : Fragment() { private val viewModel: WalletViewModel by viewModels() private lateinit var binding: WalletFragmentBinding override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { binding = WalletFragmentBinding.inflate(inflater, container, false) binding.lifecycleOwner = viewLifecycleOwner binding.viewModel = viewModel binding.onGetWalletAmountEvent = WalletIntent.GetWalletAmountEvent binding.onGetWalletListEvent = WalletIntent.GetWalletListEvent binding.onNextEvent = WalletIntent.OnNextEvent return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.viewEffects.observe(viewLifecycleOwner, Observer { onViewEffectReceived(it) }) //If you want you can decide to handle your view state changes by simply observing it viewModel.dataStates.observe(viewLifecycleOwner, Observer { onDataStateReceived(it) }) } private fun onViewEffectReceived(it: WalletViewEffect?) { when (it) { is WalletViewEffect.NavigateToNextScreen -> navigateToNextScreen() } } private fun onDataStateReceived(it: DataState<WalletViewState>) { when (it) { is DataState.Error -> { } is DataState.Loading -> { } is DataState.Success -> { } } } private fun navigateToNextScreen() {} }
Looking for a new challenge? Join Our Team
From us to your inbox weekly.