Jetpack Compose is built around Kotlin. In some cases, Kotlin provides special idioms that make it easier to write good Compose code. If you think in another programming language and mentally translate that language to Kotlin, you're likely to miss out on some of the strength of Compose, and you might find it difficult to understand idiomatically-written Kotlin code. Gaining more familiarity with Kotlin's style can help you avoid those pitfalls.
Default arguments
When you write a Kotlin function, you can specify default values for function arguments, used if the caller doesn't explicitly pass those values. This feature reduces the need for overloaded functions.
For example, suppose you want to write a function that draws a square. That function might have a single required parameter, sideLength, specifying the length of each side. It might have several optional parameters, like thickness, edgeColor and so on; if the caller doesn't specify those, the function uses default values. In other languages, you might expect to write several functions:
// We don't need to do this in Kotlin! void drawSquare(int sideLength) { } void drawSquare(int sideLength, int thickness) { } void drawSquare(int sideLength, int thickness, Color edgeColor) { }
In Kotlin, you can write a single function and specify the default values for the arguments:
fun drawSquare( sideLength: Int, thickness: Int = 2, edgeColor: Color = Color.Black ) { }
Besides saving you from having to write multiple redundant functions, this
feature makes your code much clearer to read. If the caller doesn't specify a
value for an argument, that indicates that they're willing to use the default
value. In addition, the named parameters make it much easier to see what's going
on. If you look at the code and see a function call like this, you might not
know what the parameters mean without checking the drawSquare() code:
drawSquare(30, 5, Color.Red);
By contrast, this code is self-documenting:
drawSquare(sideLength = 30, thickness = 5, edgeColor = Color.Red)
Most Compose libraries use default arguments, and it's a good practice to do the same for the composable functions that you write. This practice makes your composables customizable, but still makes the default behavior simple to invoke. So, for example, you might create a simple text element like this:
Text(text = "Hello, Android!")
That code has the same effect as the following, much more verbose code, in which
more of the Text parameters are set explicitly:
Text( text = "Hello, Android!", color = Color.Unspecified, fontSize = TextUnit.Unspecified, letterSpacing = TextUnit.Unspecified, overflow = TextOverflow.Clip )
Not only is the first code snippet much simpler and easier to read, it's also
self-documenting. By specifying only the text parameter, you document that for
all the other parameters, you want to use the default values. By contrast, the
second snippet implies that you want to explicitly set the values for those
other parameters, though the values you set happen to be the default values for
the function.
Higher-order functions and lambda expressions
Kotlin supports higher-order functions, functions that
receive other functions as parameters. Compose builds upon this approach. For
example, the Button composable function provides an onClick
lambda parameter. The value of that parameter is a function, which the button
calls when the user clicks it:
Button( // ... onClick = myClickFunction ) // ...
Higher-order functions pair naturally with lambda expressions, expressions
which evaluate to a function. If you only need the function once, you don't
have to define it elsewhere to pass it to the higher-order function. Instead,
you can just define the function right there with a lambda expression.
The previous example assumes that myClickFunction() is defined elsewhere.
But if you only use that function here, it's simpler to just define the function
inline with a lambda expression:
Button( // ... onClick = { // do something // do something else } ) { /* ... */ }
Trailing lambdas
Kotlin offers a special syntax for calling higher-order functions whose last parameter is a lambda. If you want to pass a lambda expression as that parameter, you can use trailing lambda syntax. Instead of putting the lambda expression within the parentheses, you put it afterwards. This is a common situation in Compose, so you need to be familiar with how the code looks.
For example, the last parameter to all layouts, such as the
Column() composable function, is content, a function which
emits the child UI elements. Suppose you wanted to create a column containing
three text elements, and you need to apply some formatting. This code would
work, but it's very cumbersome:
Column( modifier = Modifier.padding(16.dp), content = { Text("Some text") Text("Some more text") Text("Last text") } )
Because the content parameter is the last one in the function signature, and
we're passing its value as a lambda expression, we can pull it out of the
parentheses:
Column(modifier = Modifier.padding(16.dp)) { Text("Some text") Text("Some more text") Text("Last text") }
The two examples have exactly the same meaning. The braces define the lambda
expression that is passed to the content parameter.
In fact, if the only parameter you're passing is that trailing lambda—that is,
if the final parameter is a lambda, and you aren't passing any other
parameters—you can omit the parentheses altogether. So, for example, suppose you
didn't need to pass a modifier to the Column. You could write the code like
this:
Column { Text("Some text") Text("Some more text") Text("Last text") }
This syntax is quite common in Compose, especially for layout elements like
Column. The last parameter is a lambda expression defining the element's
children, and those children are specified in braces after the function call.
Scopes and receivers
Some methods and properties are only available in a certain scope. The limited scope lets you offer functionality where it's needed and avoid accidentally using that functionality where it isn't appropriate.
Consider an example used in Compose. When you call the Row layout
composable, your content lambda is automatically invoked within a RowScope.
This enables Row to expose functionality which is only valid within a Row.
The following example demonstrates how Row has exposed a row-specific value
for the align modifier:
Row { Text( text = "Hello world", // This Text is inside a RowScope so it has access to // Alignment.CenterVertically but not to // Alignment.CenterHorizontally, which would be available // in a ColumnScope. modifier = Modifier.align(Alignment.CenterVertically) ) }
Some APIs accept lambdas which are called in receiver scope. Those lambdas have access to properties and functions that are defined elsewhere, based on the parameter declaration:
Box( modifier = Modifier.drawBehind { // This method accepts a lambda of type DrawScope.() -> Unit // therefore in this lambda we can access properties and functions // available from DrawScope, such as the `drawRectangle` function. drawRect( /*...*/ /* ... ) } )
For more information, see function literals with receiver in the Kotlin documentation.
Delegated properties
Kotlin supports delegated properties. These properties
are called as if they were fields, but their value is determined dynamically by
evaluating an expression. You can recognize these properties by their use of
the by syntax:
class DelegatingClass { var name: String by nameGetterFunction() // ... }
Other code can access the property with code like this:
val myDC = DelegatingClass() println("The name property is: " + myDC.name)
When println() executes, nameGetterFunction() is called to return the value
of the string.
These delegated properties are particularly useful when you're working with state-backed properties:
var showDialog by remember { mutableStateOf(false) } // Updating the var automatically triggers a state change showDialog = true
Destructuring data classes
If you define a data class, you can access the data with a
destructuring declaration. For example, suppose you
define a Person class:
data class Person(val name: String, val age: Int)
If you have an object of that type, you can access its values with code like this:
val mary = Person(name = "Mary", age = 35) // ... val (name, age) = mary
You'll often see that kind of code in Compose functions:
Row { val (