Skip to content

feat(Android, Stack v5): handle header configuration and custom subviews#3796

Open
kligarski wants to merge 89 commits intomainfrom
@kligarski/stack-v5-android-header-subviews
Open

feat(Android, Stack v5): handle header configuration and custom subviews#3796
kligarski wants to merge 89 commits intomainfrom
@kligarski/stack-v5-android-header-subviews

Conversation

@kligarski
Copy link
Copy Markdown
Contributor

@kligarski kligarski commented Mar 24, 2026

Description

Adds support for header configuration and providing custom subviews for M3 App Bar in Stack v5.

Closes https://github.com/software-mansion/react-native-screens-labs/issues/916.

Details

JS API

After some discussions we decided that the API for header configuration will look as follows:

  1. Stack object will provide a Stack.HeaderConfig component only.
  2. All header configuration (and header subview configuration) will be done via props on Stack.HeaderConfig component.
  3. Stack.HeaderConfig will be split in similar way to TabsHost and TabsScreen between platforms:
  • common props will be available on both platforms (title, hidden),
  • platform-specific props will be available via android/ios prop.
  1. Due to differences between platforms, components related to subviews/bar button items will be separate (e.g. StackHeaderSubview will be Android-only).

High-level flow of updates

Header attachment
  1. StackHeaderConfig is attached to StackScreen.
  2. StackHeaderCoordinatorLayout creates StackHeaderCoordinator instance. CL attaches its own onHeaderHeightChanged callback to the coordinator. It will be called when AppBarLayout is scrolled and will propagate correct Y offset to StackScreen.
  3. StackHeaderCoordinatorLayout attaches its own callback as onHeaderConfigAttachListener on StackScreen (weak ref) and if stackScreen.headerConfig is available, it uses the config.
  4. StackHeaderCoordinatorLayout.handleHeaderConfigAttach runs and attaches its own callback as onHeaderConfigChangeListener on new config (weak ref). This callback passes all updates to headerCoordinator via applyHeaderConfig (it also batches the updates). Initial update is performed.
Subview attachment
  1. StackHeaderSubview is attached to StackHeaderConfig.
  2. StackHeaderSubview attaches itself as onStackHeaderSubviewChangeListener (weak ref) to the subview (to inform HeaderCoordinator about e.g. collapseMode prop change on the subview).
  3. HeaderConfig informs HeaderCoordinator via onConfigChangeListener about the new subview.
Offset shadow tree update
  1. When scrolling event happens, AppBarLayout.ScrollingViewBehavior.onDependentViewChanged runs.
  2. We override this method in StackHeaderScrollingViewBehavior to allow headerCoordinator to attach its own callback.
  3. This callback allows to update StackScreen's offset via onHeaderHeightChanged callback.
  4. Additionally, we attach layout and offset listeners to AppBarLayout (so that it works with both non-transparent and transparent header). They update size & offset for HeaderConfig and offsets for HeaderSubviews via syncShadowState method.

Scrolling behaviors

M3 App Bar suppors various scrolling behaviors. In order to use them, we need to use ScrollingViewBehavior via CoordinatorLayout. Important thing to note is that the content below the header has constant size. Scrolling the content scrolls the app bar and the content. The size of the content is calculated by CoordinatorLayout so that the content is fully visible when header is collapsed. When header is expanded, some part of the screen is cut off.

Please also note that for this to work we need to use NestedScrollView or at least regular ScrollView with nestedScrollEnabled={true}.

"Transparent" header

The intention of this PR is to focus only on layout-related props, not the final API. I added transparent that changes the layout but not the background color.

Another thing to consider is whether transparent prop should exist or should it be layout-related only & background color can be handled via regular styling backgroundColor prop. This might be even more useful for iOS implementation of the header but it needs to be researched (cc @kmichalikk).

"Transparent" header does not make sense with scrolling behaviors because the content will never be rendered below the header. That's why in this mode the screen fills the entire container and does not move.

Custom subviews sizing

In Stack v4 we faced many problems with handling custom subviews. For Stack v5 we decided that the size of leading (left in LTR, right in RTL), center and trailing (right in LTR, left in RTL) subviews will be determined fully by Yoga and it must depend on the size of the content inside of the subview. Subviews will not be aware of the position and size of other subviews and flex behaviors will not work (but obviously if you wrap subview content in a view with specific width and height, inside the wrapper flex will work as usual). The layout of the subview (position in the toolbar) will be determined fully by the native layout.

This simplified approach will allow us to reliably layout the subviews without Yoga and native layout fighting each other.

Stack v5 will additionally support background subview for collapsing headers (medium and large). This view will be rendered as the background of the header. The subview will always be resized to the full size of the AppBarLayout (using flex will be supported).

Layout synchronization between Yoga and native

Note

The background subview might have collapseMode: 'parallax' effect applied - then it's position moves slower than AppBarLayout. Light blue box represents background subview with collapseMode: 'parallax' as this is the most complicated example, best for the explanation.

headerNativeLayout_v2 headerReactLayout1_v2 headerReactLayout2_v2

Problems with custom subview ordering

Title in small header

When small header is used, we would usually rely on title prop from Toolbar. There is however one caveat when using custom views - title is laid out before subviews therefore leading subview will always appear after the title. This in isolation might not be considered a problem (due to this being a native behavior) but in medium/large collapsing header, title is managed by CollapsingToolbarLayout which doesn't use the title from Toolbar but attaches its own custom dummyView as a subview of Toolbar -> in this configuration if you add a subview with Gravity.START, it will be laid out before the title. We don't want the behavior to differ in such manner between header types so we decided that we should align this. It's easier to manage title in small behavior and leave medium/large as is (but it turns out that for RTL we need to do some hacky workarounds for collpsing header either way, details below).

To ensure that title is laid out after the leading subview, we don't rely on Toolbar.title but add our own view that tries to 1:1 mimic native title view.

Ensuring correct subview order in RTL

For small header, we need to make sure that leading subview and title view is added in correct order.

For medium/large header, the situation is more complicated. dummyView is always added last, after our subviews. This causes incorrect ordering. In order to fix this, we need to make sure that dummyView is created and attached (by forcing measure). Then we manually change the order of the subviews (moving dummyView to the index 0 fixes the problem).

Known issues & future upgrades related to layout

Adding/removing subviews in runtime

The order of inserting subviews is critical for correct layout and it is very fragile. For now, when subviews are added/removed in runtime, we're recreating the hierarchy from scratch.

rebuild_subview.mp4

In the future we might research whether we can optimize this to prevent unnecessary rebuilds.

Background subview collapse mode

Currently, background subview supports only 2 of 3 available collapseModes: off and parallax. As we wanted to allow using flex: 1 inside the background subview, we need to set the size of the HeaderConfig component & use absolute fill on the subview so that it matches AppBarLayout. This however is problematic for pin collapse mode. If subview takes all the space, it behaves exactly like off collapse mode - there is no additional space between the bottom edge of the header subview and bottom edge of AppBarLayout/CollapsingToolbarLayout so it scrolls immediately.

In the future, we can consider exposing maunal size configuration for the subview (or finding another solution) to support collapseMode: 'pin'.

Maintaining scroll on rebuild

Currently, when header needs rebuilding, it doesn't preserve AppBarLayout's scroll position and it expands automatically.

rebuild_scroll.mp4

We should research whether we can cache the scroll offset between rebuilds.

hitSlop

hitSlop works correctly now but in the context of native hierarchy. When in collapsing toolbar you click below MaterialToolbar even though the hitSlop range would be enough, the touch won't be registered. Additionally, collapsing header uses dummyView that does not display any text when header is expanded but it will prevent touches which might be surprising for users.

We should consider supporting hitSlop in full HeaderConfig area.

Margins for header items

Margins between subviews are inconsistent due to differences in layout.

small large
margin_small margin_large

We should consider exposing full margin configuration for subviews (it's a little bit complicated with the margins used in native layout) and maybe provide more consistent defaults.

Scroll flags

Android allows full customization of scrolling behavior.

We need to expose the scroll flags in one of the follow-up PRs. They are hard-coded for now.

Handling insets

For now, we're relying on fitsSystemWindow on AppBarLayout to apply padding to avoid system bars and display cutout. This will be problematic for nested stack support, using insets from decor view in first render and maunal control (similar to #3835).

In the future, we should manually apply the inset (but I remember from my research that this might be more difficult than it seems). If insets are read from decor, we might be able to use choreographer for requestLayout in StackContainer instead of post (currently, using choreographer as in tabs causes layout jump on first render after rebuild).

Title layout customization

We need to add support for centering the title & handle our managed title view in small header.

Navigation icon and menu

We need to add navigation icon (back button) and allow its customization. In the future we want to also expose Menu API.

RTL text ellipsize bug

There are some problems with text ellipsize in RTL when custom subviews are used.

rtl_ellipisze_bug.mp4

We should check whether this is a native bug or not.

Changes

JS

  • refactored file structure to match convention from Tabs
  • added shared StackHeaderConfig component with Android base implementation and skeleton for iOS
  • added Android-only StackHeaderSubview component
  • added Fabric specs to new components
  • updated react-native.config.js
  • adjusted StackContainer to accept header configuration as a prop and render header config component if the prop is non-null
  • fixed debug logs to avoid infinite recursive calls

Native

  • added detachFromCurrentParent View extension
  • extracted ShadowStateProxy from StackScreen to allow re-use between components
  • moved header-related files to separate package
  • added native implementation of StackHeaderConfig and StackHeaderSubview
  • added OnHeaderConfigAttachListener, OnHeaderConfigChangeListener interfaces
  • added logic to handle subviews in the header
  • adjusted layout flow to ensure that requestLayout works

C++

  • added custom shadow nodes for StackHeaderConfig and StackHeaderSubview
  • ensured that RTL doesn't change absolute positioning for HeaderSubview

Visual documentation

General
general.mp4
Small opaque Small translucent
small_opaque.mp4
small_transparent.mp4
Large opaque Large translucent
large_opaque.mp4
large_transparent.mp4

Medium is analogous to large, just differs in size.

Test plan

Run test-stack-subviews.tsx. This test contains many props as subview layout is depended on many factors but some parts of the test should be extracted and tested separately (e.g. hidden, transparent, ...). With @LKuchno we decided that this SFT will be split and scenarios will be added in the future as some parts of the implementation might still change.

Checklist

  • Included code example that can be used to test this change.
  • For visual changes, included screenshots / GIFs / recordings documenting the change.
  • For API changes, updated relevant public types.
  • Ensured that CI passes

It doesn't use any CoordinatorLayout features.
I'm not really sure which context should we use. Might want to revisit it
later.
TODO: handle layout of CoordinatorLayout, now it's added in a random place.
I did not apply any changes to color - header won't be transparent but will have
correct layout for transparent header.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This large pull request implements header configuration and custom subviews support for Material Design 3 (M3) App Bar in Stack v5, enabling developers to customize headers with title, type (small/medium/large), and custom subviews positioned in leading, center, trailing, and background areas. The implementation includes significant refactoring of native code organization and integration between JavaScript, native Android, and C++ layers.

Changes:

  • Added new Stack.HeaderConfig and StackHeaderSubview TypeScript components with platform-specific implementations (Android/iOS)
  • Reorganized native Android header code from screen.header package to dedicated header package with structured subcomponents (config, subview)
  • Implemented C++ shadow node support for the new header and subview components
  • Extracted ShadowStateProxy utility class for shared state synchronization between Yoga and native layouts
  • Integrated header configuration into the StackContainer to enable route-level header customization via setRouteOptions
  • Fixed debug logging to prevent circular reference issues using a new safeStringify utility
  • Replaced old test file with comprehensive test scenario demonstrating header configuration capabilities

Reviewed changes

Copilot reviewed 67 out of 71 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/components/gamma/stack/header/ New TypeScript components for header config with platform-specific implementations
src/components/gamma/stack/host/, screen/ Reorganized exports using index files following convention
android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/ New native Android implementation with coordinator pattern for header lifecycle
android/src/main/java/com/swmansion/rnscreens/gamma/common/ShadowStateProxy.kt Extracted state proxy for layout synchronization
common/cpp/react/renderer/components/rnscreens/RNSStackHeader* C++ shadow nodes and component descriptors
apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx Comprehensive test demonstrating header configuration
apps/src/shared/gamma/containers/stack/ Integration of header config into StackContainer

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@kligarski kligarski marked this pull request as ready for review April 9, 2026 16:47
Base automatically changed from @kligarski/stack-v5-android-header-skeleton to main April 9, 2026 17:05
val trailingSubview: StackHeaderSubviewProviding?
val backgroundSubview: StackHeaderSubviewProviding?

val isRtl: Boolean
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In the repo we write this as RTL instead of Rtl so we should probably stick to that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

attachAppBarListeners(appBar)

populateAppBar(appBar, config)
maybeApplyRtlCollapsingToolbarLayoutWorkaround(coordinatorLayout, config, appBar)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

RTL

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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


private fun setContentBehavior(coordinatorLayout: StackHeaderCoordinatorLayout) {
val params = coordinatorLayout.stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams
if (params.behavior == null) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

does it mean we can only set the behavior once?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's what we do right now, we set it once and keep it while it's necessary. I didn't notice any problems with this approach. If something comes up in the future, we can reapply the behavior on each rebuild.


// endregion

private fun maybeApplyRtlCollapsingToolbarLayoutWorkaround(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

RTL

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment on lines +64 to +68
if (index == getChildCount(parent) - 1 && parent.headerConfig != null) {
parent.headerConfig?.let { parent.detachHeaderConfig(it) }
} else {
super.removeViewAt(parent, index)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Normally, I wouldn't count on that being true. But since we are controlling it, maybe we could leave it as is

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Well, we're not rendering header config in StackScreen directly, we're doing this in our example container implementation (StackContainer.tsx).

I decided to add additional checks to throw an exception in runtime if HeaderConfig is not the last child of StackScreen.

8da4361

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