Mobile Kotlin Model-View-ViewModel architecture components
This is a Kotlin Multiplatform library that provides architecture components of Model-View-ViewModel for UI applications. Components are lifecycle-aware on Android.
Table of Contents
Features
- ViewModel - store and manage UI-related data. Interop with
Android Architecture Components
- on Android it's preciselyandroidx.lifecycle.ViewModel
; - LiveData, MutableLiveData, MediatorLiveData - lifecycle-aware reactive data holders with set of operators to transform, merge, etc.;
- EventsDispatcher - dispatch events from
ViewModel
toView
with automatic lifecycle control and explicit interface of required events.
Requirements
- Gradle version 5.4.1+
- Android API 16+
- iOS version 9.0+
Versions
- kotlin 1.3.50
- 0.1.0
- 0.2.0
- 0.3.0
- 0.3.1
- kotlin 1.3.61
- 0.4.0
- 0.5.0
- kotlin 1.3.70
- 0.6.0
Installation
root build.gradle
allprojects {
repositories {
maven { url = "https://dl.bintray.com/icerockdev/moko" }
}
}
project build.gradle
dependencies {
commonMainApi("dev.icerock.moko:mvvm:0.6.0")
androidMainImplementation("androidx.lifecycle:lifecycle-extensions:2.0.0")
}
settings.gradle
enableFeaturePreview("GRADLE_METADATA")
On iOS, in addition to the Kotlin library add in Podfile
pod 'MultiPlatformLibraryMvvm', :git => 'https://github.com/icerockdev/moko-mvvm.git', :tag => 'release/0.6.0'
MultiPlatformLibraryMvvm
CocoaPod requires that the framework compiled from Kotlin be named
MultiPlatformLibrary
and be connected as a CocoaPod MultiPlatformLibrary
.
Here's an example.
To simplify integration with MultiPlatformFramework you can use mobile-multiplatform-plugin
MultiPlatformLibraryMvvm
CocoaPod contains the extension to UIView
s for binding with LiveData
.
Usage
Simple view model
Let’s say we need a screen with a button click counter. To implement it we should:
common
In commonMain
we can create a ViewModel
like:
class SimpleViewModel() : ViewModel() {
private val _counter: MutableLiveData<Int> = MutableLiveData(0)
val counter: LiveData<String> = _counter.map { it.toString() }
fun onCounterButtonPressed() {
val current = _counter.value
_counter.value = current + 1
}
}
And after that integrate the ViewModel
on platform the sides.
Android
SimpleActivity.kt
:
class SimpleActivity : MvvmActivity<ActivitySimpleBinding, SimpleViewModel>() {
override val layoutId: Int = R.layout.activity_simple
override val viewModelVariableId: Int = BR.viewModel
override val viewModelClass: Class<SimpleViewModel> = SimpleViewModel::class.java
override fun viewModelFactory(): ViewModelProvider.Factory {
return createViewModelFactory { SimpleViewModel() }
}
}
MvvmActivity
automatically loads a databinding layout, resolves ViewModel
object and sets a databinding variable.
activity_simple.xml
:
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable
name="viewModel"
type="com.icerockdev.library.sample1.SimpleViewModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{viewModel.counter.ld}" />
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:onClick="@{() -> viewModel.onCounterButtonPressed()}"
android:text="Press me to count" />
</LinearLayout>
</layout>
iOS
SimpleViewController.swift
:
import MultiPlatformLibrary
import MultiPlatformLibraryMvvm
class SimpleViewController: UIViewController {
@IBOutlet private var counterLabel: UILabel!
private var viewModel: SimpleViewModel!
override func viewDidLoad() {
super.viewDidLoad()
viewModel = SimpleViewModel()
counterLabel.bindText(liveData: viewModel.counter)
}
@IBAction func onCounterButtonPressed() {
viewModel.onCounterButtonPressed()
}
override func didMove(toParentViewController parent: UIViewController?) {
if(parent == nil) { viewModel.onCleared() }
}
}
bindText
is an extension from the MultiPlatformLibraryMvvm
CocoaPod.
ViewModel with send events to View
Let’s say we need a screen from which we should go to another screen by pressing a button. To implement it we should:
common
class EventsViewModel(
val eventsDispatcher: EventsDispatcher<EventsListener>
) : ViewModel() {
fun onButtonPressed() {
eventsDispatcher.dispatchEvent { routeToMainPage() }
}
interface EventsListener {
fun routeToMainPage()
}
}
EventsDispatcher
is a special class that automatically removes observers from lifecycle and buffers input
events while listener is not attached (on the Android side).
Android
EventsActivity.kt
:
class EventsActivity : MvvmActivity<ActivityEventsBinding, EventsViewModel>(),
EventsViewModel.EventsListener {
override val layoutId: Int = R.layout.activity_events
override val viewModelVariableId: Int = BR.viewModel
override val viewModelClass: Class<EventsViewModel> = EventsViewModel::class.java
override fun viewModelFactory(): ViewModelProvider.Factory {
return createViewModelFactory { EventsViewModel(eventsDispatcherOnMain()) }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.eventsDispatcher.bind(
lifecycleOwner = this,
listener = this
)
}
override fun routeToMainPage() {
Toast.makeText(this, "here must be routing to main page", Toast.LENGTH_SHORT).show()
}
}
eventsDispatcher.bind
attaches EventsDispatcher
to the lifecycle (in this case - to an activity) to correctly
subscribe and unsubscribe, without memory leaks.
We can also simplify the binding of EventsDispatcher
with MvvmEventsActivity
and EventsDispatcherOwnder
.
EventsOwnerViewModel.kt
:
class EventsOwnerViewModel(
override val eventsDispatcher: EventsDispatcher<EventsListener>
) : ViewModel(), EventsDispatcherOwner<EventsOwnerViewModel.EventsListener> {
fun onButtonPressed() {
eventsDispatcher.dispatchEvent { routeToMainPage() }
}
interface EventsListener {
fun routeToMainPage()
}
}
EventsOwnderActivity.kt
:
class EventsOwnerActivity :
MvvmEventsActivity<ActivityEventsOwnerBinding, EventsOwnerViewModel, EventsOwnerViewModel.EventsListener>(),
EventsOwnerViewModel.EventsListener {
override val layoutId: Int = R.layout.activity_events_owner
override val viewModelVariableId: Int = BR.viewModel
override val viewModelClass: Class<EventsOwnerViewModel> = EventsOwnerViewModel::class.java
override fun viewModelFactory(): ViewModelProvider.Factory {
return createViewModelFactory { EventsOwnerViewModel(eventsDispatcherOnMain()) }
}
override fun routeToMainPage() {
Toast.makeText(this, "here must be routing to main page", Toast.LENGTH_SHORT).show()
}
}
iOS
EventsViewController.swift
:
import MultiPlatformLibrary
import MultiPlatformLibraryMvvm
class EventsViewController: UIViewController {
private var viewModel: EventsViewModel!
override func viewDidLoad() {
super.viewDidLoad()
let eventsDispatcher = EventsDispatcher<EventsViewModelEventsListener>(listener: self)
viewModel = EventsViewModel(eventsDispatcher: eventsDispatcher)
}
@IBAction func onButtonPressed() {
viewModel.onButtonPressed()
}
override func didMove(toParentViewController parent: UIViewController?) {
if(parent == nil) { viewModel.onCleared() }
}
}
extension EventsViewController: EventsViewModelEventsListener {
func routeToMainPage() {
showAlert(text: "go to main page")
}
}
On iOS we create an instance of EventsDispatcher
with the link to the listener. We shouldn't call bind
like
on Android (in iOS this method doesn't exist).
ViewModel with validation of user input
class ValidationMergeViewModel() : ViewModel() {
val email: MutableLiveData<String> = MutableLiveData("")
val password: MutableLiveData<String> = MutableLiveData("")
val isLoginButtonEnabled: LiveData<Boolean> = email.mergeWith(password) { email, password ->
email.isNotEmpty() && password.isNotEmpty()
}
}
isLoginButtonEnabled
is observable email
& password
LiveData
, and in case there are any changes it calls lambda
with the newly calculated value.
We can also use one of these combinations:
class ValidationAllViewModel() : ViewModel() {
val email: MutableLiveData<String> = MutableLiveData("")
val password: MutableLiveData<String> = MutableLiveData("")
private val isEmailValid: LiveData<Boolean> = email.map { it.isNotEmpty() }
private val isPasswordValid: LiveData<Boolean> = password.map { it.isNotEmpty() }
val isLoginButtonEnabled: LiveData<Boolean> = listOf(isEmailValid, isPasswordValid).all(true)
}
Here we have separated LiveData with the validation flags - isEmailValid
, isPasswordValid
and combine both
to isLoginButtonEnabled
by merging all boolean LiveData in the list with on the condition that "all values must be true".
ViewModel for login feature
common
class LoginViewModel(
override val eventsDispatcher: EventsDispatcher<EventsListener>,
private val userRepository: UserRepository
) : ViewModel(), EventsDispatcherOwner<LoginViewModel.EventsListener> {
val email: MutableLiveData<String> = MutableLiveData("")
val password: MutableLiveData<String> = MutableLiveData("")
private val _isLoading: MutableLiveData<Boolean> = MutableLiveData(false)
val isLoading: LiveData<Boolean> = _isLoading.readOnly()
val isLoginButtonVisible: LiveData<Boolean> = isLoading.not()
fun onLoginButtonPressed() {
val emailValue = email.value
val passwordValue = password.value
viewModelScope.launch {
_isLoading.value = true
try {
userRepository.login(email = emailValue, password = passwordValue)
eventsDispatcher.dispatchEvent { routeToMainScreen() }
} catch (error: Throwable) {
val message = error.message ?: error.toString()
val errorDesc = message.desc()
eventsDispatcher.dispatchEvent { showError(errorDesc) }
} finally {
_isLoading.value = false
}
}
}
interface EventsListener {
fun routeToMainScreen()
fun showError(error: StringDesc)
}
}
viewModelScope
is a CoroutineScope
field of the ViewModel
class with a default Dispatcher - UI
on both platforms.
All coroutines will be canceled in onCleared
automatically.
Android
LoginActivity.kt
:
class LoginActivity :
MvvmEventsActivity<ActivityLoginBinding, LoginViewModel, LoginViewModel.EventsListener>(),
LoginViewModel.EventsListener {
override val layoutId: Int = R.layout.activity_login
override val viewModelVariableId: Int = BR.viewModel
override val viewModelClass: Class<LoginViewModel> =
LoginViewModel::class.java
override fun viewModelFactory(): ViewModelProvider.Factory {
return createViewModelFactory {
LoginViewModel(
userRepository = MockUserRepository(),
eventsDispatcher = eventsDispatcherOnMain()
)
}
}
override fun routeToMainScreen() {
Toast.makeText(this, "route to main page here", Toast.LENGTH_SHORT).show()
}
override fun showError(error: StringDesc) {
Toast.makeText(this, error.toString(context = this), Toast.LENGTH_SHORT).show()
}
}
activity_login.xml
:
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="viewModel"
type="com.icerockdev.library.sample6.LoginViewModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="email"
android:text="@={viewModel.email.ld}" />
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="password"
android:text="@={viewModel.password.ld}" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:onClick="@{() -> viewModel.onLoginButtonPressed()}"
android:text="Login"
app:visibleOrGone="@{viewModel.isLoginButtonVisible.ld}" />
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
app:visibleOrGone="@{viewModel.isLoading.ld}" />
</FrameLayout>
</LinearLayout>
</layout>
iOS
LoginViewController.swift
:
class LoginViewController: UIViewController {
@IBOutlet private var emailField: UITextField!
@IBOutlet private var passwordField: UITextField!
@IBOutlet private var loginButton: UIButton!
@IBOutlet private var progressBar: UIActivityIndicatorView!
private var viewModel: LoginViewModel!
override func viewDidLoad() {
super.viewDidLoad()
let eventsDispatcher = EventsDispatcher<LoginViewModelEventsListener>(listener: self)
viewModel = LoginViewModel(eventsDispatcher: eventsDispatcher,
userRepository: MockUserRepository())
emailField.bindTextTwoWay(liveData: viewModel.email)
passwordField.bindTextTwoWay(liveData: viewModel.password)
loginButton.bindVisibility(liveData: viewModel.isLoginButtonVisible)
progressBar.bindVisibility(liveData: viewModel.isLoading)
}
@IBAction func onLoginButtonPressed() {
viewModel.onLoginButtonPressed()
}
override func didMove(toParentViewController parent: UIViewController?) {
if(parent == nil) { viewModel.onCleared() }
}
}
extension LoginViewController: LoginViewModelEventsListener {
func routeToMainScreen() {
showAlert(text: "route to main screen")
}
func showError(error: StringDesc) {
showAlert(text: error.localized())
}
}
Samples
Please see more examples in the sample directory.
Set Up Locally
- The mvvm directory contains the
mvvm
library; - In sample directory contains sample apps for Android and iOS; plus the mpp-library connected to the apps;
- For local testing use the
./publishToMavenLocal.sh
script - so that sample apps use the locally published version.
Contributing
All development (both new features and bug fixes) is performed in the develop
branch. This way master
always contains the sources of the most recently released version. Please send PRs with bug fixes to the develop
branch. Documentation fixes in the markdown files are an exception to this rule. They are updated directly in master
.
The develop
branch is pushed to master
on release.
For more details on contributing please see the contributing guide.
License
Copyright 2019 IceRock MAG Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.