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.