package com.unistyles import android.graphics.Rect import android.os.Build import android.view.View import android.view.Window import android.view.WindowManager import androidx.annotation.Keep import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsAnimationCompat import androidx.core.view.WindowInsetsCompat import com.facebook.proguard.annotations.DoNotStrip import com.facebook.react.bridge.ReactApplicationContext import com.margelo.nitro.unistyles.Insets import com.margelo.nitro.unistyles.UnistylesNativeMiniRuntime typealias CxxImeListener = (miniRuntime: UnistylesNativeMiniRuntime) -> Unit @Keep @DoNotStrip class NativePlatformInsets( private val reactContext: ReactApplicationContext, private val getMiniRuntime: () -> UnistylesNativeMiniRuntime, private val onConfigChange: () -> Unit ) { private var _didGetInsets = false private var _shouldListenToImeEvents = false private val _imeListeners: MutableList = mutableListOf() private var _insets: Insets = Insets(0.0, 0.0, 0.0, 0.0, 0.0) init { // for SDK below 35, it's possible to get it synchronously this.getInitialInsets(true) } fun onDestroy() { this.removeImeListeners() } fun getInsets(): Insets { val density = reactContext.resources.displayMetrics.density return Insets( this._insets.top / density, this._insets.bottom / density, this._insets.left / density, this._insets.right / density, this._insets.ime / density ) } fun getInitialInsets(skipUpdate: Boolean = false) { if (_didGetInsets) { return } reactContext.currentActivity?.let { activity -> activity.findViewById(android.R.id.content)?.let { mainView -> val insets = ViewCompat.getRootWindowInsets(mainView) insets?.let { windowInsets -> setInsets(windowInsets, activity.window, null, skipUpdate) _didGetInsets = true } } } } fun setInsets(insetsCompat: WindowInsetsCompat, window: Window, animatedBottomInsets: Double?, skipUpdate: Boolean = false) { val previousInsets = this._insets // below Android 11, we need to use window flags to detect status bar visibility val isStatusBarVisible = when(Build.VERSION.SDK_INT) { in 30..Int.MAX_VALUE -> { insetsCompat.isVisible(WindowInsetsCompat.Type.statusBars()) } else -> { @Suppress("DEPRECATION") window.attributes.flags and WindowManager.LayoutParams.FLAG_FULLSCREEN != WindowManager.LayoutParams.FLAG_FULLSCREEN } } // React Native is forcing insets to make status bar translucent // so we need to calculate top inset manually, as WindowInsetCompat will always return 0 val statusBarTopInset = when(isStatusBarVisible) { true -> { val visibleRect = Rect() window.decorView.getWindowVisibleDisplayFrame(visibleRect) visibleRect.top } false -> 0 } val insets = insetsCompat.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) val imeInsetValue = insetsCompat.getInsets(WindowInsetsCompat.Type.ime()).bottom // When a keyboard management library (e.g. react-native-keyboard-controller) is active, // systemBars().bottom can get polluted with the IME height on certain interactions // (like double-tapping to select text). Detect this by checking if systemBars bottom // is >= IME bottom (normally systemBars bottom is just the nav bar, much smaller than IME). // Fall back to getInsetsIgnoringVisibility which returns stable nav bar values. val bottomInset = if (imeInsetValue > 0 && insets.bottom >= imeInsetValue) { insetsCompat.getInsetsIgnoringVisibility( WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.displayCutout() ).bottom } else { insets.bottom } // Android 10 and below - set bottom insets to 0 while keyboard is visible and use default bottom insets otherwise // Android 11 and above - animate bottom insets while keyboard is appearing and disappearing val imeInsets = when { animatedBottomInsets != null && Build.VERSION.SDK_INT >= 30 -> animatedBottomInsets Build.VERSION.SDK_INT < 30 -> { val nextBottomInset = imeInsetValue - bottomInset maxOf(nextBottomInset, 0).toDouble() } else -> 0.0 } this._insets = Insets( statusBarTopInset.toDouble(), bottomInset.toDouble(), insets.left.toDouble(), insets.right.toDouble(), imeInsets ) val didInsetsChange = !previousInsets.isEqualTo(this._insets) val didImeChange = previousInsets.ime != this._insets.ime val shouldEmitImeEvent = didImeChange && ( Build.VERSION.SDK_INT < 30 || animatedBottomInsets != null && Build.VERSION.SDK_INT >= 30 ) if (skipUpdate) { return } if (didInsetsChange) { this@NativePlatformInsets.onConfigChange() } if (shouldEmitImeEvent) { this@NativePlatformInsets.emitImeEvent( this.getMiniRuntime().copy(insets = this.getInsets()) ) } } fun startInsetsListener() { _shouldListenToImeEvents = true reactContext.currentActivity?.let { activity -> activity.findViewById(android.R.id.content)?.let { mainView -> ViewCompat.setOnApplyWindowInsetsListener(mainView) { _, insets -> setInsets(insets, activity.window, null) insets } // IME insets are available from Android 11 if (Build.VERSION.SDK_INT >= 30) { ViewCompat.setWindowInsetsAnimationCallback( mainView, object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) { override fun onProgress( insets: WindowInsetsCompat, runningAnimations: List ): WindowInsetsCompat { if (!_shouldListenToImeEvents) { return insets } runningAnimations.firstOrNull { animation -> animation.typeMask and WindowInsetsCompat.Type.ime() != 0 }?.let { val bottomInset = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom.toDouble() - this@NativePlatformInsets._insets.bottom val nextBottomInset = if (bottomInset < 0) { 0.0 } else { bottomInset } this@NativePlatformInsets.setInsets(insets, activity.window, nextBottomInset) } return insets } } ) } } } } fun emitImeEvent(miniRuntime: UnistylesNativeMiniRuntime) { _imeListeners.forEach { listener -> listener(miniRuntime) } } fun stopInsetsListener() { reactContext.currentActivity?.let { activity -> activity.findViewById(android.R.id.content)?.let { view -> ViewCompat.setOnApplyWindowInsetsListener(view, null) } } _shouldListenToImeEvents = false } fun addImeListener(listener: CxxImeListener) { this._imeListeners.add(listener) } fun removeImeListeners() { this._imeListeners.clear() } }