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 + } +}