Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<WindowInsetsAnimationCompat>,
): 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
}
}