'How to clear TextField focus when closing the keyboard and prevent two back presses needed to exit app in Jetpack Compose?

I'm using BasicTextField.

When I start editing, back button becomes hide keyboard button(arrow down).

First press on back button hides keyboard, but the focus is still on the text field. Both onFocusChanged and BackPressHandler handlers not getting called.

Second press on back button clears focus: onFocusChanged is called and BackPressHandler is not.

BackHandler {
    println("BackPressHandler")
}
val valueState = remember { mutableStateOf(TextFieldValue(text = "")) }
BasicTextField(
    value = valueState.value,
    onValueChange = {
        valueState.value = it
    },
    modifier = Modifier
        .fillMaxWidth()
        .onFocusChanged {
            println("isFocused ${it.isFocused}")
        }
)

Third time BackHandler works fine. Just used it for testing, I shouldn't be needed it here, it expected focus to get lost after first back button tap



Solution 1:[1]

There's a compose issue with focused text field prevents back button from dismissing the app when keyboard is hidden. It's marked as fixed, but will be included in some future release, not in 1.0

But, as I understand, the fact that text field is not loosing focus after keyboard being dismissed, is intended behaviour on Android(because of possible connected keyboard? I didn't get the reason). And this is how it works in old android layout too

It seems strange to me, so I came with the following modifier which resigns focus when keyboard disappears:

fun Modifier.clearFocusOnKeyboardDismiss(): Modifier = composed {
    var isFocused by remember { mutableStateOf(false) }
    var keyboardAppearedSinceLastFocused by remember { mutableStateOf(false) }
    if (isFocused) {
        val imeIsVisible = LocalWindowInsets.current.ime.isVisible
        val focusManager = LocalFocusManager.current
        LaunchedEffect(imeIsVisible) {
            if (imeIsVisible) {
                keyboardAppearedSinceLastFocused = true
            } else if (keyboardAppearedSinceLastFocused) {
                focusManager.clearFocus()
            }
        }
    }
    onFocusEvent {
        if (isFocused != it.isFocused) {
            isFocused = it.isFocused
            if (isFocused) {
                keyboardAppearedSinceLastFocused = false
            }
        }
    }
}

p.s. You need to install accompanist insets dependency for LocalWindowInsets.current.ime

p.s.s. Since Compose 1.2.0-alpha03, Accompanist Insets was mostly moved into Compose Foundation, check out migration guide for more details. LocalWindowInsets.current.ime should be replaced with WindowInsets.ime.


Usage:

BasicTextField(
    value = valueState.value,
    onValueChange = {
        valueState.value = it
    },
    modifier = Modifier
        .clearFocusOnKeyboardDismiss()
)

Solution 2:[2]

Thanks to all the answers here. After taking reference from the answers here, here's a solution without using any library

1. Create an extension on View to determine if the keyboard is open or not

fun View.isKeyboardOpen(): Boolean {
    val rect = Rect()
    getWindowVisibleDisplayFrame(rect);
    val screenHeight = rootView.height
    val keypadHeight = screenHeight - rect.bottom;
    return keypadHeight > screenHeight * 0.15
}

2. Create an observable state for determining if a keyboard is open or not

This will listen to Global layout updates on LocalView in which on every event, we check for keyboard open/close status.

@Composable
fun rememberIsKeyboardOpen(): State<Boolean> {
    val view = LocalView.current

    return produceState(initialValue = view.isKeyboardOpen()) {
        val viewTreeObserver = view.viewTreeObserver
        val listener = OnGlobalLayoutListener { value = view.isKeyboardOpen() }
        viewTreeObserver.addOnGlobalLayoutListener(listener)

        awaitDispose { viewTreeObserver.removeOnGlobalLayoutListener(listener)  }
    }
}

3. Create modifier

This modifier will take care of clearing focus on keyboard visible/invisible events.

fun Modifier.clearFocusOnKeyboardDismiss(): Modifier = composed {

    var isFocused by remember { mutableStateOf(false) }
    var keyboardAppearedSinceLastFocused by remember { mutableStateOf(false) }

    if (isFocused) {
        val isKeyboardOpen by rememberIsKeyboardOpen()

        val focusManager = LocalFocusManager.current
        LaunchedEffect(isKeyboardOpen) {
            if (isKeyboardOpen) {
                keyboardAppearedSinceLastFocused = true
            } else if (keyboardAppearedSinceLastFocused) {
                focusManager.clearFocus()
            }
        }
    }
    onFocusEvent {
        if (isFocused != it.isFocused) {
            isFocused = it.isFocused
            if (isFocused) {
                keyboardAppearedSinceLastFocused = false
            }
        }
    }
}

4. Use it

Finally, use it with TextField composable

BasicTextField(Modifier.clearFocusOnKeyboardDismiss())

Solution 3:[3]

I found an arguably simpler solution using Android's tree observer.

You don't need to use another library or remove the insets from your layout.

It clears the focus in compose any time the keyboard is hidden.

Hopefully this will not be need when this is released.

class MainActivity : ComponentActivity() {

  var kbClosed: () -> Unit = {}
  var kbClosed: Boolean = false

  override fun onCreate(state: Bundle?) {
    super.onCreate(state)
    setContent {
      val focusManager = LocalFocusManager.current
      kbClosed = {
        focusManager.clearFocus()
      }
      MyComponent()
    }
    setupKeyboardDetection(findViewById<View>(android.R.id.content))
  }

  fun setupKeyboardDetection(contentView: View) {
    contentView.viewTreeObserver.addOnGlobalLayoutListener {
      val r = Rect()
      contentView.getWindowVisibleDisplayFrame(r)
      val screenHeight = contentView.rootView.height
      val keypadHeight = screenHeight - r.bottom
      if (keypadHeight > screenHeight * 0.15) { // 0.15 ratio is perhaps enough to determine keypad height.
        kbClosed = false
        // kb opened
      } else if(!kbClosed) {
        kbClosed = true
        kbClosed()
      }
    }
  }
}

Solution 4:[4]

@mmm111mmm, only your approach worked for me. I would like to suggest a clean way to encapsulate it.

  1. Create this Composable :
@Composable
fun AppKeyboardFocusManager() {
    val context = LocalContext.current
    val focusManager = LocalFocusManager.current
    DisposableEffect(key1 = context) {
        val keyboardManager = KeyBoardManager(context)
        keyboardManager.attachKeyboardDismissListener {
            focusManager.clearFocus()
        }
        onDispose {
            keyboardManager.release()
        }
    }
}
  1. Use this Composable at call site once on Application level
setContent {
        AppKeyboardFocusManager()
        YouAppMaterialTheme {
          ...
        }
    }
  1. Create Manager with @mmm111mmm approach
/***
 * Compose issue to be fixed in alpha 1.03
 * track from here : https://issuetracker.google.com/issues/192433071?pli=1
 * current work around
 */
class KeyBoardManager(context: Context) {

    private val activity = context as Activity
    private var keyboardDismissListener: KeyboardDismissListener? = null

    private abstract class KeyboardDismissListener(
        private val rootView: View,
        private val onKeyboardDismiss: () -> Unit
    ) : ViewTreeObserver.OnGlobalLayoutListener {
        private var isKeyboardClosed: Boolean = false
        override fun onGlobalLayout() {
            val r = Rect()
            rootView.getWindowVisibleDisplayFrame(r)
            val screenHeight = rootView.rootView.height
            val keypadHeight = screenHeight - r.bottom
            if (keypadHeight > screenHeight * 0.15) {
                // 0.15 ratio is right enough to determine keypad height.
                isKeyboardClosed = false
            } else if (!isKeyboardClosed) {
                isKeyboardClosed = true
                onKeyboardDismiss.invoke()
            }
        }
    }

    fun attachKeyboardDismissListener(onKeyboardDismiss: () -> Unit) {
        val rootView = activity.findViewById<View>(android.R.id.content)
        keyboardDismissListener = object : KeyboardDismissListener(rootView, onKeyboardDismiss) {}
        keyboardDismissListener?.let {
            rootView.viewTreeObserver.addOnGlobalLayoutListener(it)
        }
    }

    fun release() {
        val rootView = activity.findViewById<View>(android.R.id.content)
        keyboardDismissListener?.let {
            rootView.viewTreeObserver.removeOnGlobalLayoutListener(it)
        }
        keyboardDismissListener = null
    }
}

Solution 5:[5]

In a class that inherits from Application, add the following code to detect when the main activity gets created and include the code that detects when the keyboard is shown or hidden:

import android.app.Activity
import android.app.Application
import android.content.res.Resources
import android.graphics.Rect
import android.os.Bundle
import android.util.DisplayMetrics
import androidx.compose.runtime.mutableStateOf

class App : Application() {

    private val activityLifecycleTracker: AppLifecycleTracker = AppLifecycleTracker()

    override fun onCreate() {
        super.onCreate()
        registerActivityLifecycleCallbacks(activityLifecycleTracker)
    }

    companion object {
        val onKeyboardClosed = mutableStateOf(false)
    }

    /**
     * Callbacks for handling the lifecycle of activities.
     */
    class AppLifecycleTracker : ActivityLifecycleCallbacks {

        override fun onActivityCreated(activity: Activity, p1: Bundle?) {
            val displayMetrics: DisplayMetrics by lazy { Resources.getSystem().displayMetrics }
            val screenRectPx = displayMetrics.run { Rect(0, 0, widthPixels, heightPixels) }

            // Detect when the keyboard closes.
            activity.window.decorView.viewTreeObserver.addOnGlobalLayoutListener {
                val r = Rect()
                activity.window.decorView.getWindowVisibleDisplayFrame(r)
                val heightDiff: Int = screenRectPx.height() - (r.bottom - r.top)

                onKeyboardClosed.value = (heightDiff <= 100)
            }
        }

        override fun onActivityStarted(activity: Activity) {
        }

        override fun onActivityResumed(activity: Activity) {
        }

        override fun onActivityPaused(p0: Activity) {
        }

        override fun onActivityStopped(activity: Activity) {
        }

        override fun onActivitySaveInstanceState(p0: Activity, p1: Bundle) {
        }

        override fun onActivityDestroyed(p0: Activity) {
        }
    }
}

Add the following Modifier extension:

@Stable
fun Modifier.clearFocusOnKeyboardClose(focusManager: FocusManager): Modifier {
    if (App.onKeyboardClosed.value) {
        focusManager.clearFocus()
    }

    return this
}

In your composable, add a reference to the FocusManager and add the modifier to your TextField:

@Composable
fun MyComposable() {
   val focusManager = LocalFocusManager.current
   
    OutlinedTextField(
                     modifier = Modifier.clearFocusOnKeyboardClose(focusManager = focusManager)
    )
}

The TextField will clear it's focus whenever the keyboard is closed.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Shreyas Patil
Solution 3
Solution 4 Chetan Gupta
Solution 5 Johann