Compose 1.7 brings a few improvements, most notably performance benefits. Updating seems like a no-brainer - but unfortunately it’s not always that easy!
Say we’re working on an internal SDK integrated into various apps. Updating to Compose 1.7 poses an interesting challenge: Who moves first?
In the beginning I believed it doesn’t matter. After opening our libs.versions.toml
and bumping Compose to 1.7… we see build errors?
It turns out that even though Compose 1.7 technically is only a minor version bump, in practice there are breaking changes. In particular, this internal SDK uses many APIs that are marked as experimental in Compose 1.6. Google grants itself the liberty to change these APIs as they evolve. Unfortunately they do so without a deprecation cycle.
This would be fine if experimental APIs were easy to avoid in Compose. This is not the case.
This includes innocuous-looking APIs like ModalBottomSheet
. It used to have a parameter windowInsets: WindowInsets
that has changed to contentWindowInsets: @Composable () -> WindowInsets
- a breaking change!
This is what the API looked like before:
@Composable
@ExperimentalMaterial3Api
fun ModalBottomSheet(
// ...
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
) {
// ...
}
And this is what it looks like in the Compose 1.7 artifacts:
@Composable
@ExperimentalMaterial3Api
fun ModalBottomSheet(
// ...
contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets },
) {
// ...
}
This is a problem. Let’s assume both the internal SDK as well as the host app use the ModalBottomSheet
component.
Who should update to Compose 1.7 first?
If the internal SDK updates to Compose 1.7 first, this causes a transitive dependency for the host app to Compose 1.7 as well.
As the app is still using the Compose 1.6-version of ModalBottomSheet
, the app’s call to this composable is now broken, forcing
the app’s development team to immediately work on restoring Compose 1.7 compatibility. As an SDK developer, this is not a situation I want to put an app developer in.
Unfortunately we also can’t just wait for the host app to upgrade to Compose 1.7. As there can only be a single Compose version on the classpath, the moment the app depends on Compose 1.7, our SDK would cause runtime crashes as it is trying to call Compose 1.6-APIs that are no longer available or changed in Compose 1.7.
Using a naive approach, all internal and external modules using experimental Compose APIs of an app have to move to Compose 1.7 at the same time. In many organizations, this is not desirable.
Not updating is not an option - so how can we escape from this trap?
As an Android developer, the solution is actually right before our eyes. For calling Android framework APIs, the Android SDK itself offers a way to call different APIs depending on the system version the app is running on. Code like the following is a frequent sight:
val color = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
resources.getColor(R.color.colorPrimary, null)
} else {
resources.getColor(R.color.colorPrimary)
}
What if we could do the same for Compose APIs?
The solution is a compatibility layer that calls Compose 1.6 APIs when Compose 1.6 is on the classpath and Compose 1.7 APIs when Compose 1.7 is on the classpath. Let’s get started:
@Composable
fun ModalBottomSheetCompat(
contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets },
// ...
) {
if (runningOnCompose16) {
androidx.compose.material3.ModalBottomSheet(
windowInsets = contentWindowInsets(),
// ...
)
} else {
// We're on Compose 1.7 or higher
androidx.compose.material3.ModalBottomSheet(
contentWindowInsets = contentWindowInsets,
// ...
)
}
}
Maybe you’ve already noticed: This snippet can’t actually be compiled. To not force the app to Compose 1.7 through a transitive dependency,
the internal SDK has to declare a dependency to Compose 1.6. However, Compose 1.6 does not have an overload of
ModalBottomSheet
with a contentWindowInsets
parameter, so the function call cannot be resolved by the compiler.
Remember, the goal is to build a compatibility layer offering an API callable from Compose 1.6 and Compose 1.7 environments. This requires creating two Gradle modules: compose-compat-on16
and compose-compat-on17
.
The compose-compat-on16
module defines the API of our compatibility layer. To keep the structure simple,
it also has an implementation
dependency to Compose 1.6 and calls its APIs directly within a Compose 1.6 environment.
When compose-compat-on16
detects that it’s running within a Compose 1.7 environment, it delegates to the compose-compat-on17
module.
To call Compose 1.7 APIs (without resorting to Reflection), compose-compat-on17
must depend on Compose 1.7.
This has to be a compileOnly
dependency, ensuring that no transitive dependency to Compose 1.7 exists for the host app.
Let’s take a look at the code.
compose-compat-on16
build.gradle.kts
plugins {
// ...
}
android {
// ...
}
dependencies {
implementation(project(":compose-compat-on17"))
implementation("androidx.compose.material3:material3:1.2.1") // Compose 1.6
}
ModalBottomSheetCompat.kt
package composecompaton16
@Composable
@ExperimentalMaterial3Api
fun ModalBottomSheetCompat(
contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets },
// ...
) {
if (runningOnCompose16) {
androidx.compose.material3.ModalBottomSheet(
// Obtain WindowInsets by just calling contentWindowInsets for its return value
windowInsets = contentWindowInsets(),
// ...
)
} else {
// We're on Compose 1.7 or higher -> Delegate to the compose-compat-on17 module
composecompaton17.ModalBottomSheetCompat(
contentWindowInsets = contentWindowInsets,
// ...
)
}
}
compose-compat-on17
build.gradle.kts
plugins {
// ...
}
android {
// ...
}
dependencies {
compileOnly("androidx.compose.material3:material3:1.3.0") // Compose 1.7
}
ModalBottomSheetCompat.kt
package composecompaton17
@Composable
@ExperimentalMaterial3Api
fun ModalBottomSheetCompat(
contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets },
// ...
) {
// In this module the call resolves successfully!
androidx.compose.material3.ModalBottomSheet(
contentWindowInsets = contentWindowInsets(),
// ...
)
}
If you copy and paste the code snippets above, you’ll quickly notice that we haven’t defined the value runningOnCompose16
yet.
Unfortunately the myriad of Compose libraries don’t offer an easy and consistent way to detect their version at runtime. Let’s build our own then!
compose-compat-on17
VersionCheck.kt
package composecompaton17
val runningOnCompose16: Boolean =
try {
androidx.compose.material3.ripple(color = Color.Red)
false
} catch (e: LinkageError) {
true
}
The ripple
function has been added in the Compose 1.7 version of the Material3 library. We try to call it.
If the function does not actually exist at runtime, an error is thrown.
We can catch it and derive that we’re running in a Compose 1.6 environment.
Goal achieved! Now we just need to import this value inside compose-compat-on16
’s ModalBottomSheetCompat.kt
.
We’ve now successfully implemented a ModalBottomSheetCompat
component that can be called safely from our SDK code.
It can be run in a Compose 1.6 environment as well as a Compose 1.7 environment, all without forcing the host app
to update to Compose 1.7 itself.
We just add a dependency from our SDK module to our compatibility module:
sdk/build.gradle.kts
dependencies {
implementation(project(":compose-compat-on16"))
// Other Compose 1.6 dependencies here
}
And now we can use out compatibility function:
sdk/src/main/kotlin/MyAwesomeSdkFeature.kt
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun MyAwesomeSdkFeature() {
ModalBottomSheetCompat(
contentWindowInsets = { /* ... */ }
)
}
For an easy overview, I’ve published the snippets above as a GitHub Gist. You may have to apply small fixes such as adding missing imports.
Marking experimental APIs as such makes a lot of sense - and so does letting their API evolve more radically. Developers using them should be aware that they may need to react fast to changes. However, using and generating feedback for experimental APIs in real applications and libraries is only possible when developers are given the chance to react to changes.
As this blog post shows, breaking ABI changes in a minor version bump make this challenging. That doesn’t mean experimental APIs need to be maintained forever - but maybe just drop them one release later.
Not only the Material3 library has seen breaking changes. In fact, even the androidx.compose.foundation
module has breaking changes.
If your code is affected by breaking changes in more than one Compose module, you have to define multiple runningOnCompose16
values.
For example, you can detect the version of the foundation module by placing this check in compose-compat-on16
:
@OptIn(ExperimentalFoundationApi::class)
val isFoundationCompose16: Boolean =
try {
AnchoredDraggableState(
initialValue = Unit,
positionalThreshold = { it },
velocityThreshold = { 0f },
animationSpec = SnapSpec(),
)
true
} catch (e: LinkageError) {
false
}
As our internal SDK still depends on Compose 1.6, it is possible to accidentally call APIs that were broken with the update to Compose 1.7. To prevent this, we can learn from Tor Norbye’s excellent talk on how to Write your own Kotlin lint checks.
For example, we can ban direct calls to ModalBottomSheet
by creating a Gradle module called custom-lint-checks
:
build.gradle.kts
plugins {
id("kotlin")
}
dependencies {
compileOnly("com.android.tools.lint:lint-api:31.7.1")
}
tasks.withType<Jar> {
manifest {
attributes("Lint-Registry-v2" to "com.example.Registry")
}
}
Registry.kt
package com.example
class Registry : IssueRegistry() {
override val issues: List<Issue> = listOf(Compose16UsageDetector.ISSUE)
override val api: Int = CURRENT_API
override val minApi: Int = CURRENT_API
override val vendor = Vendor(
vendorName = "...",
contact = "...",
)
}
Compose16UsageDetector.kt
class Compose16UsageDetector :
Detector(),
SourceCodeScanner {
companion object Issues {
val ISSUE = Issue.create(
id = "Compose16Usage",
briefDescription = "Disallow use of Compose APIs that have been changed in Compose 1.7",
explanation = "...",
category = Category.CORRECTNESS,
severity = Severity.FATAL,
implementation = Implementation(
Compose16UsageDetector::class.java,
Scope.JAVA_FILE_SCOPE,
),
)
}
override fun getApplicableMethodNames() = listOf("ModalBottomSheet")
override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
val cls = method.containingClass?.qualifiedName ?: return
val fn = node.methodName
if (cls.startsWith("androidx.compose.material3.ModalBottomSheet") && fn == "ModalBottomSheet") {
// startsWith as in Compose 1.6 the actual containing class file is ModalBottomSheet_androidKt (KMP convention)
val message = "API changed in Compose 1.7. This call will crash in apps using Compose 1.7!"
context.report(
issue = ISSUE,
scope = node,
location = context.getLocation(node),
message = message,
)
}
}
}
In our SDK module, we can now add this lint check module to let Android Studio and Gradle’s lint
task pick up the rule:
dependencies {
lintChecks(project(":custom-lint-checks"))
// ...
}