Jetpack Compose is a declarative GUI framework proposed to build activities on Android (available only using Kotlin).
Graphical interfaces are built using composition of functions that emit graphical nodes; functions are automatically called again to refresh the interface when some of their parameters change or when an underlying state is modified.
Contrary to the legacy view API that works with XML files describing the layout that are inflated and actions that are coded inside the activity, if we use the Compose API, all the code describing the GUI including rendering and actions are present in a single file, the activity.
With Compose, we do not use several activities or fragments inside activities. Reusable components are expressed as composable functions. A single activity is used for the application that calls composable functions according to the state that must be displayed.
Other GUI APIs similar to Jetpack Compose
More and more API to design graphical user interfaces rely nowadays on a declarative style to specify the displayed elements. Here are some representents of such kinds of frameworks :
- Flutter. It uses the Dart programming language and allow to create native GUIs for several platforms like Android, iOS, web browsers (compiling to JavaScript and HTML5 elements) and also desktop systems (Windows, Linux and MacOS). This framework is developped by Google like Jetpack Compose.
- React. It is a JavaScript framework allowing to design web interfaces with a declarative style. The components are not directly described using HTML code but using JSX, a kind of templating language . This framework is supported by Facebook. A version of React named React Native is proposed to allow the apps to use native components of several mobile platforms (Android, iOS) with the help of a JavaScript interpreter.
- SwiftUI. This declarative GUI framework is proposed by Apple to develop MacOS and iOS apps with the Swift programming language. It is not compatible with Android.
JetBrains (editor of the IntelliJ IDE) proposes an extension to Jetpack Compose (in beta status) that supports developement on desktop and web platforms. It permits to develop an Android and desktop app with the same code base (minus some adaptations to configure the window of the desktop app). Concerning the web version, it does not use exactly the same components but rather components that are more close of HTML tags with the ability to use CSS to configure the rendering.
How to choose the most relevant framework for our needs?
- If for us the most important criterion is the multiplatform support, Flutter may be a good choice since it is a relatively mature framework that allow to use a single codebase to develop mobile, desktop and web apps (few special adaptations are needed to port the app for several platforms). The programming language Dart allow compiling to native code or JavaScript for web apps.
- If our primary goal is to develop web apps, React is more relevant; the application could be later ported to mobile or desktop platforms but keeping the JavaScript code; thus a JavaScript interpreter is used that involves performance penalty. However rather than using React native, we keep the possibility to use a framework packaging web applications to mobile packages like Cordova.
- If the main target for our application is Android devices, Jetpack compose is more adapted. The Jetpack Compose for desktop proposed by Jetbrains offer new outlooks for the app; note however that the support for desktop is experimental. Concerning the web version, it will require some adaptations on the codebase.
- Finally SwiftUI is a proprietary solution of Apple (the projet is not Open Source). For now we can only use it to develop MacOS and iOS applications without the ability to port them to Android, the web or non-Apple desktop platforms (Windows and Linux).
The requirements to use Jetpack Compose
- Using Jetpack Compose require the last version of Android Studio (at least Arctic Fox)
- Using a simple text editor is theoretically possible but not recommended since it would involve managing manually imports, dependencies in build.gradle with no autocompletion and syntax/semantic checking support
An Hello World activity with Compose
If we want to display an Hello World! message, on can emit a Text element with a customized name (taken as parameter of the function) inside a Box layout that fills all the space, the Text is centered inside the box with a red border and a padding of 5dp:
@Composable fun HelloWorld(name: String) { Box(modifier=Modifier.fillMaxSize()) { Text( "Hello world $name!", modifier = Modifier.align(Alignment.Center).border(width = 5.dp, color = Color.Red) .padding(10.dp), fontSize = 40.sp, textAlign = TextAlign.Center ) } }
We can include inside compose activities preview functions (without parameters): Android Studio allows to view directly the graphical rendering of the function (modifications are live updated if they are related to litterals, otherwise a refresh requiring a compilation is needed):
@Preview(showBackground = true) @Composable fun HelloWorldPreview() { CoursandTheme { HelloWorld("foobar") } }
Note that the theme (here CoursandTheme) has been automatically created by the assitant of Android Studio dedicated to the creation of compose activities. It is implemented with a set of files in the ui.theme subpackage. It is possible to change the colors, shapes or typographies declared in this theme by editing the files.
One can use the previouly written composable function as the main component that will be displayed on our activity:
class ComposeHelloWorldActivity : ComponentActivity() { @ExperimentalFoundationApi override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { CoursandTheme { // A surface container using the 'background' color from the theme Surface(color = MaterialTheme.colors.background) { HelloWorld("foobar") } } } } }
The steps and principles of graphical rendering with Compose
Rendering the GUI is made in three distinct steps with the Compose API:
-
Composition
- In this step the composer system call the main function and all the functions called directly or indirectly.
- These functions emit graphical nodes that are collected by the composer.
- This way the composer obtain a description of the graphical interface to render taking into account parameters and state.
- Since the functions used for the composition are Kotlin functions, one can use all the abilities of the programming language to make computations, use conditional rendering, use loops and so on.
-
Layout
- Once the composer knows the elements to render it must position them on the screen.
- In the layout phase, each graphical element is measured to obtain its size; this size depends on its children, so the measuring process is recursively lead
- Each parent element place these children in its space using the measured sizes.
-
Drawing
- When the positions of each element is known they can be drawn on the screen.
- Each element is reponsible to draw itself in its boundaries (like with the legacy View system using the method onDraw(Canvas c))
- The GUI is rarely static: it can change with the time.
- The composer detects changes by observing the state: the state pushes information about changes.
- When they are changes, only the functions having modified parameters or relying on a state that has changed are recomposed: they are called again to collect the modified UI elements
-
Functions with unmodified parameters and state are not called again since they would emit the same UI elements (they are deterministic)
- One must not use sources of undeterminism in the composable functions (like calling SystemClock.elapsedRealtime(), using Random...)
- Source of undeterminism must be wrapped into states
-
⚠ The composable functions MUST NEVER modify directly the state in the composition phase (no side effect) otherwise the composer will detect continuous changes of state that will lead to incessant recompositions.
- States are modified by events (click on an element, key pressed, external network event...) or properly wrapped side effects.
- States uses specific data structures that allows the composer to watch for modification; using classical data structure (like ArrayList, HashMap) for states will prevent the composer to detect any change on the structure (and recomposition will not be made).
Instances of composables
- It is possible to use the same composable several times
- Each composable instance is identified by the compiler using its call site (place where the composable is called inside its parent composable)
- The composer recomposes only the composables that have previouly read a state value that now have changed
var MYTEXT_INSTANCE_COUNTER = 0 @Composable fun MyText(text: String) { val myTextId by remember { mutableStateOf(MY_TEXT_INSTANCE_COUNTER++) } Log.v("MyText", "Composition of MyText #${myTextId}") Text(text) } @Composable fun ParentComposable() { var counter by remember { mutableStateOf(0) } TwoTexts("first", "$counter") Button(onClick={ counter++ }) { Text("Increment") } } @Composable fun TwoTexts(first: String, second: String) { Log.v("MyText", "Composition of TwoTexts") MyText(first) // first call site MyText(third) // second call site, only this text will be recomposed since only third changes }
- Sometimes several instances of composables can be created from the same call site.
- To create several instances from the same site we can used a loop, repeat, foreach...:
@Composable fun SeveralTexts(texts: List<String>) { for (t in texts) { MyText(t) } }
The composer will identify each instance of composable using its call rank:
- It is relevant if one can only add new elements in the list
- But if for example we remove the first element from the list (or add an element at the start of the list), all instances will be recomposed
- To avoid this, we can use a key to identify each instance using something else than the index (one can also use a compound key made with a tuple of values):
@Composable fun SeveralTexts(texts: List<String>) { for (t in texts) { key(t) { MyText(t) } } }
How to access to the Context ?
Typically composable functions are written directly in files at top level (not into an Activity class). To access to the Context (useful for numerous Android APIs), one must use LocalContext.current.
Example: a component displaying the level of the battery
@Composable fun BatteryDisplayer() { val context = LocalContext.current var batteryLevel by remember { mutableStateOf(Float.NaN) } val broadcastReceiver = remember { object: BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { batteryLevel = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1).toFloat() / intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) } } } DisposableEffect(true) { val intent = context.registerReceiver(broadcastReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) onDispose { Log.d("BatteryDisplayer", "Unregistered displayer") context.unregisterReceiver(broadcastReceiver) } } Text("Battery level: $batteryLevel") }
The accompanist library
Google proposes Accompanist an additional library for Jetpack Compose for features that have not been already embedded in the official library.
Here are some of the supported features:
- Pager layouts (similar to the ViewPager from the legacy API)
- Permissions
- Flow layouts that allow to place children on several rows or columns (looks like the flex of CSS)
- Placeholder that allow to display a block while content is loading (waiting for an HTTP response for example)
- ...
Compatibility with the legacy API
- Jetpack Compose promotes the use of a single activity calling composable functions rather than several activities and fragments.
-
It is possible to migrate an application using the legacy API into an app relying on Jetpack Compose in several steps:
- One can keep in some places traditional activities and fragments
- It is possible to use Jetpack compose only for some parts of the screen
- One can also embed inside a Jetpack Compose components other components using the legacy View API (like a custom View implementing onDraw for example)
- One can use a legacy theme with Compose components as explained here
- For further information one can follow this tutorial
How to embed a Compose component inside a XML layout?
Declare a ComposeView inside the XML layout (with the relevant dimensions):
<androidx.compose.ui.platform.ComposeView android:id="@+id/compose_view" android:layout_width="match_parent" android:layout_height="match_parent"/>
And don't forget to call in onCreate (for an activity) or `onCreateView (for a fragment) the method setContent`` to supply the composable:
fun onCreate(b: Bundle?) { ... val cv = findViewById<ComposeView>(R.id.compose_view) cv.setContent { MaterialTheme { MyBeautifulComposeFunction() } } // Dispose the Composition when the view's LifecycleOwner // is destroyed cv.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) }
How to use a legacy View inside a Compose component?
A component inheriting from the class View can be embedded inside a Compose component. One must supply:
- A factory to programmatically create the component (one can also use a layout inflater to create from a XML layout)
-
An update function to call the required setters on the View component to adapt it for the data
- The update function is automatically called again in case of recomposition implying data changes
Example to use a TextView (not really useful, it will be easier to use the composable Text):
@Composable private fun LegacyTextView(text: String) { AndroidView( factory = { context -> TextView(context).apply { movementMethod = LinkMovementMethod.getInstance() } }, update = { // update will be called again if text changes it.text = text } ) }