package org.tm.archive.components
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.util.DisplayMetrics
import android.view.Surface
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.Guideline
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat
import org.signal.core.util.logging.Log
import org.tm.archive.R
import org.tm.archive.keyvalue.SignalStore
import org.tm.archive.util.ServiceUtil
import org.tm.archive.util.ViewUtil
/**
* A specialized [ConstraintLayout] that sets guidelines based on the window insets provided
* by the system. For improved backwards-compatibility we must use [ViewCompat] for configuring
* the inset change callbacks.
*
* In portrait mode these are how the guidelines will be configured:
*
* - [R.id.status_bar_guideline] is set to the bottom of the status bar
* - [R.id.navigation_bar_guideline] is set to the top of the navigation bar
* - [R.id.parent_start_guideline] is set to the start of the parent
* - [R.id.parent_end_guideline] is set to the end of the parent
* - [R.id.keyboard_guideline] will be set to the top of the keyboard and will
* change as the keyboard is shown or hidden
*
* In landscape, the spirit of the guidelines are maintained but their names may not
* correlated exactly to the inset they are providing.
*
* These guidelines will only be updated if present in your layout, you can use
* `` to quickly include them.
*/
@Suppress("LeakingThis")
open class InsetAwareConstraintLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
companion object {
private val TAG = Log.tag(InsetAwareConstraintLayout::class.java)
private val keyboardType = WindowInsetsCompat.Type.ime()
private val windowTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
}
protected val statusBarGuideline: Guideline? by lazy { findViewById(R.id.status_bar_guideline) }
private val navigationBarGuideline: Guideline? by lazy { findViewById(R.id.navigation_bar_guideline) }
private val parentStartGuideline: Guideline? by lazy { findViewById(R.id.parent_start_guideline) }
private val parentEndGuideline: Guideline? by lazy { findViewById(R.id.parent_end_guideline) }
private val keyboardGuideline: Guideline? by lazy { findViewById(R.id.keyboard_guideline) }
private val windowInsetsListeners: MutableSet = mutableSetOf()
private val keyboardStateListeners: MutableSet = mutableSetOf()
private val keyboardAnimator = KeyboardInsetAnimator()
private val displayMetrics = DisplayMetrics()
private var overridingKeyboard: Boolean = false
private var previousKeyboardHeight: Int = 0
val isKeyboardShowing: Boolean
get() = previousKeyboardHeight > 0
init {
ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsetsCompat ->
applyInsets(windowInsets = windowInsetsCompat.getInsets(windowTypes), keyboardInsets = windowInsetsCompat.getInsets(keyboardType))
windowInsetsCompat
}
if (attrs != null) {
context.withStyledAttributes(attrs, R.styleable.InsetAwareConstraintLayout) {
if (getBoolean(R.styleable.InsetAwareConstraintLayout_animateKeyboardChanges, false)) {
ViewCompat.setWindowInsetsAnimationCallback(this@InsetAwareConstraintLayout, keyboardAnimator)
}
}
}
}
fun addKeyboardStateListener(listener: KeyboardStateListener) {
keyboardStateListeners += listener
}
fun removeKeyboardStateListener(listener: KeyboardStateListener) {
keyboardStateListeners.remove(listener)
}
fun addWindowInsetsListener(listener: WindowInsetsListener) {
windowInsetsListeners += listener
}
fun removeWindowInsetsListener(listener: WindowInsetsListener) {
windowInsetsListeners.remove(listener)
}
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
val isLtr = ViewUtil.isLtr(this)
val statusBar = windowInsets.top
val navigationBar = windowInsets.bottom
val parentStart = if (isLtr) windowInsets.left else windowInsets.right
val parentEnd = if (isLtr) windowInsets.right else windowInsets.left
statusBarGuideline?.setGuidelineBegin(statusBar)
navigationBarGuideline?.setGuidelineEnd(navigationBar)
parentStartGuideline?.setGuidelineBegin(parentStart)
parentEndGuideline?.setGuidelineEnd(parentEnd)
windowInsetsListeners.forEach { it.onApplyWindowInsets(statusBar, navigationBar, parentStart, parentEnd) }
if (keyboardInsets.bottom > 0) {
setKeyboardHeight(keyboardInsets.bottom)
if (!keyboardAnimator.animating) {
keyboardGuideline?.setGuidelineEnd(keyboardInsets.bottom)
} else {
keyboardAnimator.endingGuidelineEnd = keyboardInsets.bottom
}
} else if (!overridingKeyboard) {
if (!keyboardAnimator.animating) {
keyboardGuideline?.setGuidelineEnd(windowInsets.bottom)
} else {
keyboardAnimator.endingGuidelineEnd = windowInsets.bottom
}
}
if (previousKeyboardHeight != keyboardInsets.bottom) {
keyboardStateListeners.forEach {
if (previousKeyboardHeight <= 0 && keyboardInsets.bottom > 0) {
it.onKeyboardShown()
} else if (previousKeyboardHeight > 0 && keyboardInsets.bottom <= 0) {
it.onKeyboardHidden()
}
}
}
previousKeyboardHeight = keyboardInsets.bottom
}
protected fun overrideKeyboardGuidelineWithPreviousHeight() {
overridingKeyboard = true
keyboardGuideline?.setGuidelineEnd(getKeyboardHeight())
}
protected fun clearKeyboardGuidelineOverride() {
overridingKeyboard = false
}
protected fun resetKeyboardGuideline() {
clearKeyboardGuidelineOverride()
keyboardGuideline?.setGuidelineEnd(navigationBarGuideline.guidelineEnd)
}
private fun getKeyboardHeight(): Int {
val height = if (isLandscape()) {
SignalStore.misc().keyboardLandscapeHeight
} else {
SignalStore.misc().keyboardPortraitHeight
}
val minHeight = resources.getDimensionPixelSize(R.dimen.default_custom_keyboard_size)
return if (height > minHeight) {
height
} else {
Log.w(TAG, "Saved keyboard height ($height) is too low, using default size ($minHeight)")
minHeight
}
}
private fun setKeyboardHeight(height: Int) {
if (isLandscape()) {
SignalStore.misc().keyboardLandscapeHeight = height
} else {
SignalStore.misc().keyboardPortraitHeight = height
}
}
private fun isLandscape(): Boolean {
val rotation = getDeviceRotation()
return rotation == Surface.ROTATION_90
}
@Suppress("DEPRECATION")
private fun getDeviceRotation(): Int {
if (isInEditMode) {
return Surface.ROTATION_0
}
if (Build.VERSION.SDK_INT >= 30) {
context.display?.getRealMetrics(displayMetrics)
} else {
ServiceUtil.getWindowManager(context).defaultDisplay.getRealMetrics(displayMetrics)
}
return if (displayMetrics.widthPixels > displayMetrics.heightPixels) Surface.ROTATION_90 else Surface.ROTATION_0
}
private val Guideline?.guidelineEnd: Int
get() = if (this == null) 0 else (layoutParams as LayoutParams).guideEnd
interface KeyboardStateListener {
fun onKeyboardShown()
fun onKeyboardHidden()
}
interface WindowInsetsListener {
fun onApplyWindowInsets(statusBar: Int, navigationBar: Int, parentStart: Int, parentEnd: Int)
}
/**
* Adjusts the [keyboardGuideline] to move with the IME keyboard opening or closing.
*/
private inner class KeyboardInsetAnimator : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
var animating = false
private set
private var startingGuidelineEnd: Int = 0
var endingGuidelineEnd: Int = 0
set(value) {
field = value
growing = value > startingGuidelineEnd
}
private var growing: Boolean = false
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
if (overridingKeyboard) {
return
}
animating = true
startingGuidelineEnd = keyboardGuideline.guidelineEnd
}
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList): WindowInsetsCompat {
if (overridingKeyboard) {
return insets
}
val imeAnimation = runningAnimations.find { it.typeMask and WindowInsetsCompat.Type.ime() != 0 }
if (imeAnimation == null) {
return insets
}
val estimatedKeyboardHeight: Int = if (growing) {
endingGuidelineEnd * imeAnimation.interpolatedFraction
} else {
startingGuidelineEnd * (1f - imeAnimation.interpolatedFraction)
}.toInt()
if (growing) {
keyboardGuideline?.setGuidelineEnd(estimatedKeyboardHeight.coerceAtLeast(startingGuidelineEnd))
} else {
keyboardGuideline?.setGuidelineEnd(estimatedKeyboardHeight.coerceAtLeast(endingGuidelineEnd))
}
return insets
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
if (overridingKeyboard) {
return
}
keyboardGuideline?.setGuidelineEnd(endingGuidelineEnd)
animating = false
}
}
}