diff --git a/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/dialog/implementation/navbar/NavBarDialog.kt b/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/dialog/implementation/navbar/NavBarDialog.kt
index 0855e701d..f7f9dc992 100644
--- a/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/dialog/implementation/navbar/NavBarDialog.kt
+++ b/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/dialog/implementation/navbar/NavBarDialog.kt
@@ -21,6 +21,8 @@ import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.view.Window
+import android.view.WindowManager
import androidx.annotation.CallSuper
import androidx.annotation.StyleRes
@@ -47,6 +49,8 @@ abstract class NavBarDialog(@StyleRes theme: Int) : OverlayDialog(theme) {
lateinit var floatingActionButtons: IncludeFloatingActionButtonsBinding
lateinit var topBarBinding: IncludeDialogNavigationTopBarBinding
+ private var imeLiftController: NavBarDialogImeLiftController? = null
+
abstract fun inflateMenu(navBarView: NavigationBarView)
abstract fun onCreateContent(navItemId: Int): NavBarDialogContent
@@ -55,6 +59,17 @@ abstract class NavBarDialog(@StyleRes theme: Int) : OverlayDialog(theme) {
open fun onContentViewChanged(navItemId: Int) = Unit
+ /**
+ * Keep window size unchanged while the IME is shown.
+ * Portrait bottom bar sits on the dialog [CoordinatorLayout]; [SOFT_INPUT_ADJUST_RESIZE] parks the sheet mid-screen.
+ */
+ override fun applySoftInputMode(window: Window) {
+ window.setSoftInputMode(
+ WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING or
+ WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN,
+ )
+ }
+
override fun onCreateView(): ViewGroup {
baseViewBinding = DialogBaseNavBarBinding.inflate(LayoutInflater.from(context)).apply {
layoutTopBar.apply {
@@ -99,6 +114,15 @@ abstract class NavBarDialog(@StyleRes theme: Int) : OverlayDialog(theme) {
setupPortraitViews()
}
+ val coordinator = dialogCoordinatorLayout
+ val decor = dialog.window?.decorView
+ if (coordinator != null && decor != null) {
+ imeLiftController = NavBarDialogImeLiftController(
+ liftTarget = coordinator,
+ insetHost = decor,
+ ).also { it.start() }
+ }
+
updateContentView(
itemId = navBarView.selectedItemId,
forceUpdate = true,
@@ -116,6 +140,8 @@ abstract class NavBarDialog(@StyleRes theme: Int) : OverlayDialog(theme) {
}
override fun onDestroy() {
+ imeLiftController?.stop()
+ imeLiftController = null
contentMap.values.forEach { content ->
content.destroy()
}
diff --git a/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/dialog/implementation/navbar/NavBarDialogImeLiftController.kt b/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/dialog/implementation/navbar/NavBarDialogImeLiftController.kt
new file mode 100644
index 000000000..340eb84bb
--- /dev/null
+++ b/core/common/overlays/src/main/java/com/buzbuz/smartautoclicker/core/common/overlays/dialog/implementation/navbar/NavBarDialogImeLiftController.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2026 Kevin Buzeau
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.buzbuz.smartautoclicker.core.common.overlays.dialog.implementation.navbar
+
+import android.animation.ValueAnimator
+import android.view.View
+import android.view.animation.AccelerateDecelerateInterpolator
+
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsAnimationCompat
+import androidx.core.view.WindowInsetsCompat
+
+/**
+ * Lifts a dialog content root when the IME is visible.
+ *
+ * [NavBarDialog] keeps [android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING] so the portrait bottom bar
+ * is not parked by system resize; this controller mirrors IME height via translation instead.
+ */
+internal class NavBarDialogImeLiftController(
+ private val liftTarget: View,
+ private val insetHost: View,
+) {
+
+ private var lastImeLiftPx: Int = 0
+ private var imeAnimationRunning: Boolean = false
+ private var imeLiftAnimator: ValueAnimator? = null
+
+ private val insetsAnimationCallback =
+ object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
+ override fun onPrepare(animation: WindowInsetsAnimationCompat) {
+ if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) {
+ imeAnimationRunning = true
+ imeLiftAnimator?.cancel()
+ }
+ }
+
+ override fun onProgress(
+ insets: WindowInsetsCompat,
+ runningAnimations: MutableList,
+ ): WindowInsetsCompat {
+ applyImeLift(resolveImeLiftPx(insets))
+ return insets
+ }
+
+ override fun onEnd(animation: WindowInsetsAnimationCompat) {
+ if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) {
+ imeAnimationRunning = false
+ syncImeLiftAnimated()
+ }
+ }
+ }
+
+ fun start() {
+ ViewCompat.setWindowInsetsAnimationCallback(insetHost, insetsAnimationCallback)
+ ViewCompat.setOnApplyWindowInsetsListener(insetHost) { _, insets ->
+ if (!imeAnimationRunning) {
+ applyImeLift(resolveImeLiftPx(insets))
+ }
+ insets
+ }
+ ViewCompat.requestApplyInsets(insetHost)
+ }
+
+ fun stop() {
+ imeLiftAnimator?.cancel()
+ imeLiftAnimator = null
+ ViewCompat.setWindowInsetsAnimationCallback(insetHost, null)
+ ViewCompat.setOnApplyWindowInsetsListener(insetHost, null)
+ applyImeLift(0)
+ }
+
+ private fun syncImeLiftAnimated() {
+ val insets = ViewCompat.getRootWindowInsets(insetHost) ?: return
+ val target = resolveImeLiftPx(insets)
+ if (target == lastImeLiftPx) return
+
+ imeLiftAnimator?.cancel()
+ val start = lastImeLiftPx
+ imeLiftAnimator = ValueAnimator.ofInt(start, target).apply {
+ duration = IME_LIFT_ANIMATION_MS
+ interpolator = AccelerateDecelerateInterpolator()
+ addUpdateListener { animator ->
+ applyImeLift(animator.animatedValue as Int)
+ }
+ start()
+ }
+ }
+
+ private fun resolveImeLiftPx(insets: WindowInsetsCompat): Int {
+ if (!insets.isVisible(WindowInsetsCompat.Type.ime())) return 0
+ return insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
+ }
+
+ private fun applyImeLift(imeBottomPx: Int) {
+ if (imeBottomPx == lastImeLiftPx) return
+ lastImeLiftPx = imeBottomPx
+ liftTarget.translationY = -imeBottomPx.toFloat()
+ }
+
+ private companion object {
+ const val IME_LIFT_ANIMATION_MS = 180L
+ }
+}