Skip to content

Commit 3e26ddf

Browse files
authored
feat(Android, Tabs): safe area component for Android (#3215)
## Description Adds implementation for handling safe area on Android for bottom tabs. Implementation has been adapted from [`react-native-safe-area-context`](https://github.com/AppAndFlow/react-native-safe-area-context). TODO: - [x] change native layout so that TabScreen renders under the tab bar (to allow using transparent tab bar) - [x] check implementation for older Android APIs (I checked APIs: 25, 28, 29, 30, 36 and 24 on Paper) - [x] add simple API (for all edges) to control which types of insets are used by `SafeAreaView` component (system and/or interface bars) - [ ] [Separate PR] add per-edge API to control which types of insets are used by `SafeAreaView` component (system and/or interface bars) (software-mansion/react-native-screens-labs#434) - [ ] [[Separate PR](#3240)] fix interaction with Stack v4 (`CustomToolbar`) (software-mansion/react-native-screens-labs#435) - [ ] [Separate PR] There were no problems with using margins for now so I left it as it was. We can change this later to padding or add a prop to switch between padding and margins on both Android and iOS. (software-mansion/react-native-screens-labs#436) https://github.com/user-attachments/assets/c59af116-a653-40e3-9345-fe8d3ba170ee ### Transparent tab bar | `top: false, bottom: false` | `top: true, bottom: false` | `top: true, bottom: true` | | --- | --- | --- | | <img width="1280" height="2856" alt="Screenshot_20250916_091258" src="https://github.com/user-attachments/assets/03afdf11-b4d8-4ee9-b32f-d893074208b3" /> | <img width="1280" height="2856" alt="Screenshot_20250916_091303" src="https://github.com/user-attachments/assets/7d70b007-63ae-4d62-884b-59d04f96158e" /> | <img width="1280" height="2856" alt="Screenshot_20250916_091311" src="https://github.com/user-attachments/assets/0ad3d293-0f1b-4a58-8139-dfd797bfc98b" /> | ### Changes to TabsHost's layout #### SafeAreaView After internal discussion about approach to `SafeAreaView`, we had following conclusions: - as edge-to-edge becomes desirable (and is the default for apps targeting Android SDK 35 or above), and to simplify layout handling, we want the `Screen`s of our navigation containers (e.g. `StackScreen`, `BottomTabsScreen`) to have **full dimensions of their parents, even if it means that they will be laid out behind navigation bars** (header in Stack, tab bar), - `SafeAreaView` will provide unified way to handle the safe area, - on Android, we want to control which insets we want to handle: - **system insets** (received from `onApplyWindowInsets`), e.g. `systemBars`, `displayCutout` - **interface insets** - custom insets from navigation bars, e.g. `bottomNavigationView` #### Before this PR Prior to this PR, we were using `LinearLayout` for `TabsHost`: ```kotlin class TabsHost( val reactContext: ThemedReactContext, ) : LinearLayout(reactContext), TabScreenDelegate { // ... private val bottomNavigationView: BottomNavigationView = BottomNavigationView(wrappedContext).apply { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) } private val contentView: FrameLayout = FrameLayout(reactContext).apply { layoutParams = LinearLayout .LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, ).apply { weight = 1f } id = ViewIdGenerator.generateViewId() } // ... } ``` <img width="946" height="817" alt="Screenshot 2025-09-24 at 17 23 20" src="https://github.com/user-attachments/assets/784a769a-d98a-4d4e-a9aa-0413a8d85fed" /> This approach had following problems: - `contentView` did not have dimensions of its parent, which is not what we wanted, - Yoga wasn't aware of `contentView`'s height - all content inside `TabScreen` was laid out as if the screen had full height of its parent -> this meant that `contentView`'s dimensions and actual content dimensions were not in sync, - it did not support using translucent tab bar - screen's content was cut off outside of `contentView`'s bounds. <img width="1280" height="2856" alt="3215_before_transparent" src="https://github.com/user-attachments/assets/9af6b066-7f2f-4927-ac28-00277e4077ce" /> #### Approach in this PR In this PR, we change `TabsHost`'s layout to `FrameLayout` which allows multiple views placed on top of each other - this is what we want to achieve (tab bar floating over content, attached to the bottom). To attach `bottomNavigationView` to *the bottom*, we use `Gravity.BOTTOM`. ```kotlin class TabsHost( val reactContext: ThemedReactContext, ) : FrameLayout(reactContext), TabScreenDelegate, SafeAreaProvider, View.OnLayoutChangeListener { // ... private val bottomNavigationView: BottomNavigationView = BottomNavigationView(wrappedContext).apply { layoutParams = LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM, ) } private val contentView: FrameLayout = FrameLayout(reactContext).apply { layoutParams = LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, ) id = ViewIdGenerator.generateViewId() } // ... } ``` <img width="447" height="826" alt="Screenshot 2025-09-24 at 17 23 29" src="https://github.com/user-attachments/assets/e3b67014-a193-4bb8-ba19-5b7c8f7816f8" /> Now, we can: 1. use opaque or translucent `bottomNavigationView`, 2. use `SafeAreaView` to control how much space can the actual content take (do we allow it to render under `bottomNavigationView`). <img width="1280" height="2856" alt="3215_after_transparent" src="https://github.com/user-attachments/assets/2f2944f9-1984-4c01-b335-53051c58cbd3" /> ### Support for older Android versions On Android versions prior to R, insets dispatch is broken (children of ViewGroup receive insets from previous child; they should all receive the same insets). That's why we need to override `dispatchApplyWindowInsets` implementation in `TabsHost`. In `ViewGroup`'s implementation of this method, `View`'s implementation is used (via `super`) - we can't access this directly. Unfortunately, `View`'s implementation sets some private flags which are used by default `onApplyWindowInsets` implementation. If we try to use `onApplyWindowInsets` on API 28 without setting the private flag, application goes into infinite loop (`fitSystemWindows` calls `dispatchApplyWindowInsets` -> we might want to investigate this in more detail). As we don't use insets in `TabsHost`, I decided not to call `onApplyWindowInsets` in `TabsHost` at all. I haven't found any problems with it yet. ## Changes - add implementation for `SafeAreaView` and related classes for both architectures - add `SafeAreaProvider` interface and implement it for `TabsHost` - change `TabsHost` to use `FrameLayout`: - make `TabsScreen` layout behind tab bar (take full available height to match JS screen) - override `dispatchApplyWindowInsets` in `TabsHost` in order to fix insets for older Android versions - add `insetType` prop to control what kind of insets should SafeAreaView respect (all, only system, only interface) ## Test code and steps to reproduce Run `TestBottomTabs` with uncommented SafeAreaView. You can change which edges are enabled and add `insetType` prop. ## Checklist - [x] Included code example that can be used to test this change - [ ] Updated TS types - [ ] Updated documentation: <!-- For adding new props to native-stack --> - [ ] https://github.com/software-mansion/react-native-screens/blob/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md - [ ] https://github.com/software-mansion/react-native-screens/blob/main/native-stack/README.md - [ ] https://github.com/software-mansion/react-native-screens/blob/main/src/types.tsx - [ ] https://github.com/software-mansion/react-native-screens/blob/main/src/native-stack/types.tsx - [x] Ensured that CI passes
1 parent e5413af commit 3e26ddf

File tree

16 files changed

+656
-27
lines changed

16 files changed

+656
-27
lines changed

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHost.kt

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,34 @@
11
package com.swmansion.rnscreens.gamma.tabs
22

33
import android.content.res.Configuration
4+
import android.os.Build
45
import android.view.Choreographer
6+
import android.view.Gravity
57
import android.view.MenuItem
8+
import android.view.View
9+
import android.view.WindowInsets
610
import android.widget.FrameLayout
7-
import android.widget.LinearLayout
811
import androidx.appcompat.view.ContextThemeWrapper
12+
import androidx.core.view.children
913
import androidx.fragment.app.FragmentManager
1014
import com.facebook.react.modules.core.ReactChoreographer
1115
import com.facebook.react.uimanager.ThemedReactContext
1216
import com.google.android.material.bottomnavigation.BottomNavigationView
1317
import com.swmansion.rnscreens.BuildConfig
1418
import com.swmansion.rnscreens.gamma.helpers.FragmentManagerHelper
1519
import com.swmansion.rnscreens.gamma.helpers.ViewIdGenerator
20+
import com.swmansion.rnscreens.safearea.EdgeInsets
21+
import com.swmansion.rnscreens.safearea.SafeAreaProvider
22+
import com.swmansion.rnscreens.safearea.SafeAreaView
1623
import com.swmansion.rnscreens.utils.RNSLog
1724
import kotlin.properties.Delegates
1825

1926
class TabsHost(
2027
val reactContext: ThemedReactContext,
21-
) : LinearLayout(reactContext),
22-
TabScreenDelegate {
28+
) : FrameLayout(reactContext),
29+
TabScreenDelegate,
30+
SafeAreaProvider,
31+
View.OnLayoutChangeListener {
2332
/**
2433
* All container updates should go through instance of this class.
2534
* The semantics are as follows:
@@ -93,19 +102,21 @@ class TabsHost(
93102

94103
private val bottomNavigationView: BottomNavigationView =
95104
BottomNavigationView(wrappedContext).apply {
96-
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
105+
layoutParams =
106+
LayoutParams(
107+
LayoutParams.MATCH_PARENT,
108+
LayoutParams.WRAP_CONTENT,
109+
Gravity.BOTTOM,
110+
)
97111
}
98112

99113
private val contentView: FrameLayout =
100114
FrameLayout(reactContext).apply {
101115
layoutParams =
102-
LinearLayout
103-
.LayoutParams(
104-
LayoutParams.MATCH_PARENT,
105-
LayoutParams.WRAP_CONTENT,
106-
).apply {
107-
weight = 1f
108-
}
116+
LayoutParams(
117+
LayoutParams.MATCH_PARENT,
118+
LayoutParams.MATCH_PARENT,
119+
)
109120
id = ViewIdGenerator.generateViewId()
110121
}
111122

@@ -121,6 +132,8 @@ class TabsHost(
121132

122133
private var isLayoutEnqueued: Boolean = false
123134

135+
private var interfaceInsetsChangeListener: SafeAreaView? = null
136+
124137
private val appearanceCoordinator =
125138
TabsHostAppearanceCoordinator(wrappedContext, bottomNavigationView, tabScreenFragments)
126139

@@ -193,7 +206,6 @@ class TabsHost(
193206
}
194207

195208
init {
196-
orientation = VERTICAL
197209
addView(contentView)
198210
addView(bottomNavigationView)
199211

@@ -418,12 +430,73 @@ class TabsHost(
418430
bottomNavigationView.menu.findItem(index)
419431
}
420432

433+
override fun setOnInterfaceInsetsChangeListener(listener: SafeAreaView) {
434+
if (interfaceInsetsChangeListener == null) {
435+
bottomNavigationView.addOnLayoutChangeListener(this)
436+
}
437+
interfaceInsetsChangeListener = listener
438+
}
439+
440+
override fun removeOnInterfaceInsetsChangeListener(listener: SafeAreaView) {
441+
if (interfaceInsetsChangeListener == listener) {
442+
interfaceInsetsChangeListener = null
443+
bottomNavigationView.removeOnLayoutChangeListener(this)
444+
}
445+
}
446+
447+
override fun getInterfaceInsets(): EdgeInsets = EdgeInsets(0.0f, 0.0f, 0.0f, bottomNavigationView.height.toFloat())
448+
449+
override fun dispatchApplyWindowInsets(insets: WindowInsets?): WindowInsets? {
450+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
451+
return super.dispatchApplyWindowInsets(insets)
452+
}
453+
454+
// On Android versions prior to R, insets dispatch is broken.
455+
// In order to mitigate this, we override dispatchApplyWindowInsets with
456+
// correct implementation. To simplify it, we skip the call to TabsHost's
457+
// onApplyWindowInsets.
458+
if (insets?.isConsumed ?: true) {
459+
return insets
460+
}
461+
462+
for (child in children) {
463+
child.dispatchApplyWindowInsets(insets)
464+
}
465+
466+
return insets
467+
}
468+
421469
internal fun onViewManagerAddEventEmitters() {
422470
// When this is called from View Manager the view tag is already set
423471
check(id != NO_ID) { "[RNScreens] TabsHost must have its tag set when registering event emitters" }
424472
eventEmitter = TabsHostEventEmitter(reactContext, id)
425473
}
426474

475+
override fun onLayoutChange(
476+
view: View?,
477+
left: Int,
478+
top: Int,
479+
right: Int,
480+
bottom: Int,
481+
oldLeft: Int,
482+
oldTop: Int,
483+
oldRight: Int,
484+
oldBottom: Int,
485+
) {
486+
require(view is BottomNavigationView) {
487+
"[RNScreens] TabsHost's onLayoutChange expects BottomNavigationView, received $view instead"
488+
}
489+
490+
val oldHeight = oldBottom - oldTop
491+
val newHeight = bottom - top
492+
493+
if (newHeight != oldHeight) {
494+
interfaceInsetsChangeListener?.apply {
495+
this.onInterfaceInsetsChange(EdgeInsets(0.0f, 0.0f, 0.0f, newHeight.toFloat()))
496+
}
497+
}
498+
}
499+
427500
companion object {
428501
const val TAG = "TabsHost"
429502
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Implementation adapted from `react-native-safe-area-context`:
2+
// https://github.com/AppAndFlow/react-native-safe-area-context/tree/v5.6.1
3+
package com.swmansion.rnscreens.safearea
4+
5+
import androidx.core.graphics.Insets
6+
import kotlin.math.max
7+
8+
data class EdgeInsets(
9+
val left: Float,
10+
val top: Float,
11+
val right: Float,
12+
val bottom: Float,
13+
) {
14+
companion object {
15+
val ZERO: EdgeInsets = EdgeInsets(0.0f, 0.0f, 0.0f, 0.0f)
16+
17+
fun fromInsets(insets: Insets) =
18+
EdgeInsets(
19+
insets.left.toFloat(),
20+
insets.top.toFloat(),
21+
insets.right.toFloat(),
22+
insets.bottom.toFloat(),
23+
)
24+
25+
fun max(
26+
i1: EdgeInsets,
27+
i2: EdgeInsets,
28+
) = EdgeInsets(
29+
max(i1.left, i2.left),
30+
max(i1.top, i2.top),
31+
max(i1.right, i2.right),
32+
max(i1.bottom, i2.bottom),
33+
)
34+
}
35+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.swmansion.rnscreens.safearea
2+
3+
enum class InsetType {
4+
ALL,
5+
SYSTEM,
6+
INTERFACE,
7+
;
8+
9+
fun containsSystem(): Boolean = this == ALL || this == SYSTEM
10+
11+
fun containsInterface(): Boolean = this == ALL || this == INTERFACE
12+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.swmansion.rnscreens.safearea
2+
3+
/**
4+
* Allows containers that obscure some part of its children views to provide safe area.
5+
*
6+
* This protocol only handles **interface insets** (e.g. toolbar, bottomNavigationView).
7+
* System insets (e.g. `systemBars`, `displayCutout`) are handled through Android inset
8+
* dispatch mechanism (via `onApplyWindowInsets`).
9+
*
10+
* Classes implementing this protocol are responsible for notifying `SafeAreaView`, which
11+
* registers as a listener, about changes to **interface** safe area insets.
12+
*/
13+
interface SafeAreaProvider {
14+
/**
15+
* Responsible for registering **interface** safe area insets listener.
16+
*
17+
* @param listener `SafeAreaView` instance that wants to receive notifications
18+
* about changes to **interface** safe area insets via `onInterfaceInsetsChange`.
19+
*/
20+
fun setOnInterfaceInsetsChangeListener(listener: SafeAreaView)
21+
22+
/**
23+
* Responsible for unregistering **interface** safe area insets listener.
24+
*
25+
* @param listener `SafeAreaView` instance that wants to stop receiving notifications
26+
* about changes to **interface** safe area insets.
27+
*/
28+
fun removeOnInterfaceInsetsChangeListener(listener: SafeAreaView)
29+
30+
/**
31+
* Responsible for providing current **interface** safe area insets.
32+
*
33+
* @returns `EdgeInsets` describing the current **interface** safe area insets.
34+
*/
35+
fun getInterfaceInsets(): EdgeInsets
36+
}

0 commit comments

Comments
 (0)