Skip to content
Open
113 changes: 99 additions & 14 deletions android/src/main/java/com/swmansion/rnscreens/CustomToolbar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.view.Choreographer
import android.view.View
import android.view.WindowInsets
import android.view.WindowManager
import androidx.appcompat.widget.Toolbar
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.facebook.react.modules.core.ReactChoreographer
import com.facebook.react.uimanager.ThemedReactContext
import com.swmansion.rnscreens.utils.InsetsCompat
import com.swmansion.rnscreens.utils.resolveInsetsOrZero
import com.swmansion.rnscreens.utils.InsetUtils
import kotlin.math.max

/**
Expand All @@ -24,7 +27,9 @@ import kotlin.math.max
open class CustomToolbar(
context: Context,
val config: ScreenStackHeaderConfig,
) : Toolbar(context) {
) : Toolbar(context),
OnApplyWindowInsetsListener,
View.OnApplyWindowInsetsListener {
// Due to edge-to-edge enforcement starting from Android SDK 35, isTopInsetEnabled prop has been
// removed. Previously, shouldAvoidDisplayCutout, shouldApplyTopInset would directly return the
// value of isTopInsetEnabled. Now, the values of shouldAvoidDisplayCutout, shouldApplyTopInse
Expand All @@ -34,7 +39,12 @@ open class CustomToolbar(

private val shouldApplyTopInset = true

private var lastInsets = InsetsCompat.NONE
private var lastInsets = Insets.NONE

// As CustomToolbar is responsible for handling insets in Stack, we store what insets should
// be passed to Screen in this property. It's used by ScreensCoordinatorLayout in overriden
// dispatchApplyWindowInsets.
var screenInsets = WindowInsetsCompat.CONSUMED

private var isForceShadowStateUpdateOnLayoutRequested = false

Expand All @@ -53,6 +63,20 @@ open class CustomToolbar(
}
}

init {
// In order to consume display cutout insets on API 27-29, we can't use root window insets
// aware WindowInsetsCompat because they always return display cutout insets from root view.
// That's why we manually convert platform WindowInsets to WindowInsetsCompat (without
// supplying information about the view that is used by WindowInsetsCompat to find the root
// view) in View's OnApplyWindowInsetsListener, use ViewCompat listener and return insets
// converted back to platform WindowInsets.
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
setOnApplyWindowInsetsListener(this)
} else {
ViewCompat.setOnApplyWindowInsetsListener(this, this)
}
}

override fun requestLayout() {
super.requestLayout()
val softInputMode =
Expand Down Expand Up @@ -82,8 +106,24 @@ open class CustomToolbar(
}
}

override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets? {
val unhandledInsets = super.onApplyWindowInsets(insets)
// Wrapper used on API < 30 to correctly handle consuming display cutout insets.
// More details in the comment above setting the listener.
override fun onApplyWindowInsets(
v: View,
insets: WindowInsets,
): WindowInsets {
val rootViewUnawareInsets = WindowInsetsCompat.toWindowInsetsCompat(insets)
return this
.onApplyWindowInsets(this, rootViewUnawareInsets)
.toWindowInsets() ?: InsetUtils.CONSUMED_PLATFORM_WINDOW_INSETS
}

override fun onApplyWindowInsets(
v: View,
insets: WindowInsetsCompat,
): WindowInsetsCompat {
val unhandledInsets =
WindowInsetsCompat.toWindowInsetsCompat(super.onApplyWindowInsets(insets.toWindowInsets()))

// There are few UI modes we could be running in
//
Expand All @@ -92,16 +132,14 @@ open class CustomToolbar(
// 3. edge-to-edge with translucent navigation buttons bar.
//
// Additionally we need to gracefully handle possible display cutouts.
val cutoutInsets =
resolveInsetsOrZero(WindowInsetsCompat.Type.displayCutout(), unhandledInsets)
val systemBarInsets =
resolveInsetsOrZero(WindowInsetsCompat.Type.systemBars(), unhandledInsets)
val cutoutInsets = unhandledInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
val systemBarInsets = unhandledInsets.getInsets(WindowInsetsCompat.Type.systemBars())

// This seems to work fine in all tested configurations, because cutout & system bars overlap
// only in portrait mode & top inset is controlled separately, therefore we don't count
// any insets twice.
val horizontalInsets =
InsetsCompat.of(
Insets.of(
cutoutInsets.left + systemBarInsets.left,
0,
cutoutInsets.right + systemBarInsets.right,
Expand All @@ -112,14 +150,14 @@ open class CustomToolbar(
// If there are no cutout displays, we want to apply the additional padding to
// respect the status bar.
val verticalInsets =
InsetsCompat.of(
Insets.of(
0,
max(cutoutInsets.top, if (shouldApplyTopInset) systemBarInsets.top else 0),
0,
max(cutoutInsets.bottom, 0),
)

val newInsets = InsetsCompat.add(horizontalInsets, verticalInsets)
val newInsets = Insets.add(horizontalInsets, verticalInsets)

if (lastInsets != newInsets) {
lastInsets = newInsets
Expand All @@ -129,9 +167,56 @@ open class CustomToolbar(
lastInsets.right,
lastInsets.bottom,
)

// Insets for Screen component.
screenInsets =
WindowInsetsCompat
.Builder(unhandledInsets)
.setInsets(
WindowInsetsCompat.Type.displayCutout(),
Insets.of(cutoutInsets.left, 0, cutoutInsets.right, cutoutInsets.bottom),
).setInsets(
WindowInsetsCompat.Type.systemBars(),
Insets.of(
systemBarInsets.left,
if (shouldApplyTopInset) 0 else systemBarInsets.top,
systemBarInsets.right,
systemBarInsets.bottom,
),
).build()

// On Android versions prior to R, setInsets(WindowInsetsCompat.Type.displayCutout(), ...)
// does not work. We need to use previous API.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
screenInsets = screenInsets.consumeDisplayCutout()
}
}

var consumedInsets =
WindowInsetsCompat
.Builder(unhandledInsets)
.setInsets(
WindowInsetsCompat.Type.displayCutout(),
Insets.NONE,
).setInsets(
WindowInsetsCompat.Type.systemBars(),
Insets.of(
0,
if (shouldApplyTopInset) 0 else systemBarInsets.top,
0,
systemBarInsets.bottom,
),
).build()

// On Android versions prior to R, setInsets(WindowInsetsCompat.Type.displayCutout(), ...)
// does not work. We need to use previous API.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
consumedInsets = consumedInsets.consumeDisplayCutout()
}

return unhandledInsets
// Technically, we don't need those returned insets anywhere but for consistency's sake
// I decided to return the correct value.
return consumedInsets
}

override fun onLayout(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ class ScreenStackHeaderConfig(
private val configSubviews = ArrayList<ScreenStackHeaderSubview>(3)
val toolbar: CustomToolbar
var isHeaderHidden = false // named this way to avoid conflict with platform's isHidden
set(value) {
if (field != value) {
requestApplyInsets()
}
field = value
}
var isHeaderTranslucent =
false // named this way to avoid conflict with platform's isTranslucent
private var title: String? = null
Expand Down Expand Up @@ -228,6 +234,12 @@ class ScreenStackHeaderConfig(
screenFragment?.setToolbar(toolbar)
}

// Ensure that menuView is created. With current support action bar logic, this sometimes
// does not happen when nested stacks are used. MenuView is necessary for correct header
// height calculation. More details:
// https://github.com/software-mansion/react-native-screens-labs/issues/564
toolbar.menu

activity.setSupportActionBar(toolbar)
// non-null toolbar is set in the line above and it is used here
val actionBar = requireNotNull(activity.supportActionBar)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.os.Build
import android.util.Log
import android.view.View
import android.view.ViewTreeObserver
import android.view.WindowInsets
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
Expand All @@ -21,6 +22,7 @@ import com.facebook.react.views.view.ReactViewGroup
import com.swmansion.rnscreens.BuildConfig
import com.swmansion.rnscreens.safearea.paper.SafeAreaViewEdges
import com.swmansion.rnscreens.safearea.paper.SafeAreaViewLocalData
import com.swmansion.rnscreens.utils.InsetUtils
import java.lang.ref.WeakReference
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
Expand All @@ -32,7 +34,8 @@ class SafeAreaView(
private val reactContext: ThemedReactContext,
) : ReactViewGroup(reactContext),
OnApplyWindowInsetsListener,
ViewTreeObserver.OnPreDrawListener {
ViewTreeObserver.OnPreDrawListener,
View.OnApplyWindowInsetsListener {
private var provider = WeakReference<SafeAreaProvider>(null)
private var currentInterfaceInsets: EdgeInsets = EdgeInsets.ZERO
private var currentSystemInsets: EdgeInsets = EdgeInsets.ZERO
Expand All @@ -48,7 +51,17 @@ class SafeAreaView(
}

init {
ViewCompat.setOnApplyWindowInsetsListener(this, this)
// In order to consume display cutout insets on API 27-29, we can't use root window insets
// aware WindowInsetsCompat because they always return display cutout insets from root view.
// That's why we manually convert platform WindowInsets to WindowInsetsCompat (without
// supplying information about the view that is used by WindowInsetsCompat to find the root
// view) in View's OnApplyWindowInsetsListener, use ViewCompat listener and return insets
// converted back to platform WindowInsets.
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
setOnApplyWindowInsetsListener(this)
} else {
ViewCompat.setOnApplyWindowInsetsListener(this, this)
}
}

override fun onAttachedToWindow() {
Expand Down Expand Up @@ -100,6 +113,18 @@ class SafeAreaView(
}
}

// Wrapper used on API < 30 to correctly handle consuming display cutout insets.
// More details in the comment above setting the listener.
override fun onApplyWindowInsets(
v: View,
insets: WindowInsets,
): WindowInsets {
val rootViewUnawareInsets = WindowInsetsCompat.toWindowInsetsCompat(insets)
return this
.onApplyWindowInsets(this, rootViewUnawareInsets)
.toWindowInsets() ?: InsetUtils.CONSUMED_PLATFORM_WINDOW_INSETS
}

override fun onApplyWindowInsets(
view: View,
insets: WindowInsetsCompat,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import android.view.animation.Animation
import android.view.animation.AnimationSet
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.facebook.react.uimanager.ReactPointerEventsView
import com.google.android.material.appbar.AppBarLayout
import com.swmansion.rnscreens.CustomToolbar
import com.swmansion.rnscreens.PointerEventsBoxNoneImpl
import com.swmansion.rnscreens.Screen
import com.swmansion.rnscreens.ScreenStackFragment
import com.swmansion.rnscreens.bottomsheet.usesFormSheetPresentation
import com.swmansion.rnscreens.stack.anim.ScreensAnimation
Expand All @@ -23,7 +26,37 @@ internal class ScreensCoordinatorLayout(
PointerEventsBoxNoneImpl(),
)

override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets = super.onApplyWindowInsets(insets)
// We override dispatchApplyWindowInsets to ensure correct order and flow of inset consumption.
override fun dispatchApplyWindowInsets(insets: WindowInsets?): WindowInsets? {
val appBarLayout = getChildAt(1)
val screen = getChildAt(0)

// We use custom logic only in regular stack screen (and not e.g. in form sheets).
val shouldUseCustomDispatch =
childCount == 2 && screen is Screen && appBarLayout is AppBarLayout

if (!shouldUseCustomDispatch) {
return super.dispatchApplyWindowInsets(insets)
}

// First, we dispatch insets to AppBarLayout and possibly CustomToolbar.
appBarLayout.dispatchApplyWindowInsets(insets)

// As top insets are handled in CustomToolbar, we save insets prepared for Screen in
// CustomToolbar.screenInsets. There isn't really any other way to pass that information.
// If we can, we use them (if header is hidden, there is no CustomToolbar instance).
val screenInsets =
if (appBarLayout.getChildAt(0) is CustomToolbar) {
(appBarLayout.getChildAt(0) as CustomToolbar).screenInsets.toWindowInsets()
} else {
insets
}

// Dispatch correct insets to Screen.
screen.dispatchApplyWindowInsets(screenInsets)

return insets
}

private val animationListener: Animation.AnimationListener =
object : Animation.AnimationListener {
Expand Down
10 changes: 10 additions & 0 deletions android/src/main/java/com/swmansion/rnscreens/utils/InsetUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.swmansion.rnscreens.utils

import android.view.WindowInsets
import androidx.core.view.WindowInsetsCompat

internal object InsetUtils {
// .toWindowInsets() will return platform insets on API >= 20 so we're good.
internal val CONSUMED_PLATFORM_WINDOW_INSETS =
WindowInsetsCompat.CONSUMED.toWindowInsets() as WindowInsets
}
34 changes: 0 additions & 34 deletions android/src/main/java/com/swmansion/rnscreens/utils/InsetsKt.kt

This file was deleted.

Loading
Loading