Skip to main content

13 - Jetpack Compose

Jetpack Compose Intro

In traditional Android development, UI development is handled using an imperative approach.

This means you explicitly tell the system what UI elements(views) to create and how to manipulate them at each step. You define the UI structure in XML layouts and then write code to find those views and update their properties (text, images, click listeners, etc.).

Jetpack Compose, on the other hand, takes a declarative approach.

You describe the desired UI state (what you want the UI to look like) in composable functions written in Kotlin. Compose handles the rendering logic itself, making your code more concise and easier to maintain.

//in layout XML file
<TextView
android:id="@+id/text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!" />
//in Activity Kotlin class

val textView = findViewById<TextView>(R.id.text_view)
textView.text = "Updated Text"

vs

Text(text = "Hello World!")

// Later, to update the text:
Text(text = "Updated Text")

What are composable functions?

A composable function is a regular function marked with @Composable, which can call other composable functions. A function is all you need to create a new UI component. The annotation tells Compose to add special support to the function for updating and maintaining your UI over time. Compose lets you structure your code into small chunks. Composable functions are often referred to as "composables" for short.

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}

XML vs Compose

  • TextView - Text
  • EditText - TextField
  • ImageView - Image
  • Button - Button
  • LinearLayout - Box
  • FrameLayout - Box
  • ConstraintLayout - BoxWithConstraints
  • List - LazyColumn, LazyRow, Lazy Grids
  • CardView - Card

AlertDialog, Scaffold, Surface, Icon, Spacer

Arrange UI elements on screen

The three basic standard layout elements in Compose are Column, Row and Box.

Compose

They are Composable functions that take Composable content, so you can place items inside.

Use Column to place items vertically on the screen.

@Composable
fun Item() {
Column {
Text("Title")
Text("Description")
}
}

Use Row to place items horizontally on the screen.

@Composable
fun Profile() {
Row {
Text("First Name")
Text("Last Name")
}
}

Use Box to put elements on top of another.

@Composable
fun AddProfilePic() {
Box {
Image(
modifier = Modifier.clip(CircleShape),
painter = painterResource(id = R.drawable.profile_avatar),
contentDescription = null )
Image(
modifier = Modifier.align(Alignment.BottomEnd),
painter = painterResource(id = R.drawable.add_icon),
contentDescription = null )
}
}

Modifiers

Modifiers allow you to decorate or augment a composable. Modifiers let you do these sorts of things:

  • Change the composable’s size, layout, behavior, and appearance
  • Add information, like accessibility labels
  • Process user input
  • Add high-level interactions, like making an element clickable, scrollable, draggable, or zoomable

The order of modifier functions is significant. Since each function makes changes to the Modifier returned by the previous function, the sequence affects the final result.

@Composable
fun ModifierExample() {
Box(
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.background(Color.Blue)

)
}
  • Layout
    • padding
    • fillMaxSize
    • wrapContentSize
    • size
    • offset
    • align
    • weight
  • Drawing
    • background
    • border
    • clip
    • alpha
    • shadow
  • Interaction
    • clickable
    • toggleable
    • draggable
    • scrollable
  • Animation
    • animateContentSize
    • graphicsLayer
  • Gesture
    • pointerInput
    • nestedScroll
  • Focus
    • focusable

Tips

  • Layout Modifiers First: Adjust the positioning, alignment, and spacing before setting size and drawing properties.
  • Size Modifiers Next: Set the size after layout adjustments to ensure the correct dimensions are applied.
  • Drawing Modifiers Last: Apply visual styles like backgrounds, borders, and shadows after all layout and size adjustments to ensure they render accurately.

https://developer.android.com/develop/ui/compose/modifiers

Reactivity

Composable functions are naturally reactive. When the data they depend on changes, the Compose runtime automatically triggers a recomposition of the affected parts of the UI. This reactivity is typically facilitated by state holders like mutableStateOf, which Compose tracks to determine when recomposition is needed.

// Composable Function to Display a Screen
@Composable
fun GreetingScreen() {
// State Variable to Handle Button Clicks
var count by remember { mutableStateOf(0) }

// Column Layout for Organizing the Text and Button
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center
) {
// Text Displaying the Count Value
Text(text = "Button clicked $count times")

Spacer(modifier = Modifier.height(16.dp))

// Button with Click Handler to Update Count
Button(onClick = { count++ }) {
Text(text = "Click Me")
}
}
}

remember and mutableStateOf

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.

mutableStateOf creates an observable MutableState, which is an observable type integrated with the compose runtime.

interface MutableState<T> : State<T> {
override var value: T
}

Any changes to value schedules recomposition of any composable functions that read value.

There are three ways to declare a MutableState object in a composable:

val mutableState = remember { mutableStateOf(default) }
var value by remember { mutableStateOf(default) }
val (value, setValue) = remember { mutableStateOf(default) }

These declarations are equivalent, and are provided as syntax sugar for different uses of state.

Types of State in Compose

Local State: This is state that is owned by a single composable and not shared outside of it. For example, a toggle that expands or collapses a section of text is typically a local state because it’s used only within that composable.
Hoisted State: The state is moved up to a shared parent component to make it easier to manage across different parts of the UI. This is especially useful when multiple composables need access to the same state.

Local state

@Composable
fun RememberMeExample() {
var rememberMe by remember { mutableStateOf(false) }
Checkbox(
checked = rememberMe,
onCheckedChange = { rememberMe = it }
)
}

State Hoisting

State hoisting - state is moved up to a higher composable function to manage it in a more centralized way. This makes the state accessible to multiple composables, allowing for better separation of concerns and easier management of UI state.

// Root Composable Function
@Composable
fun MyApp() {
// State Hoisting: State is managed at a higher level
var count by remember { mutableStateOf(0) }

// Passing state and update logic down to composables
CounterScreen(count = count, onIncrement = { count++ })
}

// Composable Function that Displays Counter UI
@Composable
fun CounterScreen(count: Int, onIncrement: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center
) {
// Display the current count
CounterText(count = count)

Spacer(modifier = Modifier.height(16.dp))

// Increment button that triggers state change via onIncrement
CounterButton(onClick = onIncrement)
}
}

// Reusable Composable to Display Count
@Composable
fun CounterText(count: Int) {
Text(text = "You clicked $count times")
}

// Reusable Composable for Button
@Composable
fun CounterButton(onClick: () -> Unit) {
Button(onClick = onClick) {
Text(text = "Click Me")
}
}

https://developer.android.com/develop/ui/compose/state-hoisting

Side Effects

Since composables can be recomposed frequently and unpredictably, managing side effects (operations like launching a network request or subscribing to a StateFlow) properly is crucial.

  • LaunchedEffect: Use this for side effects that should only run once or need to be canceled and restarted based on specific conditions (key). It's useful for actions like fetching data or starting animations.
  • DisposableEffect: Ideal for managing cleanup operations. It ensures that when a composable leaves the composition, any resources it used are properly released. This is crucial for avoiding memory leaks.
  • rememberCoroutineScope: If you need to launch coroutines from a composable that need to be tied to the lifecycle of the composable, this provides a way to remember a coroutine scope that is canceled when the composable is removed from the composition.
@Composable
fun UserProfile(userId: String) {
val userInfo = remember { mutableStateOf<User?>(null) }

LaunchedEffect(userId) {
userInfo.value = userRepository.fetch(userId)
}
DisposableEffect(Unit) {
onDispose {
// Cleanup code here
}
}
userInfo.value?.let { user ->
Text("Hello, ${user.name}")
}
}
  • SideEffect : Control side effects that are idempotent and executed during composition.
    You should use the SideEffect composable if you need to perform a side effect that is idempotent and that should only be executed once, even if the composable is recomposed multiple times.
  • rememberUpdatedState : Maintain a stable reference to a value, ensuring accurate updates during recomposition.
  • ProduceState : Combine state and side effects, ensuring that UI display latest data.
  • DerivedStateOf : Derived the state value from another state values.
//Composable with remember and LaunchedEffect
fun MyComposable(value: Int) {

val state = remember { mutableStateOf("") }

// Update the state variable with the user's name.
LaunchedEffect(key1 = "keyName") {
value = fetchUserName()
}

// Display the user's name.
Text(text = state.value)
}

//Composable with produceState
fun MyComposableWithProduceState(value: Int) {

val state = produceState(initialValue = "") {
// Update the state variable with the user's name.
value = fetchUserName()
}

// Display the user's name.
Text(text = state.value)
}
fun MyComposable(value: Int) {

// Create a state variable that stores the user's name.
val nameState = remember { mutableStateOf("") }

// Create a new state variable that stores the user's name in uppercase.
val nameInUpperCaseState = remember { derivedStateOf {
// Convert the user's name to uppercase.
return it.value.uppercase()
}
}

// Display the user's name in uppercase.
Text(text = nameInUpperCaseState.value)
}

Unidirectional data flow

A unidirectional data flow (UDF) is a design pattern where state flows down and events flow up. By following unidirectional data flow, you can decouple composables that display state in the UI from the parts of your app that store and change state.

The UI update loop for an app using unidirectional data flow looks like this:

  • Event: Part of the UI generates an event and passes it upward, such as a button click passed to the ViewModel to handle or an event is passed from other layers of your app, such as indicating that the user session has expired.
  • Update state: An event handler might change the state.
  • Display state: The state holder passes down the state, and the UI displays it.

Compose

In Compose the UI is immutable — there’s no way to update it after it’s been drawn. What you can control is the state of your UI. Every time the state of the UI changes, Compose recreates the parts of the UI tree that have changed. Composables can accept state and expose events — for example, a TextField accepts a value and exposes a callback onValueChange that requests the callback handler to change the value.

var name by remember { mutableStateOf("") }
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") }
)

Example

https://m3.material.io/develop/android/jetpack-compose

@Composable
fun LoginPage() {
val context = LocalContext.current
Box(modifier = Modifier.fillMaxSize()) {
ClickableText(
text = AnnotatedString("Sign up here"),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(20.dp),
onClick = {
Toast.makeText(context, "Work in-progress", Toast.LENGTH_SHORT).show()
},
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily.Default,
textDecoration = TextDecoration.Underline,
color = PurpleGrey40
)
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {

val username = remember { mutableStateOf(TextFieldValue()) }
val password = remember { mutableStateOf(TextFieldValue()) }

Text(text = "Login", style = TextStyle(fontSize = 40.sp, fontFamily = FontFamily.Default))

Spacer(modifier = Modifier.height(20.dp))
TextField(
label = { Text(text = "Mobile Number") },
value = username.value,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
onValueChange = { username.value = it })

Spacer(modifier = Modifier.height(20.dp))
TextField(
label = { Text(text = "Password") },
value = password.value,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
onValueChange = { password.value = it })

Spacer(modifier = Modifier.height(20.dp))
Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) {
Button(
onClick = {
ContextCompat.startActivity(
context,
Intent(context, MainActivity::class.java),
null
)
},
shape = RoundedCornerShape(50.dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
) {
Text(text = "Login")
}
}

Spacer(modifier = Modifier.height(20.dp))
ClickableText(
text = AnnotatedString("Forgot password?"),
onClick = {
Toast.makeText(context, "Work in-progress", Toast.LENGTH_SHORT).show()
},
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily.Default
)
)
}
}