Skip to content

Commit a821ff0

Browse files
authored
feat(Android, Stack v5): add basic support for header (#3753)
## Description Adds basic support for the header on Android in Stack v5 using [Material 3 App Bar](https://m3.material.io/components/app-bars/overview). This PR focuses on creating the class structure and handling layout synchronization between native side and Yoga. There is no configuration exposed to JS, on the native side `StackScreenHeaderConfigurationProviding` exposes basic properties: - `headerMode`: can be one of `small`, `medium`, `large` - `title` - `isHidden` - `isTransparent` - please note here that this **does not** change the color of the header to transparent but only the layout. Changing appearance will be handled in separate PRs (then setting this or similar prop will override background color). Also note that this option currently might work incorrectly with `liftOnScroll` enabled in `AppBarLayout`. This will be handled in the future PRs. Closes software-mansion/react-native-screens-labs#892. Closes software-mansion/react-native-screens-labs#903. ## Changes - create necessary classes for setting up the header - create custom ShadowNode logic for `StackScreen` - add WIP Single Feature Test - it is currently static as there are no props exposed to JS. In the future PRs, it will be modified to allow changing header modes in runtime. ## Before & after - visual documentation | Small + no scroll | Large + scroll | | --- | --- | | <video src="https://github.com/user-attachments/assets/3e50572b-0d49-46f1-962d-7577da4a6ae1" /> | <video src="https://github.com/user-attachments/assets/51588e58-c696-4231-b733-db88d1cf081a" /> | | "Transparent" (`liftOnScroll` disabled) | Dynamic hide | | --- | --- | | <video src="https://github.com/user-attachments/assets/ee663c43-9029-488e-ae2a-d6b3d3e39860" /> | <video src="https://github.com/user-attachments/assets/cfb216cf-42d7-4c3a-9f27-1b1d0c4ffa0e" /> | ## Test plan Run `single-feature-tests/test-stack-header-modes.tsx`. For testing, you can change properties, currently hard-coded in `StackScreenCoordinatorLayout` and scroll flags/`liftOnScroll` in `StackScreenAppBarLayout`. ## 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] Ensured that CI passes
1 parent a52b1b4 commit a821ff0

23 files changed

Lines changed: 647 additions & 12 deletions

android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package com.swmansion.rnscreens.gamma.stack.host
33
import android.annotation.SuppressLint
44
import android.content.Context
55
import android.util.Log
6-
import androidx.coordinatorlayout.widget.CoordinatorLayout
6+
import android.widget.FrameLayout
77
import androidx.fragment.app.Fragment
88
import androidx.fragment.app.FragmentManager
99
import com.swmansion.rnscreens.ext.isMeasured
@@ -18,7 +18,7 @@ import java.lang.ref.WeakReference
1818
internal class StackContainer(
1919
context: Context,
2020
private val delegate: WeakReference<StackContainerDelegate>,
21-
) : CoordinatorLayout(context),
21+
) : FrameLayout(context),
2222
FragmentManager.OnBackStackChangedListener {
2323
private var fragmentManager: FragmentManager? = null
2424

@@ -251,6 +251,15 @@ internal class StackContainer(
251251
}
252252
}
253253

254+
internal fun forceSubtreeMeasureAndLayoutPass() {
255+
measure(
256+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
257+
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
258+
)
259+
260+
layout(left, top, right, bottom)
261+
}
262+
254263
companion object {
255264
const val TAG = "StackContainer"
256265
}

android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,6 @@ class StackScreen(
2121
ATTACHED,
2222
}
2323

24-
init {
25-
// Needed when Transition API is in use to ensure that shadows do not disappear,
26-
// views do not jump around the screen and whole sub-tree is animated as a whole.
27-
isTransitionGroup = true
28-
}
29-
3024
internal var isPreventNativeDismissEnabled: Boolean by Delegates.observable(false) { _, oldValue, newValue ->
3125
if (oldValue != newValue) {
3226
preventNativeDismissChangeObserver?.preventNativeDismissChanged(newValue)
@@ -56,6 +50,17 @@ class StackScreen(
5650
field = value
5751
}
5852

53+
private val shadowStateProxy = StackScreenShadowStateProxy()
54+
55+
var stateWrapper by shadowStateProxy::stateWrapper
56+
57+
fun updateStateIfNeeded(
58+
x: Int? = null,
59+
y: Int? = null,
60+
width: Int? = null,
61+
height: Int? = null,
62+
) = shadowStateProxy.updateStateIfNeeded(x, y, width, height)
63+
5964
internal lateinit var eventEmitter: StackScreenEventEmitter
6065

6166
/**
@@ -89,7 +94,9 @@ class StackScreen(
8994
t: Int,
9095
r: Int,
9196
b: Int,
92-
) = Unit
97+
) {
98+
shadowStateProxy.updateStateIfNeeded(width = r - l, height = b - t)
99+
}
93100

94101
override fun getAssociatedFragment(): Fragment? =
95102
this.findFragmentOrNull()?.also {

android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import android.view.View
77
import android.view.ViewGroup
88
import androidx.fragment.app.Fragment
99
import androidx.transition.Slide
10+
import com.swmansion.rnscreens.gamma.stack.screen.header.StackScreenCoordinatorLayout
1011

1112
internal class StackScreenFragment(
1213
internal val stackScreen: StackScreen,
@@ -43,7 +44,7 @@ internal class StackScreenFragment(
4344
inflater: LayoutInflater,
4445
container: ViewGroup?,
4546
savedInstanceState: Bundle?,
46-
): View = stackScreen
47+
): View = StackScreenCoordinatorLayout(requireContext(), stackScreen)
4748

4849
override fun onViewCreated(
4950
view: View,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.swmansion.rnscreens.gamma.stack.screen
2+
3+
import com.facebook.react.bridge.WritableMap
4+
import com.facebook.react.bridge.WritableNativeMap
5+
import com.facebook.react.uimanager.PixelUtil
6+
import com.facebook.react.uimanager.StateWrapper
7+
import kotlin.math.abs
8+
9+
internal class StackScreenShadowStateProxy {
10+
internal var stateWrapper: StateWrapper? = null
11+
12+
private var lastXInDp: Float = 0f
13+
private var lastYInDp: Float = 0f
14+
private var lastWidthInDp: Float = 0f
15+
private var lastHeightInDp: Float = 0f
16+
17+
fun updateStateIfNeeded(
18+
x: Int? = null,
19+
y: Int? = null,
20+
width: Int? = null,
21+
height: Int? = null,
22+
) {
23+
val xInDp: Float = x?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastXInDp
24+
val yInDp: Float = y?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastYInDp
25+
val widthInDp: Float =
26+
width?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastWidthInDp
27+
val heightInDp: Float =
28+
height?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastHeightInDp
29+
30+
// Check incoming state values. If they're already the correct value, return early to prevent
31+
// infinite UpdateState/SetState loop.
32+
if (
33+
abs(lastXInDp - xInDp) < DELTA &&
34+
abs(lastYInDp - yInDp) < DELTA &&
35+
abs(lastWidthInDp - widthInDp) < DELTA &&
36+
abs(lastHeightInDp - heightInDp) < DELTA
37+
) {
38+
return
39+
}
40+
41+
lastXInDp = xInDp
42+
lastYInDp = yInDp
43+
lastWidthInDp = widthInDp
44+
lastHeightInDp = heightInDp
45+
46+
val map: WritableMap =
47+
WritableNativeMap().apply {
48+
putDouble("frameWidth", widthInDp.toDouble())
49+
putDouble("frameHeight", heightInDp.toDouble())
50+
putDouble("contentOffsetX", xInDp.toDouble())
51+
putDouble("contentOffsetY", yInDp.toDouble())
52+
}
53+
stateWrapper?.updateState(map)
54+
}
55+
56+
companion object {
57+
private const val DELTA = 0.9f
58+
}
59+
}

android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package com.swmansion.rnscreens.gamma.stack.screen
22

33
import com.facebook.react.bridge.JSApplicationIllegalArgumentException
44
import com.facebook.react.module.annotations.ReactModule
5+
import com.facebook.react.uimanager.ReactStylesDiffMap
6+
import com.facebook.react.uimanager.StateWrapper
57
import com.facebook.react.uimanager.ThemedReactContext
68
import com.facebook.react.uimanager.ViewGroupManager
79
import com.facebook.react.uimanager.ViewManagerDelegate
@@ -45,6 +47,15 @@ class StackScreenViewManager :
4547
makeEventRegistrationInfo(StackScreenNativeDismissPreventedEvent),
4648
)
4749

50+
override fun updateState(
51+
view: StackScreen,
52+
props: ReactStylesDiffMap?,
53+
stateWrapper: StateWrapper?,
54+
): Any? {
55+
view.stateWrapper = stateWrapper
56+
return super.updateState(view, props, stateWrapper)
57+
}
58+
4859
override fun setActivityMode(
4960
view: StackScreen,
5061
value: String?,
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package com.swmansion.rnscreens.gamma.stack.screen.header
2+
3+
import android.annotation.SuppressLint
4+
import android.content.Context
5+
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
6+
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
7+
import androidx.coordinatorlayout.widget.CoordinatorLayout
8+
import com.google.android.material.R
9+
import com.google.android.material.appbar.AppBarLayout
10+
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED
11+
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL
12+
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL
13+
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP
14+
import com.google.android.material.appbar.CollapsingToolbarLayout
15+
import com.google.android.material.appbar.MaterialToolbar
16+
import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType
17+
import com.swmansion.rnscreens.utils.resolveDimensionAttr
18+
19+
internal sealed class StackScreenAppBarLayout(
20+
context: Context,
21+
) : AppBarLayout(context) {
22+
abstract val toolbar: MaterialToolbar
23+
24+
init {
25+
layoutParams = CoordinatorLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
26+
27+
// TODO: this should be exposed in the future via prop. Also, it might not work correctly
28+
// until we set liftOnScrollView manually. Also, we should disable it in transparent
29+
// mode or set elevation higher.
30+
isLiftOnScroll = true
31+
32+
// TODO: this won't work with nested header but there were some problems with lift on scroll
33+
// without it when I was researching this.
34+
fitsSystemWindows = true
35+
}
36+
37+
internal class Small(
38+
context: Context,
39+
) : StackScreenAppBarLayout(context) {
40+
override val toolbar =
41+
MaterialToolbar(context).apply {
42+
elevation = 0f
43+
layoutParams =
44+
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
47+
scrollFlags = SCROLL_FLAG_NO_SCROLL
48+
}
49+
}
50+
51+
init {
52+
addView(toolbar)
53+
}
54+
}
55+
56+
@SuppressLint("ViewConstructor")
57+
internal class Collapsing(
58+
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+
70+
override val toolbar =
71+
MaterialToolbar(context).apply {
72+
elevation = 0f
73+
layoutParams =
74+
CollapsingToolbarLayout
75+
.LayoutParams(
76+
MATCH_PARENT,
77+
resolveDimensionAttr(context, android.R.attr.actionBarSize),
78+
).apply {
79+
collapseMode = CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PIN
80+
}
81+
}
82+
83+
val collapsingToolbarLayout: CollapsingToolbarLayout =
84+
run {
85+
val (styleAttr, sizeAttr) =
86+
when (type) {
87+
StackScreenHeaderType.MEDIUM ->
88+
Pair(R.attr.collapsingToolbarLayoutMediumStyle, R.attr.collapsingToolbarLayoutMediumSize)
89+
StackScreenHeaderType.LARGE ->
90+
Pair(R.attr.collapsingToolbarLayoutLargeStyle, R.attr.collapsingToolbarLayoutLargeSize)
91+
else -> error("[RNScreens] Invalid header mode.")
92+
}
93+
CollapsingToolbarLayout(context, null, styleAttr).apply {
94+
fitsSystemWindows = false
95+
layoutParams =
96+
LayoutParams(
97+
MATCH_PARENT,
98+
resolveDimensionAttr(context, sizeAttr),
99+
).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
103+
}
104+
addView(toolbar)
105+
}
106+
}
107+
108+
init {
109+
addView(collapsingToolbarLayout)
110+
}
111+
}
112+
113+
companion object {
114+
fun create(
115+
context: Context,
116+
type: StackScreenHeaderType,
117+
): StackScreenAppBarLayout =
118+
when (type) {
119+
StackScreenHeaderType.SMALL -> Small(context)
120+
StackScreenHeaderType.MEDIUM, StackScreenHeaderType.LARGE -> Collapsing(context, type)
121+
}
122+
}
123+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.swmansion.rnscreens.gamma.stack.screen.header
2+
3+
import android.annotation.SuppressLint
4+
import android.content.Context
5+
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
6+
import android.widget.FrameLayout
7+
import androidx.coordinatorlayout.widget.CoordinatorLayout
8+
import com.swmansion.rnscreens.gamma.stack.host.StackContainer
9+
import com.swmansion.rnscreens.gamma.stack.screen.StackScreen
10+
import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding
11+
import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType
12+
13+
@SuppressLint("ViewConstructor")
14+
internal class StackScreenCoordinatorLayout(
15+
context: Context,
16+
internal val stackScreen: StackScreen,
17+
) : CoordinatorLayout(context) {
18+
private val headerCoordinator =
19+
StackScreenHeaderCoordinator(context) { headerHeight ->
20+
stackScreen.updateStateIfNeeded(y = headerHeight)
21+
}
22+
23+
internal var stackScreenWrapper: FrameLayout
24+
25+
init {
26+
// Needed when Transition API is in use to ensure that shadows do not disappear,
27+
// views do not jump around the screen and whole subtree is animated as a whole.
28+
isTransitionGroup = true
29+
30+
// Due to how we're synchronizing native & Yoga layout (via contentOriginOffset on
31+
// StackScreen), we can't use StackScreen directly as a child of CoordinatorLayout because
32+
// SurfaceMountingManager will override Y offset (that depends on the header height) with
33+
// Y=0. If we wrap StackScreen in another view, as Y is relative to parent view, value set
34+
// by Yoga will be correct.
35+
stackScreenWrapper = FrameLayout(context).apply { addView(stackScreen) }
36+
addView(
37+
stackScreenWrapper,
38+
LayoutParams(MATCH_PARENT, MATCH_PARENT),
39+
)
40+
41+
// TODO: debug-only, this will be sent in reaction to information from "HeaderConfig" component.
42+
applyHeaderConfiguration(
43+
object : StackScreenHeaderConfigurationProviding {
44+
override val headerType = StackScreenHeaderType.LARGE
45+
override val title = "Hello, World!"
46+
override val isHidden = false
47+
override val isTransparent = false
48+
},
49+
)
50+
}
51+
52+
/**
53+
* Will crash in case parent is not StackContainer.
54+
*/
55+
private fun stackContainerOrNull(): StackContainer? = this.parent as StackContainer?
56+
57+
internal fun maybeRequestLayoutContainer() {
58+
post {
59+
stackContainerOrNull()?.forceSubtreeMeasureAndLayoutPass()
60+
}
61+
}
62+
63+
internal fun applyHeaderConfiguration(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) =
64+
headerCoordinator.applyHeaderConfiguration(this, headerConfigurationProviding)
65+
}

0 commit comments

Comments
 (0)