· 7 min read

How to Fix Keyboard Issues Introduced in the Latest Jetpack Compose (1.4.0)

Non-working keyboard in the latest version of Jetpack Compose - another case of keyboard issues in Compose.

Non-working keyboard in the latest version of Jetpack Compose - another case of keyboard issues in Compose.

Recently (March 22, 2023) Google released a new Jetpack Compose stable version 1.4.0. Our team got excited and wanted to try it out, so we found some time to migrate a few days later. At first, everything was working fine. It seemed like we could just bump the version and prepare the PR for review.

A few tests later we found out that the keyboard is not working at all in Dialog Fragments with ComposeView, so basically 20% of our app was broken (all DialogFragments and BottomSheetDialogFragments).

Here is an example of the problem:

We have a full-screen DialogFragment with ComposeView. Inside it, there’s only a top bar and a text field. The keyboard should appear after clicking on the text field, but unfortunately, it’s not working.

W standardowym DialogFragment z ComposeView klawiatura powinna pojawić się po kliknięciu w dowolne pole tekstowe, ale nie działo się nic.

Let’s have a look a the code.

Initial code

We start with a simple DialogFragment. In my case, I used a custom style to make it a full-screen dialog. In onCreateView new ComposeView is returned with Composable content. We will discuss both dialogFragmentComposeView(...) and DialogContent() in the next steps:

class DialogExample : DialogFragment() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // Custom style
        setStyle(STYLE_NORMAL, R.style.FullScreenDialog_Light)
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return dialogFragmentComposeView {
            DialogContent()
        }
    }

    override fun onStart() {
        super.onStart()
        
        // Full screen dialog
        dialog?.window?.setLayout(
            ViewGroup.LayoutParams.MATCH_PARENT, 
            ViewGroup.LayoutParams.MATCH_PARENT,
        )
    }
}

Next, I declared a dialog content Composable with Scaffold and one text field to make things easy to see and understand:

@Composable
fun DialogContent() {
    var text by remember { mutableStateOf("") }

    Scaffold(
        topBar = { TopBar(onBackClicked = {}) },
    ) { paddingValues ->

        Column(
            modifier = Modifier
                .padding(paddingValues)
                .padding(horizontal = 16.dp)
        ) {
            OutlinedTextField(
                value = text,
                onValueChange = { text = it }
            )
        }
    }
}

Last, but not least is the dialogFragmentComposeView(...) extension that is used to quickly create a ComposeView to be used inside a DialogFragment.

Our setup only needs a few things:

  • The layout params should be set to MATCH_PARENT, just to be sure that our dialog will be full-screen,
  • The view composition strategy should be set to DisposeOnDetachedFromWindow. We’ve tested others multiple times and always got crashes in Jetpack Compose < 1.4.0 when working with dialogs,
  • Make sure we use our theme:
fun DialogFragment.dialogFragmentComposeView(
    content: @Composable () -> Unit
): ComposeView {
    return ComposeView(requireContext()).apply {
        layoutParams = ViewGroup.LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.MATCH_PARENT
        )

        // Another interesting flaw. If you use other strategies in DialogFragment you may get crashes 🫠
        setViewCompositionStrategy(strategy = ViewCompositionStrategy.DisposeOnDetachedFromWindow)
        
        setContent {
            CustomComposeTheme(
                darkTheme = false
            ) {
                content()
            }
        }
    }
}

This is the final result of this DialogFragment and how it behaves with the latest stable Compose version (1.4.0). You can see that the TextField gains focus when you click on it, but the keyboard is not showing at all. Well, in that situation we could at least type using an external keyboard 😅

Investigation 🕵️

So what is the cause of this issue? Well, we spent several hours looking for the cause of this it. We’ve tried on multiple devices, changed handling inset logic, tested on different text inputs, etc. but nothing seemed to make any difference in behavior. I started looking at the Google Issue Tracker and found a very helpful regression bug report: https://issuetracker.google.com/issues/262140644

As it turns out, the culprit of all the confusion was a change in the InputMethodManager (this line)

// TODO(b/221889664) Replace with composition local when available.
private fun View.findWindow(): Window? =
    (parent as? DialogWindowProvider)?.window
        ?: context.findWindow()

The sad thing about this huge regression is that it was reported on Dec 12, 2022, when someone was testing Jetpack Compose version 1.4.0-alpha03, more than 3 months before the STABLE release! Is Google testing new releases at all? They had plenty of time to fix it or at least keep the same behavior as in the previous versions. It seems to me like we’re always getting new Experimental APIs, but not so many bug fixes and regression fixes in the last Compose stable releases. Not cool.

Fix it yourself👨‍🔧

I don’t know how long it will take to fix this bug, but I was curious how I can fix, or should I say hack this error myself. Fortunately, if you read the entire content of the conversation under the reported issue you can find a lot of useful information!

Because the View.findWindow() function checks if the parent is a DialogWindowProvider we could create our own version of ComposeView that implements not only AbstractComposeView but also the DialogWindowProvider and override the function that returns the window to get it from the DialogFragment. You can see below in the DialogFragmentComposeView I am passing the dialog in a lambda to ensure what is used any time the window is called. The rest of the code is just copy-pasted from the original ComposeView class.

internal class DialogFragmentComposeView(
    context: Context,
    private val dialogProvider: () -> Dialog,
) : AbstractComposeView(context, null, 0),
    DialogWindowProvider {

    // Tu jest fix (a raczej hack)
    override val window get() = dialogProvider().window!!

    private val content = mutableStateOf<(@Composable () -> Unit)?>(null)

    override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
        private set

    @Composable
    override fun Content() {
        content.value?.invoke()
    }

    override fun getAccessibilityClassName(): CharSequence = ComposeView::class.java.name

    /**
     * Set the Jetpack Compose UI content for this view.
     * Initial composition will occur when the view becomes attached to a window or when
     * [createComposition] is called, whichever comes first.
     */
    fun setContent(content: @Composable () -> Unit) {
        shouldCreateCompositionOnAttachedToWindow = true
        this.content.value = content
        if (isAttachedToWindow) createComposition()
    }
}

internal var DialogFragmentComposeView.consumeWindowInsets: Boolean
    get() = getTag(androidx.compose.ui.R.id.consume_window_insets_tag) as? Boolean ?: true
    set(value) {
        setTag(androidx.compose.ui.R.id.consume_window_insets_tag, value)
    }

Bear in mind you cannot just pass the dialog from DialogFragment directly to your custom ComposeView as it would lead to crashes. Simply put, the dialog is created and then cleared inside onDestroyView.

On top of that Android has a messed up lifecycle, so it is possible that the call for a window would happen after the dialog is destroyed.

I’ve tested this case several times and got crashes because of this. That is why to make it “not crashing”, you can hack it by providing dialog ?: Dialog(requireContext()) so in that case “some” dialog always exists.

dialogProvider = { dialog ?: Dialog(requireContext()) },

Remember, this is a hack, not a perfect solution, unfortunately.

fun DialogFragment.dialogFragmentComposeView(
    consumeWindowInsets: Boolean,
    content: @Composable () -> Unit,
): View {
    return DialogFragmentComposeView(
        context = requireContext(),
        dialogProvider = { dialog ?: Dialog(requireContext()) },
    ).apply {
        consumeWindowInsets = consumeWindowInsets
        layoutParams = ViewGroup.LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.MATCH_PARENT
        )

        setViewCompositionStrategy(strategy = ViewCompositionStrategy.DisposeOnDetachedFromWindow)
        setContent {
            CustomComposeTheme(
                darkTheme = false
            ) {
                content()
            }
        }
    }
}

And so, after all this struggle we can finally use the keyboard in our app again. Problem solved, so we can update the app to use Compose 1.4.0, right? Well… 😅

Problem z klawiaturą w Compose 1.4.0

Did we update to Compose 1.4.0?

No. The bug presented in this article is not the only disadvantage of this “stable” version. Have a look at the release notes of the compose-ui 1.4.0 👇

https://developer.android.com/jetpack/androidx/releases/compose-ui

One thing in the important changes since 1.3.0 section is “The focus system is rewritten using the new experimental Modifier.Node APIs”. Based on my observations at times, the focus behavior can appear to be buggy and unpredictable, which can be frustrating for users.

Ultimately, the decision to upgrade to anything should be made based on a comprehensive assessment of the risks and benefits, with the goal of providing the best possible experience for your app’s users. We’re skipping this version and waiting patiently for the next stable one, but this time stable.

Final Thoughts

Hope I helped you overcome the issue that I also encountered myself. As previously mentioned, it is important to note that Compose 1.4.0 may have some bugs, and thus it may not be suitable for use in a production environment. However, if the issue discussed in this article is the only concern holding you back from updating, then maybe it’s worth giving it a try.

Thank you for reading!

Back to Blog

Related articles

View all posts »