Jetpack Compose, the new UI framework for Android development, works well in the standard environment of Activities and Fragments. However, if you attempt to show Compose-based UI from a Service, you’ll encounter problems. I faced this challenge in my current freelance project, so I’ll describe the solution here to spare you the time and headache.
Android Service to Show an Overlay
Let’s say your app declares SYSTEM_ALERT_WINDOW
permission and the user grants it to the app through settings. This means that you can show overlays over other applications, even when your app is in the background.
To implement the overlay feature, you create this Service:
class ComposeOverlayService: Service() { lateinit var windowManager: WindowManager private var overlayView: View? = null override fun onCreate() { super.onCreate() windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager } override fun onBind(intent: Intent?): IBinder? { throw RuntimeException("bound mode not supported") } override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { if (intent.hasExtra(INTENT_EXTRA_COMMAND_SHOW_OVERLAY)) { showOverlay() } if (intent.hasExtra(INTENT_EXTRA_COMMAND_HIDE_OVERLAY)) { hideOverlay() } return START_NOT_STICKY } override fun onDestroy() { super.onDestroy() hideOverlay() } private fun showOverlay() { if (overlayView != null) { return } overlayView = ComposeView(this).apply { setContent { Box( modifier = Modifier .fillMaxSize() ) { Image( modifier = Modifier .align(Alignment.TopCenter) .width(200.dp) .offset(y = 100.dp), painter = painterResource(id = R.drawable.ic_tyc_logo), contentDescription = null ) } } } windowManager.addView(overlayView, getLayoutParams()) } private fun hideOverlay() { if (overlayView == null) { return } windowManager.removeView(overlayView) overlayView = null } private fun getLayoutParams(): WindowManager.LayoutParams { return WindowManager.LayoutParams( WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, PixelFormat.TRANSLUCENT ) } companion object { private const val INTENT_EXTRA_COMMAND_SHOW_OVERLAY = "INTENT_EXTRA_COMMAND_SHOW_OVERLAY" private const val INTENT_EXTRA_COMMAND_HIDE_OVERLAY = "INTENT_EXTRA_COMMAND_HIDE_OVERLAY" internal fun showOverlay(context: Context) { val intent = Intent(context, ComposeOverlayService::class.java) intent.putExtra(INTENT_EXTRA_COMMAND_SHOW_OVERLAY, true) context.startService(intent) } internal fun hideOverlay(context: Context) { val intent = Intent(context, ComposeOverlayService::class.java) intent.putExtra(INTENT_EXTRA_COMMAND_HIDE_OVERLAY, true) context.startService(intent) } } }
This Service is supposed to show and hide an image overlay on the screen when invoked in this manner:
ComposeOverlayService.showOverlay(context) // show overlay ComposeOverlayService.hideOverlay(context) // hide overlay
If overlayView
would be comprised of just standard Android Views, without Compose, this approach would work right away. However, since ComposeView
is involved, the situation gets more complicated.
Cryptic Jetpack Compose Error
When you attempt to use the above Service to show an overlay, you’ll get this error:
FATAL EXCEPTION: main Process: com.techyourchance.android.debug, PID: 15344 java.lang.IllegalStateException: ViewTreeLifecycleOwner not found from androidx.compose.ui.platform.ComposeView{d69a80f V.E...... ......I. 0,0-0,0} at androidx.compose.ui.platform.WindowRecomposer_androidKt.createLifecycleAwareWindowRecomposer(WindowRecomposer.android.kt:352)
The framework complains that ComposeView
doesn’t have ViewTreeLifecycleOwner
. Well, great, but what should we do about that?!
Since this error doesn’t affect ComposeView
‘s hosted in Activities and Fragments, we can deduce that, somehow, these components provide this critical dependency. So, we need to figure out how to “create” it and then “inject” into our ComposeView
inside the Service.
Turning the Service Into LifecycleOwner and SavedStateRegistryOwner
To resolve the aforementioned error, you’ll need to turn your Service into LifecycleOwner
and pass it into the ComposeView
. After you do that, you’ll see another error. To solve that next problem, you’ll also need to turn the Service into SavedStateRegistryOwner
and pass that into the ComposeView
as well.
This is the full implementation:
class ComposeOverlayService: Service(), LifecycleOwner, SavedStateRegistryOwner { lateinit var windowManager: WindowManager private val _lifecycleRegistry = LifecycleRegistry(this) private val _savedStateRegistryController: SavedStateRegistryController = SavedStateRegistryController.create(this) override val savedStateRegistry: SavedStateRegistry = _savedStateRegistryController.savedStateRegistry override val lifecycle: Lifecycle = _lifecycleRegistry private var overlayView: View? = null override fun onCreate() { super.onCreate() windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager _savedStateRegistryController.performAttach() _savedStateRegistryController.performRestore(null) _lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) } override fun onBind(intent: Intent?): IBinder? { throw RuntimeException("bound mode not supported") } override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { if (intent.hasExtra(INTENT_EXTRA_COMMAND_SHOW_OVERLAY)) { showOverlay() } if (intent.hasExtra(INTENT_EXTRA_COMMAND_HIDE_OVERLAY)) { hideOverlay() } return START_NOT_STICKY } override fun onDestroy() { super.onDestroy() hideOverlay() _lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) } private fun showOverlay() { MyLogger.i("showOverlay()") if (overlayView != null) { MyLogger.i("overlay already shown - aborting") return } _lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) _lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) overlayView = ComposeView(this).apply { setViewTreeLifecycleOwner(this@ComposeOverlayService) setViewTreeSavedStateRegistryOwner(this@ComposeOverlayService) setContent { Box( modifier = Modifier .fillMaxSize() ) { Image( modifier = Modifier .align(Alignment.TopCenter) .width(200.dp) .offset(y = 100.dp), painter = painterResource(id = R.drawable.ic_tyc_logo), contentDescription = null ) } } } windowManager.addView(overlayView, getLayoutParams()) } private fun hideOverlay() { MyLogger.i("hideOverlay()") if (overlayView == null) { MyLogger.i("overlay not shown - aborting") return } windowManager.removeView(overlayView) overlayView = null _lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) _lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) } private fun getLayoutParams(): WindowManager.LayoutParams { return WindowManager.LayoutParams( WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, PixelFormat.TRANSLUCENT ) } companion object { private const val INTENT_EXTRA_COMMAND_SHOW_OVERLAY = "INTENT_EXTRA_COMMAND_SHOW_OVERLAY" private const val INTENT_EXTRA_COMMAND_HIDE_OVERLAY = "INTENT_EXTRA_COMMAND_HIDE_OVERLAY" internal fun showOverlay(context: Context) { val intent = Intent(context, ComposeOverlayService::class.java) intent.putExtra(INTENT_EXTRA_COMMAND_SHOW_OVERLAY, true) context.startService(intent) } internal fun hideOverlay(context: Context) { val intent = Intent(context, ComposeOverlayService::class.java) intent.putExtra(INTENT_EXTRA_COMMAND_HIDE_OVERLAY, true) context.startService(intent) } } }
Please note how I pass two references to the Service into ComposeView
after construction. Furthermore, note that I manually manage the states of _lifecycleRegistry
and _savedStateRegistryController
. If you’ll mismanage the states of these objects, you might encounter weird bugs (e.g. animations not playing in your overlay).
Summary
That’s it, now you can use Jetpack Compose in your Services without growing gray hair. You can find a full implementation of this feature, including the integration with in-app button to show or hide the overlay, in my open-sourced TechYourChance application.
Thanks for reading and please leave your comments and questions below.