Some different approaches
Several approaches and related APIs are proposed to develop GUI for Android:
-
The standard API handling classes inheriting from View (graphical components, layouts...). This API exists from the first version of Android and consists in creating trees of graphical components. These trees can be created:
- Programmatically but the code is very verbose
- Using a XML layout file to describe with XML tags the trees of components with their properties
- One can add listeners to components to react to events (like click, touch, key press...). One can also programmatically change the properties of the components (like setting the text displayed by a TextView with setText(...)) when events arise (clicks, lifecycle events of the activity...).
- The MVVM (Model-View-ViewModel) approach using the Jetpack Data Binding Library. This approach is similar to the approach by some JavaScript web frameworks like Angular. It was originally proposed in the Windows Presentation Foundation (WPF), a GUI API for the .Net platform. With this approach, the view is defined with a template (layout XML file) with embedded evaluable expressions and code for event handling. When observable objects are updated, the view is automatically refreshed with the new values. Therefore we don't have to call setters anymore on views to change their properties.
- The functional composing approach with Jetpack compose. This API is dedicated for the Kotlin language: it proposes a kind of DSL to create GUI by declaring composable functions. Defining the layout in XML file is not required anymore with this approach; all the work is done programmatically in functions. This approach is inspired by the frameworks React (JavaScript) and Flutter (Dart language). Like React and Flutter, Jetpack compose can also be used to create native desktop applications (for Windows, Linux or MacOS).
An example implemented using different approaches
We want to implement a small game with a screen containing one button and a label displaying the number of times the button is clicked. Click after click the background color of the label changes. After N clicks, the game is ended with a congratulation popup message (toast). The activity restarts itself with a goal of N+k clicks.
The examples are implemented using the Kotlin language.
Classical implementation
The classical implementation relies on the use of a declarative layout for the tree of components and the activity where actions to manage lifecycle and events are implemented.
<?xml version="1.0" encoding="utf-8"?> <!-- Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License --> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".clickstyle.classical.ClassicalClickGame"> <TextView android:id="@+id/clickTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="#000000" android:textColor="#FFFFFF" android:textSize="40sp" app:layout_constraintBottom_toTopOf="@+id/clickButton" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <Button android:id="@+id/clickButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Click here" android:textSize="40sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License package fr.upem.coursand.clickstyle.classical import android.content.Intent import android.graphics.Color import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import android.widget.Toast import fr.upem.coursand.R import kotlinx.android.synthetic.main.activity_classical_click_game.* class ClassicalClickGame : AppCompatActivity() { companion object { const val DEFAULT_CLICK_GOAL = 10 const val DEFAULT_CLICK_GOAL_INCREMENT = 10 } private var clickGoal: Int = DEFAULT_CLICK_GOAL private var clickNumber: Int = 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) clickGoal = intent.getIntExtra("clickGoal", DEFAULT_CLICK_GOAL) setContentView(R.layout.activity_classical_click_game) clickButton.setOnClickListener { clickNumber++ clickTextView.text = "$clickNumber clicks" clickTextView.setBackgroundColor(Color.rgb(0, ((clickNumber.toFloat() / clickGoal) * 255).toInt(), 0)) if (clickNumber == clickGoal) { Toast.makeText(this, "Congratulations, $clickNumber clicks made!", Toast.LENGTH_LONG).show() finish() Intent(this, ClassicalClickGame::class.java).apply { putExtra("clickGoal", clickGoal + DEFAULT_CLICK_GOAL_INCREMENT) startActivity(this) } } } } }
Data-binding implementation
For the data-binding implementation, we declare a class containing all the data required by the activity (the model):
// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License package fr.upem.coursand.clickstyle.databinding import androidx.databinding.BaseObservable import androidx.databinding.Bindable import fr.upem.coursand.BR class GameData(_clickGoal: Int = DEFAULT_CLICK_GOAL): BaseObservable() { companion object { const val DEFAULT_CLICK_GOAL = 10 const val DEFAULT_CLICK_GOAL_INCREMENT = 10 } @Bindable var clickGoal: Int = _clickGoal @Bindable var clickNumber: Int = 0 set(value) { field = value notifyPropertyChanged(BR.clickNumber) } @get:Bindable val isFinished: Boolean get() = clickNumber >= clickGoal fun incrementClick() { clickNumber += 1 } }
We have also a class to implement the actions to do:
// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License package fr.upem.coursand.clickstyle.databinding import android.app.Activity import android.content.Intent import android.graphics.Color import android.util.Log import android.view.View import android.widget.Toast import androidx.databinding.BindingAdapter object GameAction { /** Finish the current activity and start a new one with a new goal */ private fun restartActivity(v: View, newGoal: Int) { val activity = v.context as Activity activity.finish() activity.startActivity(Intent(activity, DataBindingClickGame::class.java).putExtra("clickGoal", newGoal)) } fun addClick(v: View, data: GameData) { data.incrementClick() Log.i(javaClass.name, "Clicks made: ${data.clickNumber}/${data.clickGoal}") if (data.isFinished) { Toast.makeText(v.context, "Congratulations, ${data.clickNumber} clicks made!", Toast.LENGTH_LONG).show() restartActivity(v, data.clickGoal + GameData.DEFAULT_CLICK_GOAL_INCREMENT) } } @BindingAdapter("greeness") @JvmStatic fun setGreeness(view: View, greeness: Float) { view.setBackgroundColor(Color.rgb(0, (greeness * 255).toInt(), 0)) } }
Then we create a layout XML file like we have done for the classical implementation but we add the declaration of the required variables gameData and gameAction and we embed declarations of expressions for the values of properties and call to a method for onclick:
<?xml version="1.0" encoding="utf-8"?> <!-- Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License --> <layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <variable name="gameData" type="fr.upem.coursand.clickstyle.databinding.GameData" /> <variable name="gameAction" type="fr.upem.coursand.clickstyle.databinding.GameAction" /> </data> <androidx.constraintlayout.widget.ConstraintLayout xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".clickstyle.databinding.DataBindingClickGame"> <TextView android:id="@+id/clickTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" app:greeness="@{(float)gameData.clickNumber / gameData.clickGoal}" android:textColor="#ffffffff" android:textSize="40sp" app:layout_constraintBottom_toTopOf="@+id/clickButton" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" android:text='@{"" + gameData.clickNumber}' /> <Button android:id="@+id/clickButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Click here" android:textSize="40sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" android:onClick="@{(view) -> gameAction.addClick(view, gameData)}" /> </androidx.constraintlayout.widget.ConstraintLayout> </layout>
Finally we create the activity that binds the model and the template layout together:
// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License package fr.upem.coursand.clickstyle.databinding import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import androidx.databinding.DataBindingUtil import fr.upem.coursand.R import fr.upem.coursand.databinding.ActivityDataBindingClickGameBinding class DataBindingClickGame : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding: ActivityDataBindingClickGameBinding = DataBindingUtil.setContentView(this, R.layout.activity_data_binding_click_game) // create the gameData object containing the data displayed by the activity binding.gameData = GameData(intent.getIntExtra("clickGoal", GameData.DEFAULT_CLICK_GOAL)) binding.gameAction = GameAction } }
Compose implementation
The compose implementation relies on the use of the latest Android Studio canary version.
We create a class to represent the model of the activity (like we have done for the data-binding version) with the annotation @Model:
package fr.upem.coursand.clickstyle.compose import androidx.compose.Model @Model class GameState(val clickGoal: Int = 10, var clickNumber: Int = 0)
And we create the activity that embeds functions to compose the user interface. The user interface is automatically rebuild when fields of the model are updated:
package fr.upem.coursand.clickstyle.compose import android.content.Intent import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import android.util.Log import androidx.compose.Composable import androidx.compose.Model import androidx.ui.core.* import androidx.ui.graphics.Color import androidx.ui.layout.* import androidx.ui.material.Button import androidx.ui.material.MaterialTheme import androidx.ui.material.OutlinedButtonStyle import androidx.ui.text.TextStyle import androidx.ui.tooling.preview.Preview class ComposeClickGame : AppCompatActivity() { companion object { const val DEFAULT_CLICK_GOAL = 10 const val DEFAULT_CLICK_INCREMENT = 10 } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val gameState = GameState(clickGoal = intent.getIntExtra("clickGoal", DEFAULT_CLICK_GOAL)) setContent { mainView(gameState = gameState) } } @Composable fun mainView(gameState: GameState) { MaterialTheme() { Column(mainAxisAlignment = MainAxisAlignment.SpaceBetween, crossAxisAlignment = CrossAxisAlignment.Center) { textView(clickNumber = gameState.clickNumber, clickGoal = gameState.clickGoal) HeightSpacer(height = 24.dp) Button(text="Let's click here", onClick = { gameState.clickNumber += 1 Log.v(javaClass.name, "Clicks: ${gameState.clickNumber}/${gameState.clickGoal}") if (gameState.clickNumber >= gameState.clickGoal) { finish() startActivity(Intent(this@ComposeClickGame, ComposeClickGame::class.java) .putExtra("clickGoal", gameState.clickGoal + DEFAULT_CLICK_INCREMENT)) } }) } } } @Composable fun textView(clickNumber: Int, clickGoal: Int) { Text(text="Clicks: $clickNumber", style = TextStyle(fontSize = 40.sp, color = Color.White, background = Color(0.0f, clickNumber.toFloat() / clickGoal.toFloat(), 0.0f) )) } @Preview @Composable fun mainViewPreview() { mainView(GameState(10, 1)) } }
We notice that we don't need a XML layout anymore; the code to write (in Kotlin) is more concise.