Slide 1

Slide 1 text

Quick Start to Jetpack Compose Jussi Pohjolainen

Slide 2

Slide 2 text

Overview • Jetpack Compose is a modern toolkit for building native Android UI • Jetpack Compose simplifies and accelerates UI development on Android with less code, powerful tools, and intuitive Kotlin API • Much easier, no more jumping between code and xml • Some UI elements a lot easier to implement (Lists!)

Slide 3

Slide 3 text

Essentials • Composables • State • Coroutines • LaunchedEffect • rememberCoroutineScope

Slide 4

Slide 4 text

Empty Activity will give jetpack compose project

Slide 5

Slide 5 text

Composable • Composables are functions in Jetpack Compose that define UI components. • Annotated with @Composable. • Can contain other composables, creating a hierarchy. • Automatically update UI with state changes. • Executed by the Compose runtime, not directly by the developer. • Can accept parameters for configuration and dynamic content. • Follow a declarative programming model. • Designed for Kotlin and integrate seamlessly with Android's UI toolkit.

Slide 6

Slide 6 text

class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Greeting("Android") } } } @Composable fun Greeting(name: String) { Text(text = "Hello $name!") } No XML, give composable function Annotates the function to be composable, part of the UI Text is another composable, notice now return!

Slide 7

Slide 7 text

Feature React Components Jetpack Compose Composables SwiftUI Views Paradigm Declarative Declarative Declarative UI Definition JSX Kotlin code with @Composable Swift code State Management useState, useContext, etc. remember, mutableStateOf, etc. @State, @Binding, etc. Reusability Components can be reused Composables can be reused Views can be reused Hierarchical Structure Component tree Composable tree View hierarchy Rendering Mechanism Virtual DOM, diffing algorithm Slot table, smart recomposition Declarative UI, diffing algorithm Lifecycle Management useEffect, useMemo, etc. DisposableEffect, LaunchedEffect onAppear, onDisappear Environment Adaptation Responsive design with CSS or libs Modifiers, MaterialTheme, etc. EnvironmentValues, @Environment Platform Integration Web (DOM) Android iOS, macOS, watchOS, tvOS Development Language JavaScript, TypeScript Kotlin Swift Community and Ecosystem Large, with extensive libraries Growing, Android-centric Growing, Apple platforms-focused UI Updates Explicit re-renders Automatic UI updates Automatic UI updates Data Flow Props down, events up Parameters, state hoisting Binding, state hoisting Debugging Tools React Developer Tools Layout Inspector, Compose Preview Xcode Previews, Instruments Learning Curve Moderate Moderate to high Moderate to high

Slide 8

Slide 8 text

class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { App() } } } @Composable fun App() { Greeting("Hello") Greeting("World") } @Composable fun Greeting(name: String) { Text( text = "Hello $name!", ) }

Slide 9

Slide 9 text

@Composable fun App() { Column { Greeting("Hello") Greeting("World") } } @Composable fun Greeting(name: String) { Text( text = "Hello $name!", ) } Column will put the composables in one column

Slide 10

Slide 10 text

Managing State

Slide 11

Slide 11 text

Overview • State in an app is any value that can change over time • All Android apps display state to the user • Jetpack Compose helps you be explicit about where and how you store and use state in an Android app

Slide 12

Slide 12 text

State and composition • Any time a state is updated a recomposition takes place • Composable functions can use the remember API to store an object in memory • A value computed by remember is stored in the Composition during initial composition, and the stored value is returned during recomposition • remember can be used to store both mutable and immutable objects.

Slide 13

Slide 13 text

mutableStateOf val state : MutableState = mutableStateOf("hello") state.value = "Hello" // MutableState: interface MutableState : State { override var value: T }

Slide 14

Slide 14 text

class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { val mutableState : MutableState = remember { mutableStateOf("Hello") } Button(onClick = { mutableState.value = "world" }) { Text(mutableState.value) } } } } Creates a MutableState that is remembered across recompositions. The remember function will ensure that the mutableStateOf("Hello") is only invoked once, and its result is preserved as long as the composable where this state is created remains in the Composition and does not get recomposed with different keys.

Slide 15

Slide 15 text

class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { val mutableState : MutableState = remember { mutableStateOf(1) } Button(onClick = { mutableState.value++ }) { Text(mutableState.value.toString()) } } } } Changing the state to Int

Slide 16

Slide 16 text

import kotlin.reflect.KProperty class Delegate { private var value: Int = 0 operator fun getValue(thisRef: Any?, property: KProperty<*>): Int { println("getValue called for '${property.name}', returning $value") return value } operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: Int) { println("setValue called for '${property.name}', changing value from $value to $newValue") value = newValue } } class Example { var x: Int by Delegate() // Notice here the by keyword } fun main() { val example = Example() example.x = 4 // This will invoke setValue of Delegate println(example.x) // This will invoke getValue of Delegate }

Slide 17

Slide 17 text

class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { // val mutableState : MutableState = remember { mutableStateOf(1) } var intState : Int by remember { mutableStateOf(1) } Button(onClick = { intState++ }) { Text(intState.toString()) } } } } By using by keyword it will invoke the state variable for you!

Slide 18

Slide 18 text

class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { var (value, setValue) = remember { mutableStateOf(1) } Button(onClick = { setValue(++value) }) { Text(value.toString()) } } } } Destruct MutableState. Compose will inject the method at compile time

Slide 19

Slide 19 text

class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { var mutableState: MutableState> = remember { mutableStateOf(mutableListOf("hei", "moi")) } Button(onClick = { mutableState.value.add("hello")}) { Text(mutableState.value.toString()) } } } } UI refresh does not happen, value itself is the same

Slide 20

Slide 20 text

class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { var mutableState: MutableState> = remember { mutableStateOf(mutableListOf("hei", "moi")) } Button(onClick = { // Create a new list that is a copy of the current one val newList = mutableState.value.toMutableList() // Add "hello" to the new list newList.add("hello") // Update mutableState with the new list mutableState.value = newList }) { Text(mutableState.value.toString()) // Assuming mutableState is a list of strings } } } } Now it works, but it’s rather painful to do…

Slide 21

Slide 21 text

class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { // var mutableState: MutableState> // = remember { mutableStateOf(mutableListOf("hei", "moi")) } val mutableList : SnapshotStateList = remember { mutableStateListOf("hei", "moi") } Button(onClick = { mutableList.add("Hello") }) { Text(mutableList.toList().toString()) } } } } Now it is observable list

Slide 22

Slide 22 text

State Hoisting • Main composable may have state • This state can be passed to child composables • You can also pass a function to child, child calls it and then parent will do something

Slide 23

Slide 23 text

@Composable fun HelloScreen() { var name by remember { mutableStateOf("") } Column { Button(onClick = { name = "Jack" }) { Text("Change") } Greeting(name) } } @Composable fun Greeting(name : String) { Text(name) } This will change when main component state name changes

Slide 24

Slide 24 text

@Composable fun HelloScreen() { var name by remember { mutableStateOf("") } Column { MyButton("Change") { newName -> name = newName } Text(name) } } @Composable fun MyButton(title : String, callback: (newName: String) -> Unit) { Button(onClick = { callback("Jack") }) { Text(title) } } Callback to parent Callback received, state change and “Jack” is shown

Slide 25

Slide 25 text

State Preservation • From landscape to portrait state is not saved • The rememberSaveable API behaves similarly to remember because it retains state across recompositions, and also across activity or process recreation using the saved instance state mechanism. For example, this happens, when the screen is rotated. • If state contains custom objects, use @Parcelize

Slide 26

Slide 26 text

@Composable fun HelloScreen() { var name by rememberSaveable { mutableStateOf("") } Column { MyButton("Change") { newName -> name = newName } Text(name) } } @Composable fun MyButton(title : String, callback: (newName: String) -> Unit) { Button(onClick = { callback("Jack") }) { Text(title) } } Restores state also when orientation changes

Slide 27

Slide 27 text

Lifecycle

Slide 28

Slide 28 text

Lifecycle • Initial composition • Keep track of the composables that you have describe in UI • State change • Jetpack Compose schedules a recomposition • Re-executes the composables that may have changed in response to state changes • Lifecycle is simple • Enter • Recompose (0 – n) • Leave

Slide 29

Slide 29 text

@Composable fun App() { val list: SnapshotStateList = remember { mutableStateListOf("Jack", "Hannah") } Column { for (text in list) { Greeting(text) } Button(onClick = { list.add("Tina") }) { Text("Add Name") } } } State triggers recomposition When adding a name, it will not recompose the Greetings already in the list

Slide 30

Slide 30 text

@Composable fun App() { val list = remember { mutableStateListOf("Jack", "Hannah") } Column { list.forEachIndexed { index, text -> Greeting(text, index) } Button(onClick = { list.add("Tina") }) { Text("Add Name") } } } @Composable fun Greeting(name: String, index: Int) { Text( text = "Hello $name!", ) SideEffect { Log.d("ComposeRecomposition", "Recomposing at $index") } } SideEffect is called after the composition is complete. Used to perform operations that can affect the state outside of the composable

Slide 31

Slide 31 text

@Composable fun App() { val list = remember { mutableStateListOf("Jack", "Hannah", "Tina") } Column { list.forEachIndexed { index, text -> Greeting(text, index) } Button(onClick = { list.removeFirst() }) { Text("Add Name") } } } @Composable fun Greeting(name: String, index: Int) { Text( text = "Hello $name!", ) SideEffect { Log.d("ComposeRecomposition", "Recomposing at $index") } } If inserting to top or removing from top (or middle), every Greeting is composed again!

Slide 32

Slide 32 text

data class Person(val id : Int, val name : String) @Composable fun App() { val list = remember { mutableStateListOf(Person(1, "Jack"), Person(2, "Tina"), Person(3, "Paul")) } Column { list.forEach { person -> key(person.id) { Greeting(person.name, person.id) } } Button(onClick = { list.removeFirst() }) { Text("Add Name") } } } @Composable fun Greeting(name: String, index: Int) { Text( text = "Hello $name!", ) SideEffect { Log.d("ComposeRecomposition", "Recomposing at $index") } } Key identifies the composable and now no unnessary refresh of composables!

Slide 33

Slide 33 text

var id = 3 data class Person(val id : Int, val name : String) @Composable fun App() { val list = remember { mutableStateListOf(Person(1, "Jack"), Person(2, "Tina"), Person(3, "Paul")) } Column { list.forEach { person -> key(person.id) { Greeting(person.name, person.id) } } Button(onClick = { list.add(0, Person(++id, "Amanda")) }) { Text("Add Name") } } } @Composable fun Greeting(name: String, index: Int) { Text( text = "Hello $name!", ) SideEffect { Log.d("ComposeRecomposition", "Recomposing at $index") } } Only amanda Greeting is recomposed

Slide 34

Slide 34 text

@Composable fun App() { val list = remember { mutableStateListOf( Person(1, "Jack"), Person(2, "Tina"), Person(3, "Paul")) } LazyColumn { items(list, key = { person -> person.id }) { person -> Greeting(name = person.name, index = person.id) } item { Button(onClick = { list.add(0, Person(++id, "Amanda")) }) { Text("Add Name") } } } } Some composables has prebuilt key functionality

Slide 35

Slide 35 text

Side-effects

Slide 36

Slide 36 text

LaunchedEffect • Call suspend functions inside of composable • When LaunchedEffect enters the Composition, it launches a coroutine with the block of code passed as a parameter. • The coroutine will be cancelled if LaunchedEffect leaves the composition • If LaunchedEffect is recomposed with different keys, the existing coroutine will be cancelled and the new suspend function will be launched in a new coroutine.

Slide 37

Slide 37 text

@Composable fun Clock() { // State to hold the current time var time: LocalTime by remember { mutableStateOf(LocalTime.now()) } // LaunchedEffect to update the time every second LaunchedEffect(Unit) { while (true) { time = LocalTime.now() // Update the time Log.d("time", time.toString()) delay(1000) // Wait for a second } } // Display the formatted time Text(text = time.toString()) } Creates new coroutine in Dispatchers.Main, so it uses event loop mechanism

Slide 38

Slide 38 text

setContent { var showClock by remember { mutableStateOf(true) } Column { Button(onClick = { showClock = !showClock }) { Text(if (showClock) "Hide Clock" else "Show Clock") } if (showClock) { Clock() } } } Will cancel the coroutine by default

Slide 39

Slide 39 text

class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { var inputText by remember { mutableStateOf("") } val number = inputText.toIntOrNull() Column { TextField( value = inputText, onValueChange = { inputText = it }, label = { Text("Enter countdown start") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) ) if (number != null) { Countdown(startNumber = number) } } } } } @Composable fun Countdown(startNumber: Int) { var currentNumber by remember { mutableStateOf(startNumber) } LaunchedEffect(key1 = startNumber) { currentNumber = startNumber // Reset the currentNumber to the new startNumber for (i in startNumber downTo 0) { currentNumber = i delay(1000) } } Text(text = "Countdown: $currentNumber") } Dependency on the given argument. If it changes, current coroutine cancels and new one starts

Slide 40

Slide 40 text

LaunchedEffect vs CoroutineScope Feature LaunchedEffect rememberCoroutineScope Scope Composable function Composable function Lifespan Tied to the composable's lifecycle Tied to the composable's lifecycle Launch Time Automatically on recomposition and if keys change Manually, whenever needed Use Case - Starting coroutines that need to be cancelled and restarted with the composable - Suitable for operations that should restart on key changes - Suitable if coroutine should start when composable starts - Starting coroutines without automatic restart on recomposition - More control over coroutine launch and cancellation Example Fetching data when composable enters the composition or keys change Handling user interactions that trigger coroutines, like button clicks Cancellation Automatically cancelled when the composable leaves the composition or keys change Manual cancellation or tied to the composable's lifecycle Control Over Coroutine Limited, as it restarts with key changes Full control, manual launch and cancellation

Slide 41

Slide 41 text

@Composable fun App() { // Remembering a value across recompositions var count by remember { mutableIntStateOf(0) } // Remembering a coroutine scope across recompositions val coroutineScope = rememberCoroutineScope() Column { Button(onClick = { coroutineScope.launch { repeat(10) { // Perform asynchronous operation delay(1000) // Update the count count++ } } }) { Text(text = "Click me") } // Display the count Text(text = "Count: ${count}") } } Will create several coroutines (one thread)

Slide 42

Slide 42 text

@Composable fun App() { // Remembering a value across recompositions var count by remember { mutableStateOf(0) } // Remembering a flag to track if coroutine is already running var coroutineRunning by remember { mutableStateOf(false) } // Remembering a coroutine scope across recompositions val coroutineScope = rememberCoroutineScope() Column { Button(onClick = { if (!coroutineRunning) { coroutineRunning = true coroutineScope.launch { repeat(10) { // Perform asynchronous operation delay(1000) // Update the count count++ } coroutineRunning = false } } }) { Text(text = "Click me") } // Display the count Text(text = "Count: ${count}") } } Preventing other coroutines

Slide 43

Slide 43 text

// Define your data model data class Result(val users: List) data class User(val id: Int, val firstName: String) interface UserService { @GET("/users") suspend fun fetchUsers(): Result @GET("/users/{id}") suspend fun fetchUserById(@Path("id") id: Int): User companion object { fun getService(): UserService { val retrofit = Retrofit.Builder() .baseUrl("https://dummyjson.com/") .addConverterFactory(GsonConverterFactory.create()) .build() return retrofit.create(UserService::class.java) } } } /* implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation("com.squareup.retrofit2:converter-gson:2.9.0") */ Retrofit

Slide 44

Slide 44 text

@Composable fun App() { // Remembering a value across recompositions var name by remember { mutableStateOf("") } var idText by remember { mutableStateOf("") } var scope = rememberCoroutineScope() val id = idText.toIntOrNull() ?: 1 Column { Button(onClick = { scope.launch { val service = UserService.getService() val user = withContext(Dispatchers.IO) { service.fetchUserById(5) } name = user.firstName } }) { Text("Fetch") } Text(text = "Name: $name") } } You can now have several of these at the same time

Slide 45

Slide 45 text

@Composable fun App() { // Remembering a value across recompositions var name by remember { mutableStateOf("") } var idText by remember { mutableStateOf("") } val id = idText.toIntOrNull() ?: 1 LaunchedEffect(id) { val service = UserService.getService() val user = withContext(Dispatchers.IO) { service.fetchUserById(id) } name = user.firstName } Column { TextField( value = idText, onValueChange = { idText = it }, label = { Text("Enter User ID") } ) Text(text = "Name: $name") } } Dependent on id changes Changes the id Uses thread pool for IO

Slide 46

Slide 46 text

LaunchedEffect vs RememberCoroutineScope • Want that coroutine triggers immediately after recomposition? • LaunchedEffect • Want the coroutine is launched when state variable changes • LaunchedEffect • Want that coroutine does not trigger immediately after recomposition? • rememberCoroutineScope • Want more control on the lifecycle of the coroutine? (start, stop) • rememberCoroutineScope