Components and view models
Components and view models are two fundamental concepts that we use day to day. So let's talk about them.
What is a Component?
In Kunafa, a Component
is an abstract class that represents a piece of UI element on the screen. We compose multiple components together to create nice-looking UI. Let's take a look at an example.
class MyFirstComponent: Component() {
override fun View?.getView(): View {
TODO("Not yet implemented")
}
}
To make a component, we first need to make a class that inherits from Component. There is also a method that we have to override which is the View?.getView()
extension method. View?.getView
expects us to return a View
so let's do that.
class MyFirstComponent: Component() {
override fun View?.getView(): View {
return verticalLayout {
textView {
text = "Hello World"
}
}
}
Kunafa provides pre-built basic UI elements (see Basic UI Components) that we can use to build complex UI.
To able to see our component on the screen, we have to mount it. So let's do that.
fun main() {
page {
mount(MyFirstComponent())
}
}
That's all to it. But wait, what if we want to make the UI interactive? Let's add some interactivity to our component by making a simple counter program. Before we do that, let's talk about observables.
Observables
In Kunafa, we use the Observer Pattern to manage the state of our applications. An Observable
is a class that is used to listen to changes in a particular subject. For instance, we could want to change a text when a button is pressed. In order to do that, we need to make that text observable and that text would be the subject.
In some projects, we started using two-way binding instead of observables to update the UI, e.g. StringBinding. Look at the project code and use them if they are available.
Counter program
Let's make a counter program that displays a text that shows the current counter and two increment/decrement buttons that increment and decrement the counter.
class CounterComponent: Component() {
private val observableCounter = Observable<Int>().apply { value = 0 }
override fun View?.getView(): View {
return verticalLayout {
textView {
observableCounter.observe {value ->
text = value!!.toString()
}
}
button {
text = "Increment"
onClick = {
observableCounter.value = observableCounter.value!! + 1
Unit
}
}
button {
text = "Decrement"
onClick = {
observableCounter.value = observableCounter.value!! - 1
Unit
}
}
}
}
}
We start by making our counter component and have it inherit from the Component class. In the CounterComponent
, we make a field called observableCounter
that is of type Observable<Int>
and it has a default value of 0. The View?.getView
method consists of a verticalLayout
that has a text view and two buttons. We make the text view observes the counter. So that whenever the counter changes (i.e increment/decrement events), update the text view to reflect the new changes. In the increment and decrement buttons, we set the onClick
field to increment and decrement the counter by setting the observable.value
to increment the counter for the increment button and decrement it for the decrement button. Pretty simple right? Now let's talk about view models.
View models
In our projects, we use the MVVM architecture (Model-View-ViewModel) to separate our business logic and data from the UI. A view model acts as a bridge between the View (the UI) and the business logic (the Model). We fetch the data in the view model (and process it if needed) so that the UI can use it. Let's take a look at an example.
Let's say we have some endpoint and we want to fetch data from it (see Routing).
class MyFirstViewModel {
private var data: SomeData? = null;
private val uiState = Observable<BasicUiState>()
fun loadData(){
basicNetworkCall(uiState) {
data = SomeDataEndPoint.remoteProcess(SomeDataEndPoint.Request()).data
}
}
}
Seems like a lot going around here, so let's break this down:
We first make a class that represents our view model (that is, MyFirstViewModel
). We have two fields in our view model, one for the data that we're willing to fetch from the server and the other is an observable for a BasicUiState
. So what exactly is a BasicUiState
? a BasicUiState
is a simple enum that has Loading
, Loaded
, and Error
variants in it. We use it to know the state of our data that we're fetching. In MyFirstViewModel
, we have a loadData
method that simply loads our data and store it in the data variable we have. loadDate
uses basicNetworkCall
which takes a block that runs asynchronously . It also takes an Observable<BasicUiState>
. basicNetworkCall
changes the state of the passed in Observable<BasicUiState>
internally and that helps us knowing the current state of our data.
Now it is time to get our view model into action!
class MyComponentThatUsesViewModel: Component() {
private val vm = MyFirstViewModel()
override fun onViewMounted(lifecycleOwner: LifecycleOwner) {
super.onViewMounted(lifecycleOwner)
vm.loadData()
}
override fun View?.getView(): View {
return verticalLayout {
withLoadingAndError(vm.uiState, onLoaded = {
val data = vm.data
data?.let {
textView {
text = data.toString()
}
}
},
onRetryClicked = vm::loadData
)
}
}
}
We start by making our view model as a field inside the component and then load the data when the component is mounted. That's what onViewMounted
for. Component
has a bunch of lifecycle methods that get called during its lifetime. We then use the nice withLoadingAndError
method that handles showing the loading indicator for us depending on the state of our data. It also handles showing a generic error message and when it gets clicked, it refetches the data (Well, to do that, we have to set the onRetryClicked
callback to load our data). withLoadingAndError
is also customizable (e.g we can show a custom error message).
Summary
In summary, we learned about components and what they are. We learned how to manage the state by using the observables and made a simple counter program to demonstrate that. We then explored view models and how they help providing data to the UI by making a simple program that displays some data from the server.
No Comments