Bridging Compose and View: Seamless Interop Communication with CompositionLocal

Julien Salvi
3 min readMar 5, 2025

Migrating to Compose exposed the team to several interoperability challenges as we integrated new Composables into our existing codebase. One key hurdle was figuring out how to trigger events from Compose and dispatch them to the main View holder, such as a Fragment or Activity.

After some exploration, we found an efficient solution using CompositionLocal. Here’s how we made it work! 🚀

What is a CompositionLocal?

Think of CompositionLocal as a localized dependency injection system built directly into Compose’s composition model. You create a CompositionLocal instance, which acts as a key for a specific type of data. Then, using the CompositionLocalProvider, you define a scope within your UI tree and associate a value with that CompositionLocal key. Any composable within that scope can then access the provided value using the CompositionLocal.current property, without needing to know where the value originated. This not only simplifies code but also makes it more maintainable and reusable.

Furthermore, CompositionLocal values can be dynamic, triggering recomposition when they change, and they are thread-safe, making them a powerful tool for managing shared state within your Compose UI.

Interop Communication with CompositionLocal

In the following code, LocalFeedbackInterop is a staticCompositionLocalOf that will hold an implementation of the FeedbackMessageInterop interface. This interface defines the methods for showing feedback alerts, and the staticCompositionLocalOf ensures that the implementation is unlikely to change as it cannot be re-provided. This allows any composable within the scope to access the FeedbackMessageInterop implementation and trigger feedback messages without explicit dependencies.

import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.staticCompositionLocalOf

val LocalFeedbackInterop: ProvidableCompositionLocal<FeedbackMessageInterop?> = staticCompositionLocalOf { null }

interface FeedbackMessageInterop {
fun showError(message: String)
fun showSuccess(message: String)
fun showInfo(message: String)
}

From our Compose code, we can retrieve the instance of the CompositionLocal to access the FeedbackMessageInterop implementation and trigger our feedback message View-based component that rely on an error state collected from a ViewModel.

@Composable
fun Content() {
val feedbackHost = LocalFeedbackInterop.current

LaunchedEffect(feedbackHost) {
viewModel.error.collect {
feedbackHost?.showError(
message = "My error message",
)
}
}
}

On the Fragment or Activity side, we can easily declare the local FeedbackMessageInterop implementation that will be provided to our CompositionLocalProvider to bridge the communication with our Compose code. And that’s it!

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
CompositionLocalProvider(
LocalFeedbackInterop provides localFeedback,
) {
TractorTheme {
CurrentScreen()
}
}
}
}
}

private val localFeedback: FeedbackMessageInterop = object : FeedbackMessageInterop {
override fun showError(message: String) {
lifecycleScope.launch {
parentFragment?.view?.findViewById<View>(R.id.rootBottomSheetModalView)?.let { root ->
showErrorFeedbackMessage(root.parent as ViewGroup) {
this.message = message
offsetPositionTop = R.dimen.feedbackOffsetPositionTopSmall
}
}
}
}
}

The other way around is also possible, you can trigger some Compose code by interacting with the interface implementation from the View-based code! One possible solution would be to expose a StateFlow or SharedFlow from the implementation provided to the CompositionLocalProvider so it can be collected on the Compose side.

// On the view side
private val localBottomSheet = object : BottomSheetInterop {

private val rightAction: MutableSharedFlow<Unit> = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = DROP_OLDEST)

override val onRightActionClicked: SharedFlow<Unit> = rightAction

override fun someFun() {
setOnRightActionClickListener { rightAction.tryEmit(Unit) }
}
}

// On the Compose side
val bottomSheet = LocalBottomSheetInterop.currentOrThrow
LaunchedEffect(bottomSheet) {
bottomSheet.onRightActionClicked.collect {
viewModel.doSomething()
}
}

By exploring the power of CompositionLocal in Compose, we were able to seamlessly enhance the interoperability with our existing View-based components. This approach not only simplified the communication between Compose and the traditional Views but also accelerated the development of multiple screens across the application.

Do not hesitate to ping me on LinkedIn if you have any question 🤓

Sign up to discover human stories that deepen your understanding of the world.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Julien Salvi
Julien Salvi

Written by Julien Salvi

Google Developer Expert for Android — Lead Android Engineer @ Aircall (Paris) — Startup way of life, beer lover and world traveler.

Responses (1)

Write a response

When the composable is just a pass through to nofity view model on bottom sheet, why not let the view directly interact with view model? A context as why this is needed would help in evaluating this pattern in other apps.
Any reason to use…