Skip to content

Commit 6b51e5e

Browse files
authored
feat(Android, Stack v5): handle header configuration and custom subviews (#3796)
## Description Adds support for header configuration and providing custom subviews for M3 App Bar in Stack v5. Closes software-mansion/react-native-screens-labs#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. 4. 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. 3. `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. 5. 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 `HeaderSubview`s 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. <img width="5137" height="4927" alt="headerNativeLayout_v2" src="https://github.com/user-attachments/assets/7f4fd50f-ce1a-409b-b37c-acb82a31ffc8" /> <img width="6237" height="4911" alt="headerReactLayout1_v2" src="https://github.com/user-attachments/assets/6743bae4-6450-4071-ba82-69d2459491db" /> <img width="5301" height="4284" alt="headerReactLayout2_v2" src="https://github.com/user-attachments/assets/2522d3fa-309e-4743-9d11-30a578e72455" /> #### 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. https://github.com/user-attachments/assets/30da0d3e-30f5-4f10-8a2d-910adcaffecb 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 `collapseMode`s: `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. https://github.com/user-attachments/assets/7d688874-c060-402f-a631-7cdcb225a310 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` | | --- | --- | | <img width="1080" height="2220" alt="margin_small" src="https://github.com/user-attachments/assets/902eaf5b-4ace-4a00-a74f-3a30b2df7173" /> | <img width="1080" height="2220" alt="margin_large" src="https://github.com/user-attachments/assets/28f4bbb7-ac10-4e2f-9abb-18ab4fe828a2" /> | 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. https://github.com/user-attachments/assets/5531e385-8f44-4932-bf68-a0c4225b57d0 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 | | --- | | <video src="https://github.com/user-attachments/assets/82ce9856-1342-40b2-8812-0aebbfa8d952" /> | | Small opaque | Small translucent | | --- | --- | | <video src="https://github.com/user-attachments/assets/fd644c78-fc47-4d4d-8e92-86c2a5733f63" /> | <video src="https://github.com/user-attachments/assets/520c0e3d-321e-4aed-a415-38ade883ace9" /> | | Large opaque | Large translucent | | --- | --- | | <video src="https://github.com/user-attachments/assets/afeb6720-cd60-4f11-8549-7908856186f1" /> | <video src="https://github.com/user-attachments/assets/e999198c-82cd-4be8-916f-1832ad14bd84" /> | 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 - [x] Included code example that can be used to test this change. - [x] For visual changes, included screenshots / GIFs / recordings documenting the change. - [x] For API changes, updated relevant public types. - [x] Ensured that CI passes
1 parent 29b2a2b commit 6b51e5e

71 files changed

Lines changed: 2239 additions & 355 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import com.facebook.react.module.model.ReactModuleInfo
88
import com.facebook.react.module.model.ReactModuleInfoProvider
99
import com.facebook.react.uimanager.ViewManager
1010
import com.swmansion.rnscreens.gamma.scrollviewmarker.ScrollViewMarkerViewManager
11+
import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigViewManager
12+
import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewViewManager
1113
import com.swmansion.rnscreens.gamma.stack.host.StackHostViewManager
1214
import com.swmansion.rnscreens.gamma.stack.screen.StackScreenViewManager
1315
import com.swmansion.rnscreens.gamma.tabs.host.TabsHostViewManager
@@ -55,6 +57,8 @@ class RNScreensPackage : BaseReactPackage() {
5557
StackHostViewManager(),
5658
StackScreenViewManager(),
5759
ScrollViewMarkerViewManager(),
60+
StackHeaderConfigViewManager(),
61+
StackHeaderSubviewViewManager(),
5862
)
5963
}
6064

android/src/main/java/com/swmansion/rnscreens/ext/ViewExt.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,7 @@ internal fun View.findFragmentOrNull(): Fragment? =
5252
* before being attached to window.
5353
*/
5454
internal fun View.isMeasured(): Boolean = this.measuredWidth != 0 || this.measuredHeight != 0 || this.isLaidOut
55+
56+
internal fun View.detachFromCurrentParent() {
57+
(parent as? ViewGroup)?.removeView(this)
58+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.swmansion.rnscreens.gamma.common
2+
3+
import com.facebook.react.bridge.WritableNativeMap
4+
import com.facebook.react.uimanager.PixelUtil
5+
import com.facebook.react.uimanager.StateWrapper
6+
import kotlin.math.abs
7+
8+
internal class ShadowStateProxy(
9+
private val includesFrameSize: Boolean = true,
10+
) {
11+
internal var stateWrapper: StateWrapper? = null
12+
13+
private var lastFrameWidthInDp: Float = 0f
14+
private var lastFrameHeightInDp: Float = 0f
15+
private var lastContentOffsetXInDp: Float = 0f
16+
private var lastContentOffsetYInDp: Float = 0f
17+
18+
fun updateStateIfNeeded(
19+
frameWidth: Int? = null,
20+
frameHeight: Int? = null,
21+
contentOffsetX: Int? = null,
22+
contentOffsetY: Int? = null,
23+
) {
24+
val widthInDp = frameWidth?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastFrameWidthInDp
25+
val heightInDp = frameHeight?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastFrameHeightInDp
26+
val offsetXInDp = contentOffsetX?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastContentOffsetXInDp
27+
val offsetYInDp = contentOffsetY?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastContentOffsetYInDp
28+
29+
if (
30+
abs(lastFrameWidthInDp - widthInDp) < DELTA &&
31+
abs(lastFrameHeightInDp - heightInDp) < DELTA &&
32+
abs(lastContentOffsetXInDp - offsetXInDp) < DELTA &&
33+
abs(lastContentOffsetYInDp - offsetYInDp) < DELTA
34+
) {
35+
return
36+
}
37+
38+
lastFrameWidthInDp = widthInDp
39+
lastFrameHeightInDp = heightInDp
40+
lastContentOffsetXInDp = offsetXInDp
41+
lastContentOffsetYInDp = offsetYInDp
42+
43+
val map =
44+
WritableNativeMap().apply {
45+
if (includesFrameSize) {
46+
putDouble("frameWidth", widthInDp.toDouble())
47+
putDouble("frameHeight", heightInDp.toDouble())
48+
}
49+
putDouble("contentOffsetX", offsetXInDp.toDouble())
50+
putDouble("contentOffsetY", offsetYInDp.toDouble())
51+
}
52+
stateWrapper?.updateState(map)
53+
}
54+
55+
companion object {
56+
private const val DELTA = 0.1f
57+
}
58+
}

android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt renamed to android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt

Lines changed: 22 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.swmansion.rnscreens.gamma.stack.screen.header
1+
package com.swmansion.rnscreens.gamma.stack.header
22

33
import android.annotation.SuppressLint
44
import android.content.Context
@@ -10,13 +10,12 @@ import com.google.android.material.appbar.AppBarLayout
1010
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED
1111
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL
1212
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL
13-
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP
1413
import com.google.android.material.appbar.CollapsingToolbarLayout
1514
import com.google.android.material.appbar.MaterialToolbar
16-
import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType
15+
import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderType
1716
import com.swmansion.rnscreens.utils.resolveDimensionAttr
1817

19-
internal sealed class StackScreenAppBarLayout(
18+
internal sealed class StackHeaderAppBarLayout(
2019
context: Context,
2120
) : AppBarLayout(context) {
2221
abstract val toolbar: MaterialToolbar
@@ -36,14 +35,13 @@ internal sealed class StackScreenAppBarLayout(
3635

3736
internal class Small(
3837
context: Context,
39-
) : StackScreenAppBarLayout(context) {
38+
) : StackHeaderAppBarLayout(context) {
4039
override val toolbar =
4140
MaterialToolbar(context).apply {
4241
elevation = 0f
4342
layoutParams =
4443
LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
45-
// TODO: debug only for small header, must be moved to configuration
46-
// scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_SNAP
44+
// TODO: debug only for small header, must be moved to config
4745
scrollFlags = SCROLL_FLAG_NO_SCROLL
4846
}
4947
}
@@ -56,17 +54,8 @@ internal sealed class StackScreenAppBarLayout(
5654
@SuppressLint("ViewConstructor")
5755
internal class Collapsing(
5856
context: Context,
59-
val type: StackScreenHeaderType,
60-
) : StackScreenAppBarLayout(context) {
61-
init {
62-
require(
63-
type == StackScreenHeaderType.MEDIUM ||
64-
type == StackScreenHeaderType.LARGE,
65-
) {
66-
"[RNScreens] Collapsing StackScreenAppBarLayout must be MEDIUM or LARGE type."
67-
}
68-
}
69-
57+
val type: StackHeaderType,
58+
) : StackHeaderAppBarLayout(context) {
7059
override val toolbar =
7160
MaterialToolbar(context).apply {
7261
elevation = 0f
@@ -80,13 +69,13 @@ internal sealed class StackScreenAppBarLayout(
8069
}
8170
}
8271

83-
val collapsingToolbarLayout: CollapsingToolbarLayout =
72+
internal val collapsingToolbarLayout: CollapsingToolbarLayout =
8473
run {
8574
val (styleAttr, sizeAttr) =
8675
when (type) {
87-
StackScreenHeaderType.MEDIUM ->
76+
StackHeaderType.MEDIUM ->
8877
Pair(R.attr.collapsingToolbarLayoutMediumStyle, R.attr.collapsingToolbarLayoutMediumSize)
89-
StackScreenHeaderType.LARGE ->
78+
StackHeaderType.LARGE ->
9079
Pair(R.attr.collapsingToolbarLayoutLargeStyle, R.attr.collapsingToolbarLayoutLargeSize)
9180
else -> error("[RNScreens] Invalid header mode.")
9281
}
@@ -97,27 +86,32 @@ internal sealed class StackScreenAppBarLayout(
9786
MATCH_PARENT,
9887
resolveDimensionAttr(context, sizeAttr),
9988
).apply {
100-
// TODO: debug only for medium/large header, must be moved to configuration
101-
scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED or SCROLL_FLAG_SNAP
102-
// scrollFlags = SCROLL_FLAG_NO_SCROLL
89+
// TODO: debug only for medium/large header, must be moved to config
90+
scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED
10391
}
10492
addView(toolbar)
10593
}
10694
}
10795

10896
init {
97+
require(
98+
type == StackHeaderType.MEDIUM ||
99+
type == StackHeaderType.LARGE,
100+
) {
101+
"[RNScreens] Collapsing StackHeaderAppBarLayout must be MEDIUM or LARGE type."
102+
}
109103
addView(collapsingToolbarLayout)
110104
}
111105
}
112106

113107
companion object {
114108
fun create(
115109
context: Context,
116-
type: StackScreenHeaderType,
117-
): StackScreenAppBarLayout =
110+
type: StackHeaderType,
111+
): StackHeaderAppBarLayout =
118112
when (type) {
119-
StackScreenHeaderType.SMALL -> Small(context)
120-
StackScreenHeaderType.MEDIUM, StackScreenHeaderType.LARGE -> Collapsing(context, type)
113+
StackHeaderType.SMALL -> Small(context)
114+
StackHeaderType.MEDIUM, StackHeaderType.LARGE -> Collapsing(context, type)
121115
}
122116
}
123117
}

0 commit comments

Comments
 (0)