Skip to content

Conversation

kligarski
Copy link
Contributor

@kligarski kligarski commented Sep 15, 2025

Description

Adds implementation for handling safe area on Android for bottom tabs.

Implementation has been adapted from react-native-safe-area-context.

TODO:

Screen.Recording.2025-09-15.at.15.11.21.mov

Transparent tab bar

top: false, bottom: false top: true, bottom: false top: true, bottom: true
Screenshot_20250916_091258 Screenshot_20250916_091303 Screenshot_20250916_091311

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 Screens 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:

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()
        }
    // ...
}
Screenshot 2025-09-24 at 17 23 20

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.
3215_before_transparent

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.

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()
        }
    // ...
}
Screenshot 2025-09-24 at 17 23 29

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).
3215_after_transparent

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

@kligarski kligarski marked this pull request as ready for review September 16, 2025 11:57
Base automatically changed from @kligarski/safe-area-view-poc to main September 17, 2025 07:57
Copy link
Contributor

@t0maboro t0maboro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had some issues with testing runtime locally, but verified with @kligarski that it's working well.

@kligarski kligarski force-pushed the @kligarski/safe-area-view-android-poc branch from cd722ac to 575c00b Compare September 18, 2025 09:23
@kligarski
Copy link
Contributor Author

Currently, SafeAreaView on Android is implemented only for bottom tabs. We're planning to add a flag to enable SafeAreaView globally in our components very soon so I'm not sure if I should create any test screen for this PR? (It would be very similar to current TestBottomTabs).

Let me know what you think.

@kligarski kligarski marked this pull request as draft September 22, 2025 12:42
@kligarski kligarski marked this pull request as ready for review September 23, 2025 06:38
Copy link
Member

@kkafar kkafar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay. The PR looks really nice. I have few requests, please answer them.

}

init {
ViewCompat.setOnApplyWindowInsetsListener(this, this)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we using insets listener here, instead of regular onApplyWindowInsets method?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used it to get WindowInsetsCompat instance instead of getting WindowInsets and converting it manually.

Comment on lines +191 to +208
// Block the main thread until the native module thread is finished with
// its current tasks. To do this we use the done boolean as a lock and enqueue
// a task on the native modules thread. When the task runs we can unblock the
// main thread. This should be safe as long as the native modules thread
// does not block waiting on the main thread.
var done = false
val lock = ReentrantLock()
val condition = lock.newCondition()
val startTime = System.nanoTime()
var waitTime = 0L
getReactContext(this).runOnNativeModulesQueueThread {
lock.withLock {
if (!done) {
done = true
condition.signal()
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hate this. Let's keep our fingers crossed we won't run into problems with this.

getReactContext(this).runOnNativeModulesQueueThread {
lock.withLock {
if (!done) {
done = true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're modifying here non-volatile value possible across thread boundaries 🙈

I won't demand changes here, since this code is already a bit battle tested, but man...

@kkafar kkafar self-requested a review September 25, 2025 10:16
Copy link
Member

@kkafar kkafar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR looks really good now. I have singl remark. Please answer it. I'll check the runtime behaviour in meantime.

Copy link
Member

@kkafar kkafar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Realised it is not used yet. The app builds fine. I think we can proceed after cleaning up what I've indicated in preceding review.

@kligarski kligarski merged commit 3e26ddf into main Sep 26, 2025
9 checks passed
@kligarski kligarski deleted the @kligarski/safe-area-view-android-poc branch September 26, 2025 10:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants