Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Introduction to Jetpack Compose

Introduction to Jetpack Compose

Composables
State
Coroutines
LaunchedEffect
rememberCoroutineScope

Jussi Pohjolainen

March 18, 2024
Tweet

More Decks by Jussi Pohjolainen

Other Decks in Technology

Transcript

  1. 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!)
  2. 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.
  3. 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!
  4. 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
  5. 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!", ) }
  6. @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
  7. 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
  8. 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.
  9. mutableStateOf val state : MutableState<String> = mutableStateOf("hello") state.value = "Hello"

    // MutableState: interface MutableState<T> : State<T> { override var value: T }
  10. class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) {

    super.onCreate(savedInstanceState) setContent { val mutableState : MutableState<String> = 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.
  11. class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) {

    super.onCreate(savedInstanceState) setContent { val mutableState : MutableState<Int> = remember { mutableStateOf(1) } Button(onClick = { mutableState.value++ }) { Text(mutableState.value.toString()) } } } } Changing the state to Int
  12. 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 }
  13. class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) {

    super.onCreate(savedInstanceState) setContent { // val mutableState : MutableState<Int> = 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!
  14. 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<String>. Compose will inject the method at compile time
  15. class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) {

    super.onCreate(savedInstanceState) setContent { var mutableState: MutableState<MutableList<String>> = 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
  16. class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) {

    super.onCreate(savedInstanceState) setContent { var mutableState: MutableState<MutableList<String>> = 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…
  17. class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) {

    super.onCreate(savedInstanceState) setContent { // var mutableState: MutableState<MutableList<String>> // = remember { mutableStateOf(mutableListOf("hei", "moi")) } val mutableList : SnapshotStateList<String> = remember { mutableStateListOf("hei", "moi") } Button(onClick = { mutableList.add("Hello") }) { Text(mutableList.toList().toString()) } } } } Now it is observable list
  18. 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
  19. @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
  20. @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
  21. 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
  22. @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
  23. 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
  24. @Composable fun App() { val list: SnapshotStateList<String> = 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
  25. @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
  26. @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!
  27. 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!
  28. 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
  29. @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
  30. 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.
  31. @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
  32. 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
  33. 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
  34. 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
  35. @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)
  36. @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
  37. // Define your data model data class Result(val users: List<User>)

    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
  38. @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
  39. @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
  40. 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