From 172044858452da954979b32d98b708ed665be9a0 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 11 Mar 2026 13:39:49 +0100 Subject: [PATCH 01/92] make StackContainer a FrameLayout It doesn't use any CoordinatorLayout features. --- .../swmansion/rnscreens/gamma/stack/host/StackContainer.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt index 8d64bc9681..3f1b1de8d6 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt @@ -3,7 +3,7 @@ package com.swmansion.rnscreens.gamma.stack.host import android.annotation.SuppressLint import android.content.Context import android.util.Log -import androidx.coordinatorlayout.widget.CoordinatorLayout +import android.widget.FrameLayout import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import com.swmansion.rnscreens.ext.isMeasured @@ -18,7 +18,7 @@ import java.lang.ref.WeakReference internal class StackContainer( context: Context, private val delegate: WeakReference, -) : CoordinatorLayout(context), +) : FrameLayout(context), FragmentManager.OnBackStackChangedListener { private var fragmentManager: FragmentManager? = null From 6ede4140b2ad74d433ccf07a318eb47d009c524d Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 11 Mar 2026 14:33:46 +0100 Subject: [PATCH 02/92] add skeleton classes --- .../screen/header/StackScreenAppBarLayout.kt | 96 +++++++++++++++++++ .../header/StackScreenCoordinatorLayout.kt | 30 ++++++ .../header/StackScreenHeaderCoordinator.kt | 69 +++++++++++++ ...StackScreenHeaderConfigurationProviding.kt | 6 ++ .../configuration/StackScreenHeaderType.kt | 7 ++ .../rnscreens/utils/DimensionUtils.kt | 18 ++++ 6 files changed, 226 insertions(+) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/utils/DimensionUtils.kt diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt new file mode 100644 index 0000000000..29b9c563fc --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt @@ -0,0 +1,96 @@ +package com.swmansion.rnscreens.gamma.stack.screen.header + +import android.annotation.SuppressLint +import android.content.Context +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import com.google.android.material.R +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.appbar.CollapsingToolbarLayout +import com.google.android.material.appbar.MaterialToolbar +import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType +import com.swmansion.rnscreens.utils.resolveDimensionAttr + +internal sealed class StackScreenAppBarLayout( + context: Context, +) : AppBarLayout(context) { + abstract val toolbar: MaterialToolbar + + internal class Small( + context: Context, + ) : StackScreenAppBarLayout(context) { + override val toolbar = + MaterialToolbar(context).apply { + elevation = 0f + layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) + } + + init { + addView(toolbar) + } + } + + @SuppressLint("ViewConstructor") + internal class Collapsing( + context: Context, + val type: StackScreenHeaderType, + ) : StackScreenAppBarLayout(context) { + init { + require( + type == StackScreenHeaderType.MEDIUM || + type == StackScreenHeaderType.LARGE, + ) { + "[RNScreens] Collapsing StackScreenAppBarLayout must be MEDIUM or LARGE type." + } + } + + override val toolbar = + MaterialToolbar(context).apply { + elevation = 0f + layoutParams = + CollapsingToolbarLayout + .LayoutParams( + MATCH_PARENT, + resolveDimensionAttr(context, android.R.attr.actionBarSize), + ).apply { + collapseMode = CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PIN + } + } + + val collapsingToolbarLayout: CollapsingToolbarLayout = + run { + val (styleAttr, sizeAttr) = + when (type) { + StackScreenHeaderType.MEDIUM -> + Pair(R.attr.collapsingToolbarLayoutMediumStyle, R.attr.collapsingToolbarLayoutMediumSize) + StackScreenHeaderType.LARGE -> + Pair(R.attr.collapsingToolbarLayoutLargeStyle, R.attr.collapsingToolbarLayoutLargeSize) + else -> error("[RNScreens] Invalid header mode.") + } + CollapsingToolbarLayout(context, null, styleAttr).apply { + fitsSystemWindows = false + layoutParams = + LayoutParams( + MATCH_PARENT, + resolveDimensionAttr(context, sizeAttr), + ) + addView(toolbar) + } + } + + init { + addView(collapsingToolbarLayout) + } + } + + companion object { + fun create( + context: Context, + type: StackScreenHeaderType, + ): StackScreenAppBarLayout = + when (type) { + StackScreenHeaderType.SMALL -> Small(context) + StackScreenHeaderType.MEDIUM, StackScreenHeaderType.LARGE -> Collapsing(context, type) + } + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt new file mode 100644 index 0000000000..3a06cc5426 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -0,0 +1,30 @@ +package com.swmansion.rnscreens.gamma.stack.screen.header + +import android.annotation.SuppressLint +import android.content.Context +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.appbar.AppBarLayout +import com.swmansion.rnscreens.gamma.stack.screen.StackScreen +import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding + +@SuppressLint("ViewConstructor") +internal class StackScreenCoordinatorLayout( + context: Context, + stackScreen: StackScreen, +) : CoordinatorLayout(context) { + private val headerCoordinator = StackScreenHeaderCoordinator(context) + + init { + addView( + stackScreen, + LayoutParams(MATCH_PARENT, MATCH_PARENT).apply { + // TODO: when adding possibility to hide the header, this needs to be moved to coordinator + behavior = AppBarLayout.ScrollingViewBehavior() + }, + ) + } + + internal fun applyHeaderConfiguration(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) = + headerCoordinator.applyHeaderConfiguration(this, headerConfigurationProviding) +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt new file mode 100644 index 0000000000..b98de34278 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt @@ -0,0 +1,69 @@ +package com.swmansion.rnscreens.gamma.stack.screen.header + +import android.content.Context +import androidx.appcompat.view.ContextThemeWrapper +import com.google.android.material.R +import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding +import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType + +internal class StackScreenHeaderCoordinator( + context: Context, +) { + private var appBarLayout: StackScreenAppBarLayout? = null + private var currentHeaderType: StackScreenHeaderType? = null + + private val wrappedContext = + ContextThemeWrapper( + context, + R.style.Theme_Material3_DayNight_NoActionBar, + ) + + internal fun applyHeaderConfiguration( + coordinatorLayout: StackScreenCoordinatorLayout, + headerConfigurationProviding: StackScreenHeaderConfigurationProviding, + ) { + // TODO: handle hiding the header + if (appBarLayout == null || currentHeaderType == null || currentHeaderType != headerConfigurationProviding.headerType) { + rebuild(coordinatorLayout, headerConfigurationProviding) + } else { + update(headerConfigurationProviding) + } + } + + private fun rebuild( + coordinatorLayout: StackScreenCoordinatorLayout, + headerConfigurationProviding: StackScreenHeaderConfigurationProviding, + ) { + teardown(coordinatorLayout) + appBarLayout = StackScreenAppBarLayout.create(wrappedContext, headerConfigurationProviding.headerType) + coordinatorLayout.addView(appBarLayout, 0) + update(headerConfigurationProviding) + } + + private fun teardown(coordinatorLayout: StackScreenCoordinatorLayout) { + coordinatorLayout.removeView(appBarLayout) + appBarLayout = null + currentHeaderType = null + } + + private fun update(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) { + appBarLayout?.let { + applyTitle(it, headerConfigurationProviding.title) + } + } + + private fun applyTitle( + appBarLayout: StackScreenAppBarLayout, + title: String, + ) { + when (appBarLayout) { + is StackScreenAppBarLayout.Small -> { + appBarLayout.toolbar.title = title + } + is StackScreenAppBarLayout.Collapsing -> { + appBarLayout.toolbar.title = null + appBarLayout.collapsingToolbarLayout.title = title + } + } + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt new file mode 100644 index 0000000000..900107d009 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt @@ -0,0 +1,6 @@ +package com.swmansion.rnscreens.gamma.stack.screen.header.configuration + +internal interface StackScreenHeaderConfigurationProviding { + val headerType: StackScreenHeaderType + val title: String +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt new file mode 100644 index 0000000000..fbaa11873f --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt @@ -0,0 +1,7 @@ +package com.swmansion.rnscreens.gamma.stack.screen.header.configuration + +internal enum class StackScreenHeaderType { + SMALL, + MEDIUM, + LARGE, +} diff --git a/android/src/main/java/com/swmansion/rnscreens/utils/DimensionUtils.kt b/android/src/main/java/com/swmansion/rnscreens/utils/DimensionUtils.kt new file mode 100644 index 0000000000..7261578486 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/utils/DimensionUtils.kt @@ -0,0 +1,18 @@ +package com.swmansion.rnscreens.utils + +import android.content.Context +import android.util.TypedValue + +internal fun resolveDimensionAttr( + context: Context, + attrId: Int, +): Int { + val typedValue = TypedValue() + require(context.theme.resolveAttribute(attrId, typedValue, true)) { + "[RNScreens] Unable to resolve Material theme dimension." + } + return TypedValue.complexToDimensionPixelSize( + typedValue.data, + context.resources.displayMetrics, + ) +} From 67e51c3770c3e4f5956c8166430fca214185e647 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 11 Mar 2026 14:46:05 +0100 Subject: [PATCH 03/92] pass Context from host I'm not really sure which context should we use. Might want to revisit it later. --- .../swmansion/rnscreens/gamma/stack/host/StackContainer.kt | 4 ++-- .../rnscreens/gamma/stack/screen/StackScreenFragment.kt | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt index 3f1b1de8d6..0581800f21 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt @@ -16,7 +16,7 @@ import java.lang.ref.WeakReference @SuppressLint("ViewConstructor") // Only we construct this view, it is never inflated. internal class StackContainer( - context: Context, + private val context: Context, private val delegate: WeakReference, ) : FrameLayout(context), FragmentManager.OnBackStackChangedListener { @@ -194,7 +194,7 @@ internal class StackContainer( } private fun createFragmentForScreen(screen: StackScreen): StackScreenFragment = - StackScreenFragment(screen).also { + StackScreenFragment(context, screen).also { Log.d(TAG, "Created Fragment $it for screen ${screen.screenKey}") } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt index 87160eca31..4d4d661ee3 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt @@ -1,5 +1,6 @@ package com.swmansion.rnscreens.gamma.stack.screen +import android.content.Context import android.os.Bundle import android.view.Gravity import android.view.LayoutInflater @@ -7,8 +8,10 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.transition.Slide +import com.swmansion.rnscreens.gamma.stack.screen.header.StackScreenCoordinatorLayout internal class StackScreenFragment( + private val context: Context, internal val stackScreen: StackScreen, ) : Fragment() { private var screenLifecycleEventEmitter: StackScreenAppearanceEventsEmitter? = null @@ -43,7 +46,7 @@ internal class StackScreenFragment( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View = stackScreen + ): View = StackScreenCoordinatorLayout(context, stackScreen) override fun onViewCreated( view: View, From 8c3fb2cd7cf51aeacb287760a6a87d0d0579de11 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 11 Mar 2026 15:00:25 +0100 Subject: [PATCH 04/92] debug setup --- .../stack/screen/header/StackScreenCoordinatorLayout.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index 3a06cc5426..d5da1364c3 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -7,6 +7,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.appbar.AppBarLayout import com.swmansion.rnscreens.gamma.stack.screen.StackScreen import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding +import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType @SuppressLint("ViewConstructor") internal class StackScreenCoordinatorLayout( @@ -23,6 +24,14 @@ internal class StackScreenCoordinatorLayout( behavior = AppBarLayout.ScrollingViewBehavior() }, ) + + // TODO: debug-only + applyHeaderConfiguration( + object : StackScreenHeaderConfigurationProviding { + override val headerType = StackScreenHeaderType.SMALL + override val title = "Hello, World!" + }, + ) } internal fun applyHeaderConfiguration(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) = From 0938b0a47984faa66d96461fca84c32e0dad3dcb Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 11 Mar 2026 15:40:29 +0100 Subject: [PATCH 05/92] small header PoC TODO: handle layout of CoordinatorLayout, now it's added in a random place. --- .../rnscreens/gamma/stack/host/StackContainer.kt | 14 ++++++++++++++ .../rnscreens/gamma/stack/screen/StackScreen.kt | 6 ------ .../stack/screen/header/StackScreenAppBarLayout.kt | 9 +++++++++ .../screen/header/StackScreenCoordinatorLayout.kt | 4 ++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt index 0581800f21..193d9bf12f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt @@ -211,6 +211,11 @@ internal class StackContainer( check(fragmentManager.primaryNavigationFragment === fragments.last()) { "[RNScreens] Top fragment different from primary navigation fragment" } + + // TODO: debug only but this is necessary to layout CoordinatorLayout + post { + forceSubtreeMeasureAndLayoutPass() + } } /** @@ -251,6 +256,15 @@ internal class StackContainer( } } + private fun forceSubtreeMeasureAndLayoutPass() { + measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), + ) + + layout(left, top, right, bottom) + } + companion object { const val TAG = "StackContainer" } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt index 3290846a8a..dfaf3743ae 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt @@ -21,12 +21,6 @@ class StackScreen( ATTACHED, } - init { - // Needed when Transition API is in use to ensure that shadows do not disappear, - // views do not jump around the screen and whole sub-tree is animated as a whole. - isTransitionGroup = true - } - internal var isPreventNativeDismissEnabled: Boolean by Delegates.observable(false) { _, oldValue, newValue -> if (oldValue != newValue) { preventNativeDismissChangeObserver?.preventNativeDismissChanged(newValue) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt index 29b9c563fc..9591434f24 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.R import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.CollapsingToolbarLayout @@ -16,6 +17,14 @@ internal sealed class StackScreenAppBarLayout( ) : AppBarLayout(context) { abstract val toolbar: MaterialToolbar + init { + layoutParams = CoordinatorLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + isLiftOnScroll = true + // TODO: this won't work with nested header but there were some problems with lift on scroll + // without it when I was researching this. + fitsSystemWindows = true + } + internal class Small( context: Context, ) : StackScreenAppBarLayout(context) { diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index d5da1364c3..80ef6c3de8 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -17,6 +17,10 @@ internal class StackScreenCoordinatorLayout( private val headerCoordinator = StackScreenHeaderCoordinator(context) init { + // Needed when Transition API is in use to ensure that shadows do not disappear, + // views do not jump around the screen and whole sub-tree is animated as a whole. + isTransitionGroup = true + addView( stackScreen, LayoutParams(MATCH_PARENT, MATCH_PARENT).apply { From e6f7118000abaae2927d794c848a1ae7e28cedef Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 11 Mar 2026 16:00:34 +0100 Subject: [PATCH 06/92] add temporary SFT --- .../screen/header/StackScreenAppBarLayout.kt | 14 ++++- .../header/StackScreenCoordinatorLayout.kt | 2 +- .../single-feature-tests/stack-v5/index.ts | 2 + .../stack-v5/test-stack-header-modes.tsx | 51 +++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt index 9591434f24..f9e1733b25 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt @@ -7,6 +7,9 @@ import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.R import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED +import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL +import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP import com.google.android.material.appbar.CollapsingToolbarLayout import com.google.android.material.appbar.MaterialToolbar import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType @@ -31,7 +34,11 @@ internal sealed class StackScreenAppBarLayout( override val toolbar = MaterialToolbar(context).apply { elevation = 0f - layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) + layoutParams = + LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + // TODO: debug only for small header, must be moved to configuration + scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_SNAP + } } init { @@ -82,7 +89,10 @@ internal sealed class StackScreenAppBarLayout( LayoutParams( MATCH_PARENT, resolveDimensionAttr(context, sizeAttr), - ) + ).apply { + // TODO: debug only for medium/large header, must be moved to configuration + scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED or SCROLL_FLAG_SNAP + } addView(toolbar) } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index 80ef6c3de8..d55116bf95 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -32,7 +32,7 @@ internal class StackScreenCoordinatorLayout( // TODO: debug-only applyHeaderConfiguration( object : StackScreenHeaderConfigurationProviding { - override val headerType = StackScreenHeaderType.SMALL + override val headerType = StackScreenHeaderType.LARGE override val title = "Hello, World!" }, ) diff --git a/apps/src/tests/single-feature-tests/stack-v5/index.ts b/apps/src/tests/single-feature-tests/stack-v5/index.ts index 3880b04a8a..fa9878cb16 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/index.ts +++ b/apps/src/tests/single-feature-tests/stack-v5/index.ts @@ -2,11 +2,13 @@ import type { ScenarioGroup } from '../../shared/helpers'; import PreventNativeDismissSingleStack from './prevent-native-dismiss-single-stack'; import PreventNativeDismissNestedStack from './prevent-native-dismiss-nested-stack'; import AnimationAndroid from './test-animation-android'; +import TestStackHeaderModes from './test-stack-header-modes'; const scenarios = { PreventNativeDismissSingleStack, PreventNativeDismissNestedStack, AnimationAndroid, + TestStackHeaderModes, }; const StackScenarioGroup: ScenarioGroup = { diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx new file mode 100644 index 0000000000..a83063b29a --- /dev/null +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Scenario } from '../../shared/helpers'; +import { StackContainer } from '../../../shared/gamma/containers/stack'; +import { ScrollView } from 'react-native'; +import LongText from '../../../../src/shared/LongText'; +import { StackNavigationButtons } from '../../shared/components/stack-v5/StackNavigationButtons'; +import Colors from '../../../../src/shared/styling/Colors'; + +const SCENARIO: Scenario = { + name: 'Stack Header Modes', + key: 'test-stack-header-modes', + details: '[WIP] Tests different header modes.', + platforms: ['android'], + AppComponent: App, +}; + +export default SCENARIO; + +export function App() { + return ; +} + +function StackSetup() { + return ( + Screen(true), + options: {}, + }, + { + name: 'A', + Component: () => Screen(false), + options: {}, + }, + ]} + /> + ); +} + +function Screen(isHome: boolean) { + return ( + + + + + ); +} From 1d81436be279490a0f1f0c1661c971458ae28667 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 14:18:02 +0100 Subject: [PATCH 07/92] add ability to hide the header, refactor requesting layout --- .../gamma/stack/host/StackContainer.kt | 7 +--- .../header/StackScreenCoordinatorLayout.kt | 38 ++++++++++++++++--- .../header/StackScreenHeaderCoordinator.kt | 24 +++++++++++- ...StackScreenHeaderConfigurationProviding.kt | 1 + 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt index 193d9bf12f..79e2f0e5b6 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt @@ -211,11 +211,6 @@ internal class StackContainer( check(fragmentManager.primaryNavigationFragment === fragments.last()) { "[RNScreens] Top fragment different from primary navigation fragment" } - - // TODO: debug only but this is necessary to layout CoordinatorLayout - post { - forceSubtreeMeasureAndLayoutPass() - } } /** @@ -256,7 +251,7 @@ internal class StackContainer( } } - private fun forceSubtreeMeasureAndLayoutPass() { + internal fun forceSubtreeMeasureAndLayoutPass() { measure( MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index d55116bf95..352cc15c95 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -4,7 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.view.ViewGroup.LayoutParams.MATCH_PARENT import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.appbar.AppBarLayout +import com.swmansion.rnscreens.gamma.stack.host.StackContainer import com.swmansion.rnscreens.gamma.stack.screen.StackScreen import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType @@ -12,7 +12,7 @@ import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScre @SuppressLint("ViewConstructor") internal class StackScreenCoordinatorLayout( context: Context, - stackScreen: StackScreen, + internal val stackScreen: StackScreen, ) : CoordinatorLayout(context) { private val headerCoordinator = StackScreenHeaderCoordinator(context) @@ -23,10 +23,7 @@ internal class StackScreenCoordinatorLayout( addView( stackScreen, - LayoutParams(MATCH_PARENT, MATCH_PARENT).apply { - // TODO: when adding possibility to hide the header, this needs to be moved to coordinator - behavior = AppBarLayout.ScrollingViewBehavior() - }, + LayoutParams(MATCH_PARENT, MATCH_PARENT), ) // TODO: debug-only @@ -34,8 +31,37 @@ internal class StackScreenCoordinatorLayout( object : StackScreenHeaderConfigurationProviding { override val headerType = StackScreenHeaderType.LARGE override val title = "Hello, World!" + override val isHidden = false }, ) + + postDelayed({ + applyHeaderConfiguration( + object : StackScreenHeaderConfigurationProviding { + override val headerType = StackScreenHeaderType.LARGE + override val title = "Hello, World!" + override val isHidden = true + }, + ) + + postDelayed({ + applyHeaderConfiguration( + object : StackScreenHeaderConfigurationProviding { + override val headerType = StackScreenHeaderType.LARGE + override val title = "Hello, World!" + override val isHidden = false + }, + ) + }, 3000) + }, 3000) + } + + private fun stackContainerOrNull(): StackContainer? = this.parent as StackContainer? + + internal fun maybeRequestLayoutContainer() { + post { + stackContainerOrNull()?.forceSubtreeMeasureAndLayoutPass() + } } internal fun applyHeaderConfiguration(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) = diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt index b98de34278..b43b8f8105 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt @@ -2,7 +2,9 @@ package com.swmansion.rnscreens.gamma.stack.screen.header import android.content.Context import androidx.appcompat.view.ContextThemeWrapper +import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.R +import com.google.android.material.appbar.AppBarLayout import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType @@ -23,11 +25,15 @@ internal class StackScreenHeaderCoordinator( headerConfigurationProviding: StackScreenHeaderConfigurationProviding, ) { // TODO: handle hiding the header - if (appBarLayout == null || currentHeaderType == null || currentHeaderType != headerConfigurationProviding.headerType) { + if (headerConfigurationProviding.isHidden) { + teardown(coordinatorLayout) + } else if (appBarLayout == null || currentHeaderType == null || currentHeaderType != headerConfigurationProviding.headerType) { rebuild(coordinatorLayout, headerConfigurationProviding) } else { update(headerConfigurationProviding) } + + coordinatorLayout.maybeRequestLayoutContainer() } private fun rebuild( @@ -38,12 +44,14 @@ internal class StackScreenHeaderCoordinator( appBarLayout = StackScreenAppBarLayout.create(wrappedContext, headerConfigurationProviding.headerType) coordinatorLayout.addView(appBarLayout, 0) update(headerConfigurationProviding) + updateContentBehavior(coordinatorLayout, false) } private fun teardown(coordinatorLayout: StackScreenCoordinatorLayout) { coordinatorLayout.removeView(appBarLayout) appBarLayout = null currentHeaderType = null + updateContentBehavior(coordinatorLayout, true) } private fun update(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) { @@ -66,4 +74,18 @@ internal class StackScreenHeaderCoordinator( } } } + + private fun updateContentBehavior( + coordinatorLayout: StackScreenCoordinatorLayout, + isHidden: Boolean, + ) { + val stackScreen = coordinatorLayout.stackScreen + val params = stackScreen.layoutParams as CoordinatorLayout.LayoutParams + val needsBehavior = !isHidden && appBarLayout != null + val hasBehavior = params.behavior != null + if (needsBehavior != hasBehavior) { + params.behavior = if (needsBehavior) AppBarLayout.ScrollingViewBehavior() else null + stackScreen.layoutParams = params + } + } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt index 900107d009..249e38b6a2 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt @@ -3,4 +3,5 @@ package com.swmansion.rnscreens.gamma.stack.screen.header.configuration internal interface StackScreenHeaderConfigurationProviding { val headerType: StackScreenHeaderType val title: String + val isHidden: Boolean } From 9a65534b7f530881b35cdbfe55988749770f546c Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 14:49:37 +0100 Subject: [PATCH 08/92] refactor HeaderCoordinator, handle layout for transparent header I did not apply any changes to color - header won't be transparent but will have correct layout for transparent header. --- .../screen/header/StackScreenAppBarLayout.kt | 3 ++ .../header/StackScreenCoordinatorLayout.kt | 43 ++++++++------- .../header/StackScreenHeaderCoordinator.kt | 54 +++++++++---------- ...StackScreenHeaderConfigurationProviding.kt | 1 + 4 files changed, 52 insertions(+), 49 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt index f9e1733b25..da165c9372 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt @@ -23,6 +23,7 @@ internal sealed class StackScreenAppBarLayout( init { layoutParams = CoordinatorLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) isLiftOnScroll = true + // TODO: this won't work with nested header but there were some problems with lift on scroll // without it when I was researching this. fitsSystemWindows = true @@ -37,6 +38,7 @@ internal sealed class StackScreenAppBarLayout( layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { // TODO: debug only for small header, must be moved to configuration +// scrollFlags = SCROLL_FLAG_NO_SCROLL scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_SNAP } } @@ -92,6 +94,7 @@ internal sealed class StackScreenAppBarLayout( ).apply { // TODO: debug only for medium/large header, must be moved to configuration scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED or SCROLL_FLAG_SNAP +// scrollFlags = SCROLL_FLAG_NO_SCROLL } addView(toolbar) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index 352cc15c95..2e0af8407d 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -29,31 +29,34 @@ internal class StackScreenCoordinatorLayout( // TODO: debug-only applyHeaderConfiguration( object : StackScreenHeaderConfigurationProviding { - override val headerType = StackScreenHeaderType.LARGE + override val headerType = StackScreenHeaderType.SMALL override val title = "Hello, World!" override val isHidden = false + override val isTransparent = true }, ) - postDelayed({ - applyHeaderConfiguration( - object : StackScreenHeaderConfigurationProviding { - override val headerType = StackScreenHeaderType.LARGE - override val title = "Hello, World!" - override val isHidden = true - }, - ) - - postDelayed({ - applyHeaderConfiguration( - object : StackScreenHeaderConfigurationProviding { - override val headerType = StackScreenHeaderType.LARGE - override val title = "Hello, World!" - override val isHidden = false - }, - ) - }, 3000) - }, 3000) +// postDelayed({ +// applyHeaderConfiguration( +// object : StackScreenHeaderConfigurationProviding { +// override val headerType = StackScreenHeaderType.LARGE +// override val title = "Hello, World!" +// override val isHidden = true +// override val isTransparent = false +// }, +// ) +// +// postDelayed({ +// applyHeaderConfiguration( +// object : StackScreenHeaderConfigurationProviding { +// override val headerType = StackScreenHeaderType.LARGE +// override val title = "Hello, World!" +// override val isHidden = false +// override val isTransparent = false +// }, +// ) +// }, 3000) +// }, 3000) } private fun stackContainerOrNull(): StackContainer? = this.parent as StackContainer? diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt index b43b8f8105..eef3c8c324 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt @@ -22,41 +22,37 @@ internal class StackScreenHeaderCoordinator( internal fun applyHeaderConfiguration( coordinatorLayout: StackScreenCoordinatorLayout, - headerConfigurationProviding: StackScreenHeaderConfigurationProviding, + config: StackScreenHeaderConfigurationProviding, ) { - // TODO: handle hiding the header - if (headerConfigurationProviding.isHidden) { - teardown(coordinatorLayout) - } else if (appBarLayout == null || currentHeaderType == null || currentHeaderType != headerConfigurationProviding.headerType) { - rebuild(coordinatorLayout, headerConfigurationProviding) - } else { - update(headerConfigurationProviding) - } - + applyStructure(coordinatorLayout, config) + applyAppBarConfiguration(config) + applyContentBehavior(coordinatorLayout, config) coordinatorLayout.maybeRequestLayoutContainer() } - private fun rebuild( + private fun applyStructure( coordinatorLayout: StackScreenCoordinatorLayout, - headerConfigurationProviding: StackScreenHeaderConfigurationProviding, + config: StackScreenHeaderConfigurationProviding, ) { - teardown(coordinatorLayout) - appBarLayout = StackScreenAppBarLayout.create(wrappedContext, headerConfigurationProviding.headerType) - coordinatorLayout.addView(appBarLayout, 0) - update(headerConfigurationProviding) - updateContentBehavior(coordinatorLayout, false) - } + val desiredType = if (config.isHidden) null else config.headerType + + if (desiredType == currentHeaderType) return + + appBarLayout?.let { coordinatorLayout.removeView(it) } + appBarLayout = + desiredType?.let { + StackScreenAppBarLayout.create(wrappedContext, it).also { appBar -> + coordinatorLayout.addView(appBar, 0) + } + } - private fun teardown(coordinatorLayout: StackScreenCoordinatorLayout) { - coordinatorLayout.removeView(appBarLayout) - appBarLayout = null - currentHeaderType = null - updateContentBehavior(coordinatorLayout, true) + currentHeaderType = desiredType } - private fun update(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) { - appBarLayout?.let { - applyTitle(it, headerConfigurationProviding.title) + private fun applyAppBarConfiguration(config: StackScreenHeaderConfigurationProviding) { + appBarLayout?.let { appBar -> + applyTitle(appBar, config.title) + // ... } } @@ -75,13 +71,13 @@ internal class StackScreenHeaderCoordinator( } } - private fun updateContentBehavior( + private fun applyContentBehavior( coordinatorLayout: StackScreenCoordinatorLayout, - isHidden: Boolean, + config: StackScreenHeaderConfigurationProviding, ) { val stackScreen = coordinatorLayout.stackScreen val params = stackScreen.layoutParams as CoordinatorLayout.LayoutParams - val needsBehavior = !isHidden && appBarLayout != null + val needsBehavior = appBarLayout != null && !config.isTransparent && !config.isHidden val hasBehavior = params.behavior != null if (needsBehavior != hasBehavior) { params.behavior = if (needsBehavior) AppBarLayout.ScrollingViewBehavior() else null diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt index 249e38b6a2..32c2a1c73c 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt @@ -4,4 +4,5 @@ internal interface StackScreenHeaderConfigurationProviding { val headerType: StackScreenHeaderType val title: String val isHidden: Boolean + val isTransparent: Boolean } From e790e98cd5999f249c0e9e30ac990e42d5b7663f Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 16:46:22 +0100 Subject: [PATCH 09/92] add custom shadow node --- .../gamma/stack/screen/StackScreen.kt | 8 ++- .../screen/StackScreenShadowStateProxy.kt | 57 +++++++++++++++++++ .../stack/screen/StackScreenViewManager.kt | 13 +++++ .../header/StackScreenCoordinatorLayout.kt | 4 +- android/src/main/jni/rnscreens.h | 1 + .../RNSStackScreenComponentDescriptor.h | 43 ++++++++++++++ .../rnscreens/RNSStackScreenShadowNode.cpp | 15 +++++ .../rnscreens/RNSStackScreenShadowNode.h | 30 ++++++++++ .../rnscreens/RNSStackScreenState.cpp | 13 +++++ .../rnscreens/RNSStackScreenState.h | 42 ++++++++++++++ react-native.config.js | 3 +- .../gamma/stack/StackScreenNativeComponent.ts | 4 +- 12 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackScreenComponentDescriptor.h create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.cpp create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.h create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.cpp create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt index dfaf3743ae..2dda466712 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt @@ -50,6 +50,10 @@ class StackScreen( field = value } + private val shadowStateProxy = StackScreenShadowStateProxy() + + var stateWrapper by shadowStateProxy::stateWrapper + internal lateinit var eventEmitter: StackScreenEventEmitter /** @@ -83,7 +87,9 @@ class StackScreen( t: Int, r: Int, b: Int, - ) = Unit + ) { + shadowStateProxy.updateStateIfNeeded(l, t, r - l, b - t) + } override fun getAssociatedFragment(): Fragment? = this.findFragmentOrNull()?.also { diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt new file mode 100644 index 0000000000..5ce2ddd584 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt @@ -0,0 +1,57 @@ +package com.swmansion.rnscreens.gamma.stack.screen + +import com.facebook.react.bridge.WritableMap +import com.facebook.react.bridge.WritableNativeMap +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.StateWrapper +import kotlin.math.abs + +internal class StackScreenShadowStateProxy { + internal var stateWrapper: StateWrapper? = null + + private var lastXInDp: Float = 0f + private var lastYInDp: Float = 0f + private var lastWidthInDp: Float = 0f + private var lastHeightInDp: Float = 0f + + fun updateStateIfNeeded( + x: Int, + y: Int, + width: Int, + height: Int, + ) { + val xInDp: Float = PixelUtil.toDIPFromPixel(x.toFloat()) + val yInDp: Float = PixelUtil.toDIPFromPixel(y.toFloat()) + val widthInDp: Float = PixelUtil.toDIPFromPixel(width.toFloat()) + val heightInDp: Float = PixelUtil.toDIPFromPixel(height.toFloat()) + + // Check incoming state values. If they're already the correct value, return early to prevent + // infinite UpdateState/SetState loop. + if ( + abs(lastXInDp - xInDp) < DELTA && + abs(lastYInDp - yInDp) < DELTA && + abs(lastWidthInDp - widthInDp) < DELTA && + abs(lastHeightInDp - heightInDp) < DELTA + ) { + return + } + + lastXInDp = xInDp + lastYInDp = yInDp + lastWidthInDp = widthInDp + lastHeightInDp = heightInDp + + val map: WritableMap = + WritableNativeMap().apply { + putDouble("frameWidth", widthInDp.toDouble()) + putDouble("frameHeight", heightInDp.toDouble()) + putDouble("contentOffsetX", xInDp.toDouble()) + putDouble("contentOffsetY", yInDp.toDouble()) + } + stateWrapper?.updateState(map) + } + + companion object { + private const val DELTA = 0.9f + } +} \ No newline at end of file diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt index 44d75157de..af1f81942e 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt @@ -2,11 +2,15 @@ package com.swmansion.rnscreens.gamma.stack.screen import com.facebook.react.bridge.JSApplicationIllegalArgumentException import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.viewmanagers.RNSStackScreenManagerDelegate import com.facebook.react.viewmanagers.RNSStackScreenManagerInterface +import com.swmansion.rnscreens.BuildConfig +import com.swmansion.rnscreens.Screen import com.swmansion.rnscreens.gamma.helpers.makeEventRegistrationInfo import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidAppearEvent import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidDisappearEvent @@ -45,6 +49,15 @@ class StackScreenViewManager : makeEventRegistrationInfo(StackScreenNativeDismissPreventedEvent), ) + override fun updateState( + view: StackScreen, + props: ReactStylesDiffMap?, + stateWrapper: StateWrapper?, + ): Any? { + view.stateWrapper = stateWrapper + return super.updateState(view, props, stateWrapper) + } + override fun setActivityMode( view: StackScreen, value: String?, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index 2e0af8407d..aea71b4ba1 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -29,10 +29,10 @@ internal class StackScreenCoordinatorLayout( // TODO: debug-only applyHeaderConfiguration( object : StackScreenHeaderConfigurationProviding { - override val headerType = StackScreenHeaderType.SMALL + override val headerType = StackScreenHeaderType.LARGE override val title = "Hello, World!" override val isHidden = false - override val isTransparent = true + override val isTransparent = false }, ) diff --git a/android/src/main/jni/rnscreens.h b/android/src/main/jni/rnscreens.h index ab83c59fca..a3d5690d39 100644 --- a/android/src/main/jni/rnscreens.h +++ b/android/src/main/jni/rnscreens.h @@ -23,6 +23,7 @@ #include #include #include +#include namespace facebook { namespace react { diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenComponentDescriptor.h new file mode 100644 index 0000000000..e821d0ef81 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenComponentDescriptor.h @@ -0,0 +1,43 @@ +#pragma once + +#ifdef ANDROID +#include +#endif // ANDROID + +#include +#include +#include "RNSStackScreenShadowNode.h" + +namespace facebook::react { + +class RNSStackScreenComponentDescriptor final + : public ConcreteComponentDescriptor { + public: + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + + void adopt(ShadowNode &shadowNode) const override { + react_native_assert(dynamic_cast(&shadowNode)); + +#ifdef ANDROID + auto &screenShadowNode = + static_cast(shadowNode); + react_native_assert( + dynamic_cast(&screenShadowNode)); + auto &layoutableShadowNode = + static_cast(screenShadowNode); + + auto state = + std::static_pointer_cast( + shadowNode.getState()); + auto stateData = state->getData(); + + if (stateData.frameSize.width != 0 && stateData.frameSize.height != 0) { + layoutableShadowNode.setSize( + Size{stateData.frameSize.width, stateData.frameSize.height}); + } +#endif // ANDROID + ConcreteComponentDescriptor::adopt(shadowNode); + } +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.cpp new file mode 100644 index 0000000000..bf9c34141a --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.cpp @@ -0,0 +1,15 @@ +#include "RNSStackScreenShadowNode.h" + +namespace facebook::react { + +extern const char RNSStackScreenComponentName[] = "RNSStackScreen"; + +#ifdef ANDROID +Point RNSStackScreenShadowNode::getContentOriginOffset( + bool /*includeTransform*/) const { + auto stateData = getStateData(); + return stateData.contentOffset; +} +#endif // ANDROID + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.h new file mode 100644 index 0000000000..8e64c48ea3 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include +#include +#include "RNSStackScreenState.h" + +namespace facebook::react { + +JSI_EXPORT extern const char RNSStackScreenComponentName[]; + +class JSI_EXPORT RNSStackScreenShadowNode final + : public ConcreteViewShadowNode< + RNSStackScreenComponentName, + RNSStackScreenProps, + RNSStackScreenEventEmitter, + RNSStackScreenState> { + public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + using StateData = ConcreteViewShadowNode::ConcreteStateData; + +#pragma mark - ShadowNode overrides + +#ifdef ANDROID + Point getContentOriginOffset(bool includeTransform) const override; +#endif // ANDROID +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.cpp new file mode 100644 index 0000000000..6ddf9b29c6 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.cpp @@ -0,0 +1,13 @@ +#include "RNSStackScreenState.h" + +namespace facebook::react { + +#ifdef ANDROID +folly::dynamic RNSStackScreenState::getDynamic() const { + return folly::dynamic::object("frameWidth", frameSize.width)( + "frameHeight", frameSize.height)("contentOffsetX", contentOffset.x)( + "contentOffsetY", contentOffset.y); +} +#endif // ANDROID + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h new file mode 100644 index 0000000000..59ee1a4ad2 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h @@ -0,0 +1,42 @@ +#pragma once + +#ifdef ANDROID +#include +#include +#include +#include +#include +#endif // ANDROID + +namespace facebook::react { + +class JSI_EXPORT RNSStackScreenState final { + public: + using Shared = std::shared_ptr; + + RNSStackScreenState() {}; + +#ifdef ANDROID + RNSStackScreenState( + RNSStackScreenState const &previousState, + folly::dynamic data) + : frameSize( + Size{ + (Float)data["frameWidth"].getDouble(), + (Float)data["frameHeight"].getDouble()}), + contentOffset( + Point{ + (Float)data["contentOffsetX"].getDouble(), + (Float)data["contentOffsetY"].getDouble()}) {}; + + Size frameSize{}; + Point contentOffset; + + folly::dynamic getDynamic() const; + MapBuffer getMapBuffer() const { + return MapBufferBuilder::EMPTY(); + }; +#endif // ANDROID +}; + +} // namespace facebook::react diff --git a/react-native.config.js b/react-native.config.js index 7815576b13..6bf41244e4 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -15,7 +15,8 @@ module.exports = { "RNSScreenContentWrapperComponentDescriptor", 'RNSModalScreenComponentDescriptor', 'RNSTabsHostComponentDescriptor', - 'RNSSafeAreaViewComponentDescriptor' + 'RNSSafeAreaViewComponentDescriptor', + 'RNSStackScreenComponentDescriptor' ], cmakeListsPath: "../android/src/main/jni/CMakeLists.txt" }, diff --git a/src/fabric/gamma/stack/StackScreenNativeComponent.ts b/src/fabric/gamma/stack/StackScreenNativeComponent.ts index 64e87f3c81..06cd3ec369 100644 --- a/src/fabric/gamma/stack/StackScreenNativeComponent.ts +++ b/src/fabric/gamma/stack/StackScreenNativeComponent.ts @@ -35,4 +35,6 @@ export interface NativeProps extends ViewProps { preventNativeDismiss?: CT.WithDefault; } -export default codegenNativeComponent('RNSStackScreen', {}); +export default codegenNativeComponent('RNSStackScreen', { + interfaceOnly: true, +}); From 5ad530d54dfbd28f02ba5c83a009aae2d99d07d5 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 16:56:02 +0100 Subject: [PATCH 10/92] add wrapper to match Yoga layout --- .../stack/screen/header/StackScreenCoordinatorLayout.kt | 5 ++++- .../stack/screen/header/StackScreenHeaderCoordinator.kt | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index aea71b4ba1..ad875ffbd9 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -3,6 +3,7 @@ package com.swmansion.rnscreens.gamma.stack.screen.header import android.annotation.SuppressLint import android.content.Context import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.FrameLayout import androidx.coordinatorlayout.widget.CoordinatorLayout import com.swmansion.rnscreens.gamma.stack.host.StackContainer import com.swmansion.rnscreens.gamma.stack.screen.StackScreen @@ -15,14 +16,16 @@ internal class StackScreenCoordinatorLayout( internal val stackScreen: StackScreen, ) : CoordinatorLayout(context) { private val headerCoordinator = StackScreenHeaderCoordinator(context) + internal var stackScreenWrapper: FrameLayout init { // Needed when Transition API is in use to ensure that shadows do not disappear, // views do not jump around the screen and whole sub-tree is animated as a whole. isTransitionGroup = true + stackScreenWrapper = FrameLayout(context).apply { addView(stackScreen) } addView( - stackScreen, + stackScreenWrapper, LayoutParams(MATCH_PARENT, MATCH_PARENT), ) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt index eef3c8c324..213aff7c08 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt @@ -75,13 +75,13 @@ internal class StackScreenHeaderCoordinator( coordinatorLayout: StackScreenCoordinatorLayout, config: StackScreenHeaderConfigurationProviding, ) { - val stackScreen = coordinatorLayout.stackScreen - val params = stackScreen.layoutParams as CoordinatorLayout.LayoutParams + val stackScreenWrapper = coordinatorLayout.stackScreenWrapper + val params = stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams val needsBehavior = appBarLayout != null && !config.isTransparent && !config.isHidden val hasBehavior = params.behavior != null if (needsBehavior != hasBehavior) { params.behavior = if (needsBehavior) AppBarLayout.ScrollingViewBehavior() else null - stackScreen.layoutParams = params + stackScreenWrapper.layoutParams = params } } } From 648338a64ad07a8e11e3e8ca2e28b8a8032f7b45 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 17:19:27 +0100 Subject: [PATCH 11/92] subclass ScrollingViewBehavior to synchronize content origin offset --- .../gamma/stack/screen/StackScreen.kt | 9 ++++++++- .../screen/StackScreenShadowStateProxy.kt | 18 ++++++++++-------- .../header/StackScreenCoordinatorLayout.kt | 5 ++++- .../header/StackScreenHeaderCoordinator.kt | 8 +++++++- .../StackScreenScrollingViewBehavior.kt | 19 +++++++++++++++++++ 5 files changed, 48 insertions(+), 11 deletions(-) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt index 2dda466712..91fba70a19 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt @@ -54,6 +54,13 @@ class StackScreen( var stateWrapper by shadowStateProxy::stateWrapper + fun updateStateIfNeeded( + x: Int? = null, + y: Int? = null, + width: Int? = null, + height: Int? = null, + ) = shadowStateProxy.updateStateIfNeeded(x, y, width, height) + internal lateinit var eventEmitter: StackScreenEventEmitter /** @@ -88,7 +95,7 @@ class StackScreen( r: Int, b: Int, ) { - shadowStateProxy.updateStateIfNeeded(l, t, r - l, b - t) + shadowStateProxy.updateStateIfNeeded(width = r - l, height = b - t) } override fun getAssociatedFragment(): Fragment? = diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt index 5ce2ddd584..34bda01c43 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt @@ -15,15 +15,17 @@ internal class StackScreenShadowStateProxy { private var lastHeightInDp: Float = 0f fun updateStateIfNeeded( - x: Int, - y: Int, - width: Int, - height: Int, + x: Int? = null, + y: Int? = null, + width: Int? = null, + height: Int? = null, ) { - val xInDp: Float = PixelUtil.toDIPFromPixel(x.toFloat()) - val yInDp: Float = PixelUtil.toDIPFromPixel(y.toFloat()) - val widthInDp: Float = PixelUtil.toDIPFromPixel(width.toFloat()) - val heightInDp: Float = PixelUtil.toDIPFromPixel(height.toFloat()) + val xInDp: Float = x?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastXInDp + val yInDp: Float = y?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastYInDp + val widthInDp: Float = + width?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastWidthInDp + val heightInDp: Float = + height?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastHeightInDp // Check incoming state values. If they're already the correct value, return early to prevent // infinite UpdateState/SetState loop. diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index ad875ffbd9..72e49c6363 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -15,7 +15,10 @@ internal class StackScreenCoordinatorLayout( context: Context, internal val stackScreen: StackScreen, ) : CoordinatorLayout(context) { - private val headerCoordinator = StackScreenHeaderCoordinator(context) + private val headerCoordinator = StackScreenHeaderCoordinator(context) { headerHeight -> + stackScreen.updateStateIfNeeded(y = headerHeight) + } + internal var stackScreenWrapper: FrameLayout init { diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt index 213aff7c08..c34855ee1f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt @@ -10,6 +10,7 @@ import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScre internal class StackScreenHeaderCoordinator( context: Context, + private val onHeaderHeightChanged: (headerHeight: Int) -> Unit, ) { private var appBarLayout: StackScreenAppBarLayout? = null private var currentHeaderType: StackScreenHeaderType? = null @@ -80,7 +81,12 @@ internal class StackScreenHeaderCoordinator( val needsBehavior = appBarLayout != null && !config.isTransparent && !config.isHidden val hasBehavior = params.behavior != null if (needsBehavior != hasBehavior) { - params.behavior = if (needsBehavior) AppBarLayout.ScrollingViewBehavior() else null + params.behavior = if (needsBehavior) { + StackScreenScrollingViewBehavior(onHeaderHeightChanged) + } else { + onHeaderHeightChanged(0) + null + } stackScreenWrapper.layoutParams = params } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt new file mode 100644 index 0000000000..6d1ce6e14c --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt @@ -0,0 +1,19 @@ +package com.swmansion.rnscreens.gamma.stack.screen.header + +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.appbar.AppBarLayout + +internal class StackScreenScrollingViewBehavior( + private val onContentOffsetChanged: (headerHeight: Int) -> Unit, +) : AppBarLayout.ScrollingViewBehavior() { + override fun onDependentViewChanged( + parent: CoordinatorLayout, + child: View, + dependency: View, + ): Boolean { + val result = super.onDependentViewChanged(parent, child, dependency) + onContentOffsetChanged(child.top) + return result + } +} \ No newline at end of file From 37a4aef9e0e8234ac9e85d2ce787e4d383133224 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 17:34:57 +0100 Subject: [PATCH 12/92] add Pressable to SFT --- .../stack-v5/test-stack-header-modes.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx index a83063b29a..06a6f724da 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { Scenario } from '../../shared/helpers'; import { StackContainer } from '../../../shared/gamma/containers/stack'; -import { ScrollView } from 'react-native'; +import { ScrollView, Text, View } from 'react-native'; import LongText from '../../../../src/shared/LongText'; import { StackNavigationButtons } from '../../shared/components/stack-v5/StackNavigationButtons'; import Colors from '../../../../src/shared/styling/Colors'; +import PressableWithFeedback from '../../../../src/shared/PressableWithFeedback'; const SCENARIO: Scenario = { name: 'Stack Header Modes', @@ -44,7 +45,18 @@ function Screen(isHome: boolean) { - + + + + + Pressable + + ); From d7b0c7bb65d74ca1e12d2c9706f871749067c750 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 17:35:21 +0100 Subject: [PATCH 13/92] format android --- .../stack/screen/StackScreenShadowStateProxy.kt | 2 +- .../gamma/stack/screen/StackScreenViewManager.kt | 2 -- .../screen/header/StackScreenCoordinatorLayout.kt | 7 ++++--- .../screen/header/StackScreenHeaderCoordinator.kt | 14 +++++++------- .../header/StackScreenScrollingViewBehavior.kt | 2 +- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt index 34bda01c43..6a40cf3104 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt @@ -56,4 +56,4 @@ internal class StackScreenShadowStateProxy { companion object { private const val DELTA = 0.9f } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt index af1f81942e..b1a0bdfd9e 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt @@ -9,8 +9,6 @@ import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.viewmanagers.RNSStackScreenManagerDelegate import com.facebook.react.viewmanagers.RNSStackScreenManagerInterface -import com.swmansion.rnscreens.BuildConfig -import com.swmansion.rnscreens.Screen import com.swmansion.rnscreens.gamma.helpers.makeEventRegistrationInfo import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidAppearEvent import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidDisappearEvent diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index 72e49c6363..8935539237 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -15,9 +15,10 @@ internal class StackScreenCoordinatorLayout( context: Context, internal val stackScreen: StackScreen, ) : CoordinatorLayout(context) { - private val headerCoordinator = StackScreenHeaderCoordinator(context) { headerHeight -> - stackScreen.updateStateIfNeeded(y = headerHeight) - } + private val headerCoordinator = + StackScreenHeaderCoordinator(context) { headerHeight -> + stackScreen.updateStateIfNeeded(y = headerHeight) + } internal var stackScreenWrapper: FrameLayout diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt index c34855ee1f..b9e4e331c3 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.appcompat.view.ContextThemeWrapper import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.R -import com.google.android.material.appbar.AppBarLayout import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType @@ -81,12 +80,13 @@ internal class StackScreenHeaderCoordinator( val needsBehavior = appBarLayout != null && !config.isTransparent && !config.isHidden val hasBehavior = params.behavior != null if (needsBehavior != hasBehavior) { - params.behavior = if (needsBehavior) { - StackScreenScrollingViewBehavior(onHeaderHeightChanged) - } else { - onHeaderHeightChanged(0) - null - } + params.behavior = + if (needsBehavior) { + StackScreenScrollingViewBehavior(onHeaderHeightChanged) + } else { + onHeaderHeightChanged(0) + null + } stackScreenWrapper.layoutParams = params } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt index 6d1ce6e14c..80c9083cc7 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt @@ -16,4 +16,4 @@ internal class StackScreenScrollingViewBehavior( onContentOffsetChanged(child.top) return result } -} \ No newline at end of file +} From eb3d6086374f05478868547f327306719b4ea266 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 17:49:57 +0100 Subject: [PATCH 14/92] add comments --- .../stack/screen/header/StackScreenAppBarLayout.kt | 5 ++++- .../screen/header/StackScreenCoordinatorLayout.kt | 11 +++++++++-- .../screen/header/StackScreenHeaderCoordinator.kt | 3 ++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt index da165c9372..cd9d1b3200 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt @@ -22,6 +22,9 @@ internal sealed class StackScreenAppBarLayout( init { layoutParams = CoordinatorLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + + // TODO: this should be exposed in the future via prop. Also, it might not work correctly + // until we set liftOnScrollView manually. isLiftOnScroll = true // TODO: this won't work with nested header but there were some problems with lift on scroll @@ -38,8 +41,8 @@ internal sealed class StackScreenAppBarLayout( layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { // TODO: debug only for small header, must be moved to configuration -// scrollFlags = SCROLL_FLAG_NO_SCROLL scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_SNAP +// scrollFlags = SCROLL_FLAG_NO_SCROLL } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index 8935539237..03572027e7 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -24,16 +24,21 @@ internal class StackScreenCoordinatorLayout( init { // Needed when Transition API is in use to ensure that shadows do not disappear, - // views do not jump around the screen and whole sub-tree is animated as a whole. + // views do not jump around the screen and whole subtree is animated as a whole. isTransitionGroup = true + // Due to how we're synchronizing native & Yoga layout (via contentOriginOffset on + // StackScreen), we can't use StackScreen directly as a child of CoordinatorLayout because + // SurfaceMountingManager will override Y offset (that depends on the header height) with + // Y=0. If we wrap StackScreen in another view, as Y is relative to parent view, value set + // by Yoga will be correct. stackScreenWrapper = FrameLayout(context).apply { addView(stackScreen) } addView( stackScreenWrapper, LayoutParams(MATCH_PARENT, MATCH_PARENT), ) - // TODO: debug-only + // TODO: debug-only, this will be sent in reaction to information from "HeaderConfig" component. applyHeaderConfiguration( object : StackScreenHeaderConfigurationProviding { override val headerType = StackScreenHeaderType.LARGE @@ -43,6 +48,7 @@ internal class StackScreenCoordinatorLayout( }, ) + // TODO: debug only, until we expose props via JS // postDelayed({ // applyHeaderConfiguration( // object : StackScreenHeaderConfigurationProviding { @@ -68,6 +74,7 @@ internal class StackScreenCoordinatorLayout( private fun stackContainerOrNull(): StackContainer? = this.parent as StackContainer? + // TODO: do we need to rely on parent here? internal fun maybeRequestLayoutContainer() { post { stackContainerOrNull()?.forceSubtreeMeasureAndLayoutPass() diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt index b9e4e331c3..27bf662605 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt @@ -52,7 +52,7 @@ internal class StackScreenHeaderCoordinator( private fun applyAppBarConfiguration(config: StackScreenHeaderConfigurationProviding) { appBarLayout?.let { appBar -> applyTitle(appBar, config.title) - // ... + // TODO: other app bar configuration... } } @@ -60,6 +60,7 @@ internal class StackScreenHeaderCoordinator( appBarLayout: StackScreenAppBarLayout, title: String, ) { + // TODO: diffing mechanism? when (appBarLayout) { is StackScreenAppBarLayout.Small -> { appBarLayout.toolbar.title = title From 9f6c97cc3ac41816fe426b2853e0dab5687cbe26 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 18:12:42 +0100 Subject: [PATCH 15/92] fix build on iOS, add information to lift on scroll comment --- .../gamma/stack/screen/header/StackScreenAppBarLayout.kt | 8 +++++--- .../renderer/components/rnscreens/RNSStackScreenState.h | 2 ++ ios/gamma/stack/screen/RNSStackScreenComponentView.mm | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt index cd9d1b3200..2353577678 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt @@ -8,6 +8,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.R import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED +import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP import com.google.android.material.appbar.CollapsingToolbarLayout @@ -24,7 +25,8 @@ internal sealed class StackScreenAppBarLayout( layoutParams = CoordinatorLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) // TODO: this should be exposed in the future via prop. Also, it might not work correctly - // until we set liftOnScrollView manually. + // until we set liftOnScrollView manually. Also, we should disable it in transparent + // mode or set elevation higher. isLiftOnScroll = true // TODO: this won't work with nested header but there were some problems with lift on scroll @@ -41,8 +43,8 @@ internal sealed class StackScreenAppBarLayout( layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { // TODO: debug only for small header, must be moved to configuration - scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_SNAP -// scrollFlags = SCROLL_FLAG_NO_SCROLL +// scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_SNAP + scrollFlags = SCROLL_FLAG_NO_SCROLL } } diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h index 59ee1a4ad2..7404471c4d 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h @@ -1,5 +1,7 @@ #pragma once +#include + #ifdef ANDROID #include #include diff --git a/ios/gamma/stack/screen/RNSStackScreenComponentView.mm b/ios/gamma/stack/screen/RNSStackScreenComponentView.mm index f2689ddfa3..cc9d98f4be 100644 --- a/ios/gamma/stack/screen/RNSStackScreenComponentView.mm +++ b/ios/gamma/stack/screen/RNSStackScreenComponentView.mm @@ -5,6 +5,7 @@ #import #import #import +#import #import "RNSConversions-Stack.h" #import "RNSStackHostComponentView.h" From abf2e321640a2b5985d93511dd276050b19ad9c4 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 18:33:25 +0100 Subject: [PATCH 16/92] unify naming --- .../stack/screen/header/StackScreenScrollingViewBehavior.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt index 80c9083cc7..b0accd1f16 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt @@ -5,7 +5,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.appbar.AppBarLayout internal class StackScreenScrollingViewBehavior( - private val onContentOffsetChanged: (headerHeight: Int) -> Unit, + private val onHeaderHeightChanged: (headerHeight: Int) -> Unit, ) : AppBarLayout.ScrollingViewBehavior() { override fun onDependentViewChanged( parent: CoordinatorLayout, @@ -13,7 +13,7 @@ internal class StackScreenScrollingViewBehavior( dependency: View, ): Boolean { val result = super.onDependentViewChanged(parent, child, dependency) - onContentOffsetChanged(child.top) + onHeaderHeightChanged(child.top) return result } } From 6fdd5bf14f665a00d0e3f9fea3e8cb10da2037a0 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 16 Mar 2026 15:06:14 +0100 Subject: [PATCH 17/92] use requireContext() instead of passing it down to fragment --- .../swmansion/rnscreens/gamma/stack/host/StackContainer.kt | 4 ++-- .../rnscreens/gamma/stack/screen/StackScreenFragment.kt | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt index 79e2f0e5b6..eae017288d 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt @@ -16,7 +16,7 @@ import java.lang.ref.WeakReference @SuppressLint("ViewConstructor") // Only we construct this view, it is never inflated. internal class StackContainer( - private val context: Context, + context: Context, private val delegate: WeakReference, ) : FrameLayout(context), FragmentManager.OnBackStackChangedListener { @@ -194,7 +194,7 @@ internal class StackContainer( } private fun createFragmentForScreen(screen: StackScreen): StackScreenFragment = - StackScreenFragment(context, screen).also { + StackScreenFragment(screen).also { Log.d(TAG, "Created Fragment $it for screen ${screen.screenKey}") } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt index 4d4d661ee3..21fd19deee 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt @@ -11,7 +11,6 @@ import androidx.transition.Slide import com.swmansion.rnscreens.gamma.stack.screen.header.StackScreenCoordinatorLayout internal class StackScreenFragment( - private val context: Context, internal val stackScreen: StackScreen, ) : Fragment() { private var screenLifecycleEventEmitter: StackScreenAppearanceEventsEmitter? = null @@ -46,7 +45,7 @@ internal class StackScreenFragment( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View = StackScreenCoordinatorLayout(context, stackScreen) + ): View = StackScreenCoordinatorLayout(requireContext(), stackScreen) override fun onViewCreated( view: View, From e4ac2f1a03288bc88f24e86488fdd44e4374dae9 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 16 Mar 2026 15:08:55 +0100 Subject: [PATCH 18/92] add comment informing about potential crash --- .../gamma/stack/screen/header/StackScreenCoordinatorLayout.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index 03572027e7..810f10af34 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -72,6 +72,9 @@ internal class StackScreenCoordinatorLayout( // }, 3000) } + /** + * Will crash in case parent is not StackContainer. + */ private fun stackContainerOrNull(): StackContainer? = this.parent as StackContainer? // TODO: do we need to rely on parent here? From 05afaf78e31a553d7fde78de4da697a813a65897 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 16 Mar 2026 15:14:13 +0100 Subject: [PATCH 19/92] format android --- .../rnscreens/gamma/stack/screen/StackScreenFragment.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt index 21fd19deee..922efbfe51 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt @@ -1,6 +1,5 @@ package com.swmansion.rnscreens.gamma.stack.screen -import android.content.Context import android.os.Bundle import android.view.Gravity import android.view.LayoutInflater From 1536e9ca5676eca2462779737ae8750a187e4aff Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 16 Mar 2026 16:23:57 +0100 Subject: [PATCH 20/92] apply suggestion from code review --- .../react/renderer/components/rnscreens/RNSStackScreenState.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h index 7404471c4d..b8a5dd12a3 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h @@ -32,7 +32,7 @@ class JSI_EXPORT RNSStackScreenState final { (Float)data["contentOffsetY"].getDouble()}) {}; Size frameSize{}; - Point contentOffset; + Point contentOffset{}; folly::dynamic getDynamic() const; MapBuffer getMapBuffer() const { From 5f905ff72afbb199b49a8504ec3960eb336603a6 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 18 Mar 2026 13:42:15 +0100 Subject: [PATCH 21/92] flatten native package hierarchy, drop Screen infix --- .../StackHeaderAppBarLayout.kt} | 28 +-- .../stack/header/StackHeaderCoordinator.kt | 162 ++++++++++++++++++ .../StackHeaderCoordinatorLayout.kt} | 16 +- .../StackHeaderScrollingViewBehavior.kt} | 4 +- .../StackHeaderConfigurationProviding.kt | 8 + .../header/configuration/StackHeaderType.kt | 7 + .../gamma/stack/screen/StackScreenFragment.kt | 4 +- .../header/StackScreenHeaderCoordinator.kt | 94 ---------- ...StackScreenHeaderConfigurationProviding.kt | 8 - .../configuration/StackScreenHeaderType.kt | 7 - 10 files changed, 203 insertions(+), 135 deletions(-) rename android/src/main/java/com/swmansion/rnscreens/gamma/stack/{screen/header/StackScreenAppBarLayout.kt => header/StackHeaderAppBarLayout.kt} (84%) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt rename android/src/main/java/com/swmansion/rnscreens/gamma/stack/{screen/header/StackScreenCoordinatorLayout.kt => header/StackHeaderCoordinatorLayout.kt} (85%) rename android/src/main/java/com/swmansion/rnscreens/gamma/stack/{screen/header/StackScreenScrollingViewBehavior.kt => header/StackHeaderScrollingViewBehavior.kt} (83%) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderType.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt similarity index 84% rename from android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt rename to android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt index 2353577678..bee7bf5102 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt @@ -1,4 +1,4 @@ -package com.swmansion.rnscreens.gamma.stack.screen.header +package com.swmansion.rnscreens.gamma.stack.header import android.annotation.SuppressLint import android.content.Context @@ -13,10 +13,10 @@ import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP import com.google.android.material.appbar.CollapsingToolbarLayout import com.google.android.material.appbar.MaterialToolbar -import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType +import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderType import com.swmansion.rnscreens.utils.resolveDimensionAttr -internal sealed class StackScreenAppBarLayout( +internal sealed class StackHeaderAppBarLayout( context: Context, ) : AppBarLayout(context) { abstract val toolbar: MaterialToolbar @@ -36,7 +36,7 @@ internal sealed class StackScreenAppBarLayout( internal class Small( context: Context, - ) : StackScreenAppBarLayout(context) { + ) : StackHeaderAppBarLayout(context) { override val toolbar = MaterialToolbar(context).apply { elevation = 0f @@ -56,12 +56,12 @@ internal sealed class StackScreenAppBarLayout( @SuppressLint("ViewConstructor") internal class Collapsing( context: Context, - val type: StackScreenHeaderType, - ) : StackScreenAppBarLayout(context) { + val type: StackHeaderType, + ) : StackHeaderAppBarLayout(context) { init { require( - type == StackScreenHeaderType.MEDIUM || - type == StackScreenHeaderType.LARGE, + type == StackHeaderType.MEDIUM || + type == StackHeaderType.LARGE, ) { "[RNScreens] Collapsing StackScreenAppBarLayout must be MEDIUM or LARGE type." } @@ -84,9 +84,9 @@ internal sealed class StackScreenAppBarLayout( run { val (styleAttr, sizeAttr) = when (type) { - StackScreenHeaderType.MEDIUM -> + StackHeaderType.MEDIUM -> Pair(R.attr.collapsingToolbarLayoutMediumStyle, R.attr.collapsingToolbarLayoutMediumSize) - StackScreenHeaderType.LARGE -> + StackHeaderType.LARGE -> Pair(R.attr.collapsingToolbarLayoutLargeStyle, R.attr.collapsingToolbarLayoutLargeSize) else -> error("[RNScreens] Invalid header mode.") } @@ -113,11 +113,11 @@ internal sealed class StackScreenAppBarLayout( companion object { fun create( context: Context, - type: StackScreenHeaderType, - ): StackScreenAppBarLayout = + type: StackHeaderType, + ): StackHeaderAppBarLayout = when (type) { - StackScreenHeaderType.SMALL -> Small(context) - StackScreenHeaderType.MEDIUM, StackScreenHeaderType.LARGE -> Collapsing(context, type) + StackHeaderType.SMALL -> Small(context) + StackHeaderType.MEDIUM, StackHeaderType.LARGE -> Collapsing(context, type) } } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt new file mode 100644 index 0000000000..9d7b7619e9 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -0,0 +1,162 @@ +package com.swmansion.rnscreens.gamma.stack.header + +import android.content.Context +import android.graphics.Color +import android.view.Gravity +import android.view.MenuItem +import android.view.View +import androidx.appcompat.view.ContextThemeWrapper +import androidx.appcompat.widget.Toolbar +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.R +import com.google.android.material.appbar.MaterialToolbar +import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationProviding +import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderType + +internal class StackHeaderCoordinator( + context: Context, + private val onHeaderHeightChanged: (headerHeight: Int) -> Unit, +) { + private var appBarLayout: StackHeaderAppBarLayout? = null + private var currentHeaderType: StackHeaderType? = null + + private val wrappedContext = + ContextThemeWrapper( + context, + R.style.Theme_Material3_DayNight_NoActionBar, + ) + + internal fun applyHeaderConfiguration( + coordinatorLayout: StackHeaderCoordinatorLayout, + config: StackHeaderConfigurationProviding, + ) { + applyStructure(coordinatorLayout, config) + applyAppBarConfiguration(config, coordinatorLayout) + applyContentBehavior(coordinatorLayout, config) + coordinatorLayout.maybeRequestLayoutContainer() + } + + private fun applyStructure( + coordinatorLayout: StackHeaderCoordinatorLayout, + config: StackHeaderConfigurationProviding, + ) { + val desiredType = if (config.isHidden) null else config.headerType + + if (desiredType == currentHeaderType) return + + appBarLayout?.let { coordinatorLayout.removeView(it) } + appBarLayout = + desiredType?.let { + StackHeaderAppBarLayout.create(wrappedContext, it).also { appBar -> + coordinatorLayout.addView(appBar, 0) + } + } + + currentHeaderType = desiredType + } + + private fun applyAppBarConfiguration(config: StackHeaderConfigurationProviding, coordinatorLayout: StackHeaderCoordinatorLayout) { + appBarLayout?.let { appBar -> + applyTitle(appBar, config.title) + // TODO: other app bar configuration... + applyDebugToolbarItems(appBar.toolbar, coordinatorLayout) + } + } + + // region DEBUG — remove later + private var debugItemsApplied = false + + private fun applyDebugToolbarItems(toolbar: MaterialToolbar, coordinatorLayout: StackHeaderCoordinatorLayout) { + if (debugItemsApplied) return + debugItemsApplied = true + + // Navigation icon (back arrow) + toolbar.setNavigationIcon(androidx.appcompat.R.drawable.abc_ic_ab_back_material) + toolbar.setNavigationOnClickListener { /* no-op for now */ } + + val density = toolbar.context.resources.displayMetrics.density + + // Custom views and menu items added with delay to test runtime insertion + toolbar.postDelayed({ + // Custom view on the left side (red box, 48x48dp) + val customView = View(toolbar.context).apply { + setBackgroundColor(Color.RED) + layoutParams = Toolbar.LayoutParams( + (48 * density).toInt(), + (48 * density).toInt(), + Gravity.START, + ) + } + toolbar.addView(customView, 0) + coordinatorLayout.maybeRequestLayoutContainer() + }, 3000) + + toolbar.postDelayed({ + // Custom view on the right side (green box, 48x48dp) + val customView2 = View(toolbar.context).apply { + setBackgroundColor(Color.GREEN) + layoutParams = Toolbar.LayoutParams( + (48 * density).toInt(), + (48 * density).toInt(), + Gravity.END, + ) + } + toolbar.addView(customView2, 2) + coordinatorLayout.maybeRequestLayoutContainer() + }, 5000) + + toolbar.postDelayed({ + // Menu items: one always visible, two in overflow + toolbar.menu.apply { + add(0, 1, 0, "Search").apply { + setIcon(android.R.drawable.ic_menu_search) + setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) + } + add(0, 2, 1, "Settings").apply { + setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) + } + add(0, 3, 2, "About").apply { + setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) + } + } + coordinatorLayout.maybeRequestLayoutContainer() + }, 7000) + } + // endregion + + private fun applyTitle( + appBarLayout: StackHeaderAppBarLayout, + title: String, + ) { + // TODO: diffing mechanism? + when (appBarLayout) { + is StackHeaderAppBarLayout.Small -> { + appBarLayout.toolbar.title = title + } + is StackHeaderAppBarLayout.Collapsing -> { + appBarLayout.toolbar.title = null + appBarLayout.collapsingToolbarLayout.title = title + } + } + } + + private fun applyContentBehavior( + coordinatorLayout: StackHeaderCoordinatorLayout, + config: StackHeaderConfigurationProviding, + ) { + val stackScreenWrapper = coordinatorLayout.stackScreenWrapper + val params = stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams + val needsBehavior = appBarLayout != null && !config.isTransparent && !config.isHidden + val hasBehavior = params.behavior != null + if (needsBehavior != hasBehavior) { + params.behavior = + if (needsBehavior) { + StackHeaderScrollingViewBehavior(onHeaderHeightChanged) + } else { + onHeaderHeightChanged(0) + null + } + stackScreenWrapper.layoutParams = params + } + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt similarity index 85% rename from android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt rename to android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index 810f10af34..b38192ad28 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -1,4 +1,4 @@ -package com.swmansion.rnscreens.gamma.stack.screen.header +package com.swmansion.rnscreens.gamma.stack.header import android.annotation.SuppressLint import android.content.Context @@ -7,16 +7,16 @@ import android.widget.FrameLayout import androidx.coordinatorlayout.widget.CoordinatorLayout import com.swmansion.rnscreens.gamma.stack.host.StackContainer import com.swmansion.rnscreens.gamma.stack.screen.StackScreen -import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding -import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType +import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationProviding +import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderType @SuppressLint("ViewConstructor") -internal class StackScreenCoordinatorLayout( +internal class StackHeaderCoordinatorLayout( context: Context, internal val stackScreen: StackScreen, ) : CoordinatorLayout(context) { private val headerCoordinator = - StackScreenHeaderCoordinator(context) { headerHeight -> + StackHeaderCoordinator(context) { headerHeight -> stackScreen.updateStateIfNeeded(y = headerHeight) } @@ -40,8 +40,8 @@ internal class StackScreenCoordinatorLayout( // TODO: debug-only, this will be sent in reaction to information from "HeaderConfig" component. applyHeaderConfiguration( - object : StackScreenHeaderConfigurationProviding { - override val headerType = StackScreenHeaderType.LARGE + object : StackHeaderConfigurationProviding { + override val headerType = StackHeaderType.LARGE override val title = "Hello, World!" override val isHidden = false override val isTransparent = false @@ -84,6 +84,6 @@ internal class StackScreenCoordinatorLayout( } } - internal fun applyHeaderConfiguration(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) = + internal fun applyHeaderConfiguration(headerConfigurationProviding: StackHeaderConfigurationProviding) = headerCoordinator.applyHeaderConfiguration(this, headerConfigurationProviding) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderScrollingViewBehavior.kt similarity index 83% rename from android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt rename to android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderScrollingViewBehavior.kt index b0accd1f16..99e00311ed 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderScrollingViewBehavior.kt @@ -1,10 +1,10 @@ -package com.swmansion.rnscreens.gamma.stack.screen.header +package com.swmansion.rnscreens.gamma.stack.header import android.view.View import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.appbar.AppBarLayout -internal class StackScreenScrollingViewBehavior( +internal class StackHeaderScrollingViewBehavior( private val onHeaderHeightChanged: (headerHeight: Int) -> Unit, ) : AppBarLayout.ScrollingViewBehavior() { override fun onDependentViewChanged( diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt new file mode 100644 index 0000000000..54dca41df2 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt @@ -0,0 +1,8 @@ +package com.swmansion.rnscreens.gamma.stack.header.configuration + +internal interface StackHeaderConfigurationProviding { + val headerType: StackHeaderType + val title: String + val isHidden: Boolean + val isTransparent: Boolean +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderType.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderType.kt new file mode 100644 index 0000000000..843dca0548 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderType.kt @@ -0,0 +1,7 @@ +package com.swmansion.rnscreens.gamma.stack.header.configuration + +internal enum class StackHeaderType { + SMALL, + MEDIUM, + LARGE, +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt index 922efbfe51..abbbc07700 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt @@ -7,7 +7,7 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.transition.Slide -import com.swmansion.rnscreens.gamma.stack.screen.header.StackScreenCoordinatorLayout +import com.swmansion.rnscreens.gamma.stack.header.StackHeaderCoordinatorLayout internal class StackScreenFragment( internal val stackScreen: StackScreen, @@ -44,7 +44,7 @@ internal class StackScreenFragment( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View = StackScreenCoordinatorLayout(requireContext(), stackScreen) + ): View = StackHeaderCoordinatorLayout(requireContext(), stackScreen) override fun onViewCreated( view: View, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt deleted file mode 100644 index 27bf662605..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.screen.header - -import android.content.Context -import androidx.appcompat.view.ContextThemeWrapper -import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.R -import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding -import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType - -internal class StackScreenHeaderCoordinator( - context: Context, - private val onHeaderHeightChanged: (headerHeight: Int) -> Unit, -) { - private var appBarLayout: StackScreenAppBarLayout? = null - private var currentHeaderType: StackScreenHeaderType? = null - - private val wrappedContext = - ContextThemeWrapper( - context, - R.style.Theme_Material3_DayNight_NoActionBar, - ) - - internal fun applyHeaderConfiguration( - coordinatorLayout: StackScreenCoordinatorLayout, - config: StackScreenHeaderConfigurationProviding, - ) { - applyStructure(coordinatorLayout, config) - applyAppBarConfiguration(config) - applyContentBehavior(coordinatorLayout, config) - coordinatorLayout.maybeRequestLayoutContainer() - } - - private fun applyStructure( - coordinatorLayout: StackScreenCoordinatorLayout, - config: StackScreenHeaderConfigurationProviding, - ) { - val desiredType = if (config.isHidden) null else config.headerType - - if (desiredType == currentHeaderType) return - - appBarLayout?.let { coordinatorLayout.removeView(it) } - appBarLayout = - desiredType?.let { - StackScreenAppBarLayout.create(wrappedContext, it).also { appBar -> - coordinatorLayout.addView(appBar, 0) - } - } - - currentHeaderType = desiredType - } - - private fun applyAppBarConfiguration(config: StackScreenHeaderConfigurationProviding) { - appBarLayout?.let { appBar -> - applyTitle(appBar, config.title) - // TODO: other app bar configuration... - } - } - - private fun applyTitle( - appBarLayout: StackScreenAppBarLayout, - title: String, - ) { - // TODO: diffing mechanism? - when (appBarLayout) { - is StackScreenAppBarLayout.Small -> { - appBarLayout.toolbar.title = title - } - is StackScreenAppBarLayout.Collapsing -> { - appBarLayout.toolbar.title = null - appBarLayout.collapsingToolbarLayout.title = title - } - } - } - - private fun applyContentBehavior( - coordinatorLayout: StackScreenCoordinatorLayout, - config: StackScreenHeaderConfigurationProviding, - ) { - val stackScreenWrapper = coordinatorLayout.stackScreenWrapper - val params = stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams - val needsBehavior = appBarLayout != null && !config.isTransparent && !config.isHidden - val hasBehavior = params.behavior != null - if (needsBehavior != hasBehavior) { - params.behavior = - if (needsBehavior) { - StackScreenScrollingViewBehavior(onHeaderHeightChanged) - } else { - onHeaderHeightChanged(0) - null - } - stackScreenWrapper.layoutParams = params - } - } -} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt deleted file mode 100644 index 32c2a1c73c..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.screen.header.configuration - -internal interface StackScreenHeaderConfigurationProviding { - val headerType: StackScreenHeaderType - val title: String - val isHidden: Boolean - val isTransparent: Boolean -} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt deleted file mode 100644 index fbaa11873f..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.screen.header.configuration - -internal enum class StackScreenHeaderType { - SMALL, - MEDIUM, - LARGE, -} From d27fb40b8eda6e91636039d0c894b0ad17122ab6 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 18 Mar 2026 15:11:18 +0100 Subject: [PATCH 22/92] add StackHeaderConfiguration and StackHeaderSubview view skeletons --- .../swmansion/rnscreens/RNScreensPackage.kt | 4 +++ .../configuration/StackHeaderConfiguration.kt | 12 +++++++ .../StackHeaderConfigurationViewManager.kt | 34 +++++++++++++++++++ .../header/subview/StackHeaderSubview.kt | 13 +++++++ .../subview/StackHeaderSubviewViewManager.kt | 31 +++++++++++++++++ android/src/main/jni/rnscreens.h | 2 ++ ...ckHeaderConfigurationComponentDescriptor.h | 22 ++++++++++++ .../RNSStackHeaderConfigurationShadowNode.cpp | 8 +++++ .../RNSStackHeaderConfigurationShadowNode.h | 24 +++++++++++++ .../RNSStackHeaderConfigurationState.cpp | 3 ++ .../RNSStackHeaderConfigurationState.h | 14 ++++++++ ...RNSStackHeaderSubviewComponentDescriptor.h | 21 ++++++++++++ .../RNSStackHeaderSubviewShadowNode.cpp | 8 +++++ .../RNSStackHeaderSubviewShadowNode.h | 24 +++++++++++++ .../rnscreens/RNSStackHeaderSubviewState.cpp | 3 ++ .../rnscreens/RNSStackHeaderSubviewState.h | 14 ++++++++ react-native.config.js | 4 ++- .../stack/header/StackHeaderConfiguration.tsx | 21 ++++++++++++ .../header/StackHeaderConfiguration.types.ts | 12 +++++++ .../header/StackHeaderConfiguration.web.tsx | 5 +++ .../gamma/stack/header/StackHeaderSubview.tsx | 21 ++++++++++++ .../stack/header/StackHeaderSubview.types.ts | 9 +++++ .../stack/header/StackHeaderSubview.web.tsx | 5 +++ src/components/gamma/stack/index.ts | 10 ++++++ ...StackHeaderConfigurationNativeComponent.ts | 20 +++++++++++ .../StackHeaderSubviewNativeComponent.ts | 14 ++++++++ 26 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationComponentDescriptor.h create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.cpp create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.h create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.cpp create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.h create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.cpp create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.h create mode 100644 src/components/gamma/stack/header/StackHeaderConfiguration.tsx create mode 100644 src/components/gamma/stack/header/StackHeaderConfiguration.types.ts create mode 100644 src/components/gamma/stack/header/StackHeaderConfiguration.web.tsx create mode 100644 src/components/gamma/stack/header/StackHeaderSubview.tsx create mode 100644 src/components/gamma/stack/header/StackHeaderSubview.types.ts create mode 100644 src/components/gamma/stack/header/StackHeaderSubview.web.tsx create mode 100644 src/fabric/gamma/stack/StackHeaderConfigurationNativeComponent.ts create mode 100644 src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts diff --git a/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt b/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt index 7c05e628a5..0026abe91b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt +++ b/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt @@ -8,6 +8,8 @@ import com.facebook.react.module.model.ReactModuleInfo import com.facebook.react.module.model.ReactModuleInfoProvider import com.facebook.react.uimanager.ViewManager import com.swmansion.rnscreens.gamma.scrollviewmarker.ScrollViewMarkerViewManager +import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationViewManager +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewViewManager import com.swmansion.rnscreens.gamma.stack.host.StackHostViewManager import com.swmansion.rnscreens.gamma.stack.screen.StackScreenViewManager import com.swmansion.rnscreens.gamma.tabs.host.TabsHostViewManager @@ -57,6 +59,8 @@ class RNScreensPackage : BaseReactPackage() { StackHostViewManager(), StackScreenViewManager(), ScrollViewMarkerViewManager(), + StackHeaderConfigurationViewManager(), + StackHeaderSubviewViewManager(), ) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt new file mode 100644 index 0000000000..0eb6037678 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt @@ -0,0 +1,12 @@ +package com.swmansion.rnscreens.gamma.stack.header.configuration + +import android.annotation.SuppressLint +import com.facebook.react.bridge.ReactContext +import com.facebook.react.views.view.ReactViewGroup + +@SuppressLint("ViewConstructor") +class StackHeaderConfiguration( + val reactContext: ReactContext, +) : ReactViewGroup(reactContext) { + +} \ No newline at end of file diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt new file mode 100644 index 0000000000..ba5a65e24e --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt @@ -0,0 +1,34 @@ +package com.swmansion.rnscreens.gamma.stack.header.configuration + +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.viewmanagers.RNSStackHeaderConfigurationManagerDelegate +import com.facebook.react.viewmanagers.RNSStackHeaderConfigurationManagerInterface + +@ReactModule(name = StackHeaderConfigurationViewManager.REACT_CLASS) +open class StackHeaderConfigurationViewManager : + ViewGroupManager(), + RNSStackHeaderConfigurationManagerInterface { + private val delegate: ViewManagerDelegate + + init { + delegate = RNSStackHeaderConfigurationManagerDelegate(this) + } + + override fun getName() = REACT_CLASS + + override fun createViewInstance(reactContext: ThemedReactContext) = StackHeaderConfiguration(reactContext) + + override fun getDelegate(): ViewManagerDelegate = delegate + + override fun setType(view: StackHeaderConfiguration, value: String?) = Unit + override fun setTitle(view: StackHeaderConfiguration, value: String?) = Unit + override fun setHidden(view: StackHeaderConfiguration, value: Boolean) = Unit + override fun setTransparent(view: StackHeaderConfiguration, value: Boolean) = Unit + + companion object { + const val REACT_CLASS = "RNSStackHeaderConfiguration" + } +} \ No newline at end of file diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt new file mode 100644 index 0000000000..bef6f1a968 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -0,0 +1,13 @@ +package com.swmansion.rnscreens.gamma.stack.header.subview + + +import android.annotation.SuppressLint +import com.facebook.react.bridge.ReactContext +import com.facebook.react.views.view.ReactViewGroup + +@SuppressLint("ViewConstructor") +class StackHeaderSubview( + val reactContext: ReactContext, +) : ReactViewGroup(reactContext) { + +} \ No newline at end of file diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt new file mode 100644 index 0000000000..ff7a8628c2 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt @@ -0,0 +1,31 @@ +package com.swmansion.rnscreens.gamma.stack.header.subview + +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.viewmanagers.RNSStackHeaderSubviewManagerDelegate +import com.facebook.react.viewmanagers.RNSStackHeaderSubviewManagerInterface + +@ReactModule(name = StackHeaderSubviewViewManager.REACT_CLASS) +open class StackHeaderSubviewViewManager : + ViewGroupManager(), + RNSStackHeaderSubviewManagerInterface { + private val delegate: ViewManagerDelegate + + init { + delegate = RNSStackHeaderSubviewManagerDelegate(this) + } + + override fun getName() = REACT_CLASS + + override fun createViewInstance(reactContext: ThemedReactContext) = StackHeaderSubview(reactContext) + + override fun getDelegate(): ViewManagerDelegate = delegate + + override fun setType(view: StackHeaderSubview, value: String?) = Unit + + companion object { + const val REACT_CLASS = "RNSStackHeaderSubview" + } +} \ No newline at end of file diff --git a/android/src/main/jni/rnscreens.h b/android/src/main/jni/rnscreens.h index a3d5690d39..47868368d9 100644 --- a/android/src/main/jni/rnscreens.h +++ b/android/src/main/jni/rnscreens.h @@ -24,6 +24,8 @@ #include #include #include +#include +#include namespace facebook { namespace react { diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationComponentDescriptor.h new file mode 100644 index 0000000000..6e1bf6f87f --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationComponentDescriptor.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include "RNSStackHeaderConfigurationShadowNode.h" + +namespace facebook::react { + +class RNSStackHeaderConfigurationComponentDescriptor final + : public ConcreteComponentDescriptor< + RNSStackHeaderConfigurationShadowNode> { + public: + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + + void adopt(ShadowNode &shadowNode) const override { + react_native_assert( + dynamic_cast(&shadowNode)); + ConcreteComponentDescriptor::adopt(shadowNode); + } +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.cpp new file mode 100644 index 0000000000..f7876ee9be --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.cpp @@ -0,0 +1,8 @@ +#include "RNSStackHeaderConfigurationShadowNode.h" + +namespace facebook::react { + +extern const char RNSStackHeaderConfigurationComponentName[] = + "RNSStackHeaderConfiguration"; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.h new file mode 100644 index 0000000000..d8fdb6de0c --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include +#include +#include "RNSStackHeaderConfigurationState.h" + +namespace facebook::react { + +JSI_EXPORT extern const char RNSStackHeaderConfigurationComponentName[]; + +class JSI_EXPORT RNSStackHeaderConfigurationShadowNode final + : public ConcreteViewShadowNode< + RNSStackHeaderConfigurationComponentName, + RNSStackHeaderConfigurationProps, + RNSStackHeaderConfigurationEventEmitter, + RNSStackHeaderConfigurationState> { + public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + using StateData = ConcreteViewShadowNode::ConcreteStateData; +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.cpp new file mode 100644 index 0000000000..8cb2ba0e9c --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.cpp @@ -0,0 +1,3 @@ +#include "RNSStackHeaderConfigurationState.h" + +namespace facebook::react {} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.h new file mode 100644 index 0000000000..97e5866858 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace facebook::react { + +class JSI_EXPORT RNSStackHeaderConfigurationState final { + public: + using Shared = std::shared_ptr; + + RNSStackHeaderConfigurationState() {}; +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h new file mode 100644 index 0000000000..bf827a4bb4 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include "RNSStackHeaderSubviewShadowNode.h" + +namespace facebook::react { + +class RNSStackHeaderSubviewComponentDescriptor final + : public ConcreteComponentDescriptor { + public: + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + + void adopt(ShadowNode &shadowNode) const override { + react_native_assert( + dynamic_cast(&shadowNode)); + ConcreteComponentDescriptor::adopt(shadowNode); + } +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp new file mode 100644 index 0000000000..711318af72 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp @@ -0,0 +1,8 @@ +#include "RNSStackHeaderSubviewShadowNode.h" + +namespace facebook::react { + +extern const char RNSStackHeaderSubviewComponentName[] = + "RNSStackHeaderSubview"; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h new file mode 100644 index 0000000000..f89b6fbef7 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include +#include +#include "RNSStackHeaderSubviewState.h" + +namespace facebook::react { + +JSI_EXPORT extern const char RNSStackHeaderSubviewComponentName[]; + +class JSI_EXPORT RNSStackHeaderSubviewShadowNode final + : public ConcreteViewShadowNode< + RNSStackHeaderSubviewComponentName, + RNSStackHeaderSubviewProps, + RNSStackHeaderSubviewEventEmitter, + RNSStackHeaderSubviewState> { + public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + using StateData = ConcreteViewShadowNode::ConcreteStateData; +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.cpp new file mode 100644 index 0000000000..1e481f0231 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.cpp @@ -0,0 +1,3 @@ +#include "RNSStackHeaderSubviewState.h" + +namespace facebook::react {} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.h new file mode 100644 index 0000000000..15b7c4e6fd --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace facebook::react { + +class JSI_EXPORT RNSStackHeaderSubviewState final { + public: + using Shared = std::shared_ptr; + + RNSStackHeaderSubviewState() {}; +}; + +} // namespace facebook::react diff --git a/react-native.config.js b/react-native.config.js index 6bf41244e4..8fab3480a9 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -16,7 +16,9 @@ module.exports = { 'RNSModalScreenComponentDescriptor', 'RNSTabsHostComponentDescriptor', 'RNSSafeAreaViewComponentDescriptor', - 'RNSStackScreenComponentDescriptor' + 'RNSStackScreenComponentDescriptor', + 'RNSStackHeaderConfigurationComponentDescriptor', + 'RNSStackHeaderSubviewComponentDescriptor' ], cmakeListsPath: "../android/src/main/jni/CMakeLists.txt" }, diff --git a/src/components/gamma/stack/header/StackHeaderConfiguration.tsx b/src/components/gamma/stack/header/StackHeaderConfiguration.tsx new file mode 100644 index 0000000000..a3eeb0b9cb --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderConfiguration.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; +import { StackHeaderConfigurationProps } from './StackHeaderConfiguration.types'; +import StackHeaderConfigurationNativeComponent from '../../../../fabric/gamma/stack/StackHeaderConfigurationNativeComponent'; + +/** + * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE + */ +function StackHeaderConfiguration(props: StackHeaderConfigurationProps) { + const { children, ...filteredProps } = props; + return ( + + {children} + + ); +} + +export default StackHeaderConfiguration; diff --git a/src/components/gamma/stack/header/StackHeaderConfiguration.types.ts b/src/components/gamma/stack/header/StackHeaderConfiguration.types.ts new file mode 100644 index 0000000000..6aa32f9391 --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderConfiguration.types.ts @@ -0,0 +1,12 @@ +import { ViewProps } from 'react-native'; + +export type StackHeaderTypeAndroid = 'small' | 'medium' | 'large'; + +export type StackHeaderConfigurationProps = { + children?: ViewProps['children']; + + type?: StackHeaderTypeAndroid; + title?: string; + hidden?: boolean; + transparent?: boolean; +}; diff --git a/src/components/gamma/stack/header/StackHeaderConfiguration.web.tsx b/src/components/gamma/stack/header/StackHeaderConfiguration.web.tsx new file mode 100644 index 0000000000..d9db852a32 --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderConfiguration.web.tsx @@ -0,0 +1,5 @@ +import { View } from 'react-native'; + +const StackHeaderConfiguration = View; + +export default StackHeaderConfiguration; diff --git a/src/components/gamma/stack/header/StackHeaderSubview.tsx b/src/components/gamma/stack/header/StackHeaderSubview.tsx new file mode 100644 index 0000000000..4b0a9ddcff --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderSubview.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; +import { StackHeaderSubviewProps } from './StackHeaderSubview.types'; +import StackHeaderSubviewNativeComponent from '../../../../fabric/gamma/stack/StackHeaderSubviewNativeComponent'; + +/** + * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE + */ +function StackHeaderSubview(props: StackHeaderSubviewProps) { + const { children, ...filteredProps } = props; + return ( + + {children} + + ); +} + +export default StackHeaderSubview; diff --git a/src/components/gamma/stack/header/StackHeaderSubview.types.ts b/src/components/gamma/stack/header/StackHeaderSubview.types.ts new file mode 100644 index 0000000000..9f12c3900d --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderSubview.types.ts @@ -0,0 +1,9 @@ +import { ViewProps } from 'react-native'; + +export type StackHeaderSubviewTypeAndroid = 'left' | 'center' | 'right'; + +export type StackHeaderSubviewProps = { + children?: ViewProps['children']; + + type?: StackHeaderSubviewTypeAndroid; +}; diff --git a/src/components/gamma/stack/header/StackHeaderSubview.web.tsx b/src/components/gamma/stack/header/StackHeaderSubview.web.tsx new file mode 100644 index 0000000000..2b4e14c84d --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderSubview.web.tsx @@ -0,0 +1,5 @@ +import { View } from 'react-native'; + +const StackHeaderSubview = View; + +export default StackHeaderSubview; diff --git a/src/components/gamma/stack/index.ts b/src/components/gamma/stack/index.ts index f557dd0426..6b39a05eba 100644 --- a/src/components/gamma/stack/index.ts +++ b/src/components/gamma/stack/index.ts @@ -1,15 +1,25 @@ import StackHost from './StackHost'; import StackScreen from './StackScreen'; +import StackHeaderConfiguration from './header/StackHeaderConfiguration'; +import StackHeaderSubview from './header/StackHeaderSubview'; export * from './StackHost.types'; export * from './StackScreen.types'; +export * from './header/StackHeaderConfiguration.types'; +export * from './header/StackHeaderSubview.types'; /** * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE */ +const Header = { + Configuration: StackHeaderConfiguration, + Subview: StackHeaderSubview, +}; + const Stack = { Host: StackHost, Screen: StackScreen, + Header, }; export default Stack; diff --git a/src/fabric/gamma/stack/StackHeaderConfigurationNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderConfigurationNativeComponent.ts new file mode 100644 index 0000000000..25bf126d99 --- /dev/null +++ b/src/fabric/gamma/stack/StackHeaderConfigurationNativeComponent.ts @@ -0,0 +1,20 @@ +'use client'; + +import type { CodegenTypes as CT, ViewProps } from 'react-native'; +import { codegenNativeComponent } from 'react-native'; + +type StackHeaderTypeAndroid = 'small' | 'medium' | 'large'; + +export interface NativeProps extends ViewProps { + type?: CT.WithDefault; + title?: string; + hidden?: CT.WithDefault; + transparent?: CT.WithDefault; +} + +export default codegenNativeComponent( + 'RNSStackHeaderConfiguration', + { + interfaceOnly: true, + }, +); diff --git a/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts new file mode 100644 index 0000000000..ab4b8bf6c0 --- /dev/null +++ b/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts @@ -0,0 +1,14 @@ +'use client'; + +import type { CodegenTypes as CT, ViewProps } from 'react-native'; +import { codegenNativeComponent } from 'react-native'; + +type StackHeaderSubviewTypeAndroid = 'left' | 'center' | 'right'; + +export interface NativeProps extends ViewProps { + type?: CT.WithDefault; +} + +export default codegenNativeComponent('RNSStackHeaderSubview', { + interfaceOnly: true, +}); From f6668e55fca3d2b9c87814bdfe59fc7f82b13c54 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Fri, 20 Mar 2026 12:54:24 +0100 Subject: [PATCH 23/92] header subviews WIP --- .../stack/header/StackHeaderAppBarLayout.kt | 9 + .../stack/header/StackHeaderCoordinator.kt | 279 ++++++++++++------ .../header/StackHeaderCoordinatorLayout.kt | 74 ++--- .../configuration/StackHeaderConfiguration.kt | 70 ++++- .../StackHeaderConfigurationAttachObserver.kt | 5 + .../StackHeaderConfigurationChangeListener.kt | 5 + .../StackHeaderConfigurationProviding.kt | 11 +- .../StackHeaderConfigurationViewManager.kt | 76 ++++- .../header/configuration/StackHeaderType.kt | 2 +- .../header/subview/StackHeaderSubview.kt | 26 +- .../header/subview/StackHeaderSubviewType.kt | 7 + .../subview/StackHeaderSubviewViewManager.kt | 16 +- .../gamma/stack/screen/StackScreen.kt | 24 ++ .../stack/screen/StackScreenViewManager.kt | 25 ++ .../gamma/containers/stack/StackContainer.tsx | 14 + .../gamma/stack/header/StackHeaderSubview.tsx | 3 +- 16 files changed, 506 insertions(+), 140 deletions(-) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationAttachObserver.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationChangeListener.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt index bee7bf5102..d579d57ffb 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt @@ -2,6 +2,8 @@ package com.swmansion.rnscreens.gamma.stack.header import android.annotation.SuppressLint import android.content.Context +import android.graphics.Color +import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import androidx.coordinatorlayout.widget.CoordinatorLayout @@ -101,6 +103,13 @@ internal sealed class StackHeaderAppBarLayout( scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED or SCROLL_FLAG_SNAP // scrollFlags = SCROLL_FLAG_NO_SCROLL } + addView( + View(context).apply { + layoutParams = CollapsingToolbarLayout.LayoutParams(1080, 900) + setBackgroundColor(Color.BLUE) + fitsSystemWindows = true + }, + ) addView(toolbar) } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 9d7b7619e9..8c1643bc20 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -1,17 +1,20 @@ package com.swmansion.rnscreens.gamma.stack.header import android.content.Context -import android.graphics.Color +import android.text.TextUtils import android.view.Gravity -import android.view.MenuItem import android.view.View +import android.view.ViewGroup import androidx.appcompat.view.ContextThemeWrapper +import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.widget.TextViewCompat import com.google.android.material.R import com.google.android.material.appbar.MaterialToolbar import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationProviding import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderType +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview internal class StackHeaderCoordinator( context: Context, @@ -20,6 +23,17 @@ internal class StackHeaderCoordinator( private var appBarLayout: StackHeaderAppBarLayout? = null private var currentHeaderType: StackHeaderType? = null + private var attachedLeftSubview: StackHeaderSubview? = null + private var lastLeftSubviewWidth: Int? = null + + private var attachedCenterSubview: StackHeaderSubview? = null + private var lastCenterSubviewWidth: Int? = null + + private var attachedRightSubview: StackHeaderSubview? = null + private var lastRightSubviewWidth: Int? = null + + private var managedTitleView: AppCompatTextView? = null + private val wrappedContext = ContextThemeWrapper( context, @@ -30,123 +44,212 @@ internal class StackHeaderCoordinator( coordinatorLayout: StackHeaderCoordinatorLayout, config: StackHeaderConfigurationProviding, ) { - applyStructure(coordinatorLayout, config) - applyAppBarConfiguration(config, coordinatorLayout) + if (requiresRebuild(config)) { + rebuild(coordinatorLayout, config) + } + applyProps(config) applyContentBehavior(coordinatorLayout, config) coordinatorLayout.maybeRequestLayoutContainer() } - private fun applyStructure( + // --- Rebuild detection --- + + private fun requiresRebuild(config: StackHeaderConfigurationProviding): Boolean { + val desiredType = if (config.hidden) null else config.type + if (desiredType != currentHeaderType) return true + if (config.leftSubview !== attachedLeftSubview) return true + if (config.centerSubview !== attachedCenterSubview) return true + if (config.rightSubview !== attachedRightSubview) return true + + // Collapsing headers need rebuild when subview sizes change + // (MDC limitation: can't change custom views at runtime in CollapsingToolbarLayout) + if (appBarLayout is StackHeaderAppBarLayout.Collapsing) { + if (subviewWidthChanged(config.leftSubview, lastLeftSubviewWidth)) return true + if (subviewWidthChanged(config.centerSubview, lastCenterSubviewWidth)) return true + if (subviewWidthChanged(config.rightSubview, lastRightSubviewWidth)) return true + } + + return false + } + + private fun subviewWidthChanged( + subview: StackHeaderSubview?, + lastSubviewWidth: Int?, + ): Boolean { + if (subview == null && lastSubviewWidth == null) return false + if (subview == null || lastSubviewWidth == null) return true + return subview.width != lastSubviewWidth + } + + private fun snapshotSubviewWidths(config: StackHeaderConfigurationProviding) { + lastLeftSubviewWidth = config.leftSubview?.width + lastCenterSubviewWidth = config.centerSubview?.width + lastRightSubviewWidth = config.rightSubview?.width + } + + // --- Full rebuild --- + + private fun rebuild( coordinatorLayout: StackHeaderCoordinatorLayout, config: StackHeaderConfigurationProviding, ) { - val desiredType = if (config.isHidden) null else config.headerType + teardown(coordinatorLayout) - if (desiredType == currentHeaderType) return + val desiredType = if (config.hidden) null else config.type - appBarLayout?.let { coordinatorLayout.removeView(it) } - appBarLayout = - desiredType?.let { - StackHeaderAppBarLayout.create(wrappedContext, it).also { appBar -> - coordinatorLayout.addView(appBar, 0) - } - } + if (desiredType != null) { + val appBar = StackHeaderAppBarLayout.create(wrappedContext, desiredType) + appBarLayout = appBar + populateToolbar(appBar.toolbar, config) + coordinatorLayout.addView(appBar, 0) + } currentHeaderType = desiredType + attachedLeftSubview = config.leftSubview + attachedCenterSubview = config.centerSubview + attachedRightSubview = config.rightSubview + + snapshotSubviewWidths(config) } - private fun applyAppBarConfiguration(config: StackHeaderConfigurationProviding, coordinatorLayout: StackHeaderCoordinatorLayout) { - appBarLayout?.let { appBar -> - applyTitle(appBar, config.title) - // TODO: other app bar configuration... - applyDebugToolbarItems(appBar.toolbar, coordinatorLayout) - } + private fun teardown(coordinatorLayout: StackHeaderCoordinatorLayout) { + // Subviews need to be detached from toolbar before removing the app bar, + // otherwise they'd be destroyed with it + detachSubviewsFromToolbar() + appBarLayout?.let { coordinatorLayout.removeView(it) } + appBarLayout = null + managedTitleView = null + currentHeaderType = null + attachedLeftSubview = null + attachedCenterSubview = null + attachedRightSubview = null } - // region DEBUG — remove later - private var debugItemsApplied = false - - private fun applyDebugToolbarItems(toolbar: MaterialToolbar, coordinatorLayout: StackHeaderCoordinatorLayout) { - if (debugItemsApplied) return - debugItemsApplied = true - - // Navigation icon (back arrow) - toolbar.setNavigationIcon(androidx.appcompat.R.drawable.abc_ic_ab_back_material) - toolbar.setNavigationOnClickListener { /* no-op for now */ } - - val density = toolbar.context.resources.displayMetrics.density - - // Custom views and menu items added with delay to test runtime insertion - toolbar.postDelayed({ - // Custom view on the left side (red box, 48x48dp) - val customView = View(toolbar.context).apply { - setBackgroundColor(Color.RED) - layoutParams = Toolbar.LayoutParams( - (48 * density).toInt(), - (48 * density).toInt(), - Gravity.START, - ) - } - toolbar.addView(customView, 0) - coordinatorLayout.maybeRequestLayoutContainer() - }, 3000) - - toolbar.postDelayed({ - // Custom view on the right side (green box, 48x48dp) - val customView2 = View(toolbar.context).apply { - setBackgroundColor(Color.GREEN) - layoutParams = Toolbar.LayoutParams( - (48 * density).toInt(), - (48 * density).toInt(), - Gravity.END, - ) - } - toolbar.addView(customView2, 2) - coordinatorLayout.maybeRequestLayoutContainer() - }, 5000) - - toolbar.postDelayed({ - // Menu items: one always visible, two in overflow - toolbar.menu.apply { - add(0, 1, 0, "Search").apply { - setIcon(android.R.drawable.ic_menu_search) - setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) - } - add(0, 2, 1, "Settings").apply { - setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) - } - add(0, 3, 2, "About").apply { - setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) - } - } - coordinatorLayout.maybeRequestLayoutContainer() - }, 7000) + private fun detachSubviewsFromToolbar() { + val toolbar = appBarLayout?.toolbar ?: return + attachedLeftSubview?.let { toolbar.removeView(it) } + attachedCenterSubview?.let { toolbar.removeView(it) } + attachedRightSubview?.let { toolbar.removeView(it) } } - // endregion - private fun applyTitle( - appBarLayout: StackHeaderAppBarLayout, - title: String, + // --- Toolbar population (called during rebuild) --- + + private fun populateToolbar( + toolbar: MaterialToolbar, + config: StackHeaderConfigurationProviding, ) { - // TODO: diffing mechanism? - when (appBarLayout) { + // Never use native title — we manage our own + toolbar.title = null + + // Order matters for measurement: left first, then right, then title/center last. + // Toolbar measures children by index order. Title should be measured last + // so it gets the remaining space after left and right are accounted for. + + config.leftSubview?.let { + detachFromCurrentParent(it) + toolbar.addView(it, startGravityParams()) + } + + config.rightSubview?.let { + detachFromCurrentParent(it) + toolbar.addView(it, endGravityParams()) + } + + if (config.centerSubview != null) { + // Center subview replaces title for all header types + managedTitleView = null + detachFromCurrentParent(config.centerSubview) + toolbar.addView(config.centerSubview, centerGravityParams()) + } else if (appBarLayout is StackHeaderAppBarLayout.Small) { + // Small header: managed title view (we can't use native title + // because we can't insert custom views before it) + val titleView = createManagedTitleView(toolbar) + managedTitleView = titleView + toolbar.addView(titleView) + } + // Collapsing: no title in toolbar — CTL handles it (canvas-drawn) + } + + private fun createManagedTitleView(toolbar: Toolbar): AppCompatTextView = + AppCompatTextView(toolbar.context).apply { + // Matches configuration from Toolbar.java + setSingleLine() + ellipsize = TextUtils.TruncateAt.END + TextViewCompat.setTextAppearance( + this, + R.style.TextAppearance_Material3_TitleLarge, + ) + layoutParams = + Toolbar + .LayoutParams( + Toolbar.LayoutParams.WRAP_CONTENT, + Toolbar.LayoutParams.WRAP_CONTENT, + Gravity.START, + ).apply { + // TODO: this seems to be a problem with collapsing margins. + // We will expose customization either way but we should + // have consistent behavior and defaults. + marginStart = toolbar.titleMarginStart + toolbar.contentInsetStart + marginEnd = toolbar.titleMarginEnd + topMargin = toolbar.titleMarginTop + bottomMargin = toolbar.titleMarginBottom + } + } + + private fun detachFromCurrentParent(view: View?) { + (view?.parent as? ViewGroup)?.removeView(view) + } + + private fun startGravityParams() = + Toolbar.LayoutParams( + Toolbar.LayoutParams.WRAP_CONTENT, + Toolbar.LayoutParams.WRAP_CONTENT, + Gravity.START, + ) + + private fun centerGravityParams() = + Toolbar.LayoutParams( + Toolbar.LayoutParams.WRAP_CONTENT, + Toolbar.LayoutParams.WRAP_CONTENT, + Gravity.CENTER_HORIZONTAL, + ) + + private fun endGravityParams() = + Toolbar.LayoutParams( + Toolbar.LayoutParams.WRAP_CONTENT, + Toolbar.LayoutParams.WRAP_CONTENT, + Gravity.END, + ) + + // --- In-place prop updates (no rebuild needed) --- + + private fun applyProps(config: StackHeaderConfigurationProviding) { + val appBar = appBarLayout ?: return + + when (appBar) { is StackHeaderAppBarLayout.Small -> { - appBarLayout.toolbar.title = title + managedTitleView?.text = config.title } + is StackHeaderAppBarLayout.Collapsing -> { - appBarLayout.toolbar.title = null - appBarLayout.collapsingToolbarLayout.title = title + if (config.centerSubview != null) { + appBar.collapsingToolbarLayout.title = null + } else { + appBar.collapsingToolbarLayout.title = config.title + } } } } + // --- Content behavior (unchanged) --- + private fun applyContentBehavior( coordinatorLayout: StackHeaderCoordinatorLayout, config: StackHeaderConfigurationProviding, ) { val stackScreenWrapper = coordinatorLayout.stackScreenWrapper val params = stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams - val needsBehavior = appBarLayout != null && !config.isTransparent && !config.isHidden + val needsBehavior = appBarLayout != null && !config.transparent && !config.hidden val hasBehavior = params.behavior != null if (needsBehavior != hasBehavior) { params.behavior = diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index b38192ad28..c6220e05b4 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -5,10 +5,13 @@ import android.content.Context import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfiguration +import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationAttachObserver +import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationChangeListener +import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationProviding import com.swmansion.rnscreens.gamma.stack.host.StackContainer import com.swmansion.rnscreens.gamma.stack.screen.StackScreen -import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationProviding -import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderType +import java.lang.ref.WeakReference @SuppressLint("ViewConstructor") internal class StackHeaderCoordinatorLayout( @@ -22,6 +25,24 @@ internal class StackHeaderCoordinatorLayout( internal var stackScreenWrapper: FrameLayout + private var isHeaderUpdatePending = false + + private val headerConfigChangeListener = + StackHeaderConfigurationChangeListener { config -> + if (!isHeaderUpdatePending) { + isHeaderUpdatePending = true + post { + isHeaderUpdatePending = false + applyHeaderConfiguration(config) + } + } + } + + private val headerAttachObserver = + StackHeaderConfigurationAttachObserver { config -> + onHeaderConfigurationAvailable(config) + } + init { // Needed when Transition API is in use to ensure that shadows do not disappear, // views do not jump around the screen and whole subtree is animated as a whole. @@ -38,38 +59,21 @@ internal class StackHeaderCoordinatorLayout( LayoutParams(MATCH_PARENT, MATCH_PARENT), ) - // TODO: debug-only, this will be sent in reaction to information from "HeaderConfig" component. - applyHeaderConfiguration( - object : StackHeaderConfigurationProviding { - override val headerType = StackHeaderType.LARGE - override val title = "Hello, World!" - override val isHidden = false - override val isTransparent = false - }, - ) + // Wire observer on StackScreen for header attach/detach notifications + stackScreen.headerConfigurationAttachObserver = WeakReference(headerAttachObserver) - // TODO: debug only, until we expose props via JS -// postDelayed({ -// applyHeaderConfiguration( -// object : StackScreenHeaderConfigurationProviding { -// override val headerType = StackScreenHeaderType.LARGE -// override val title = "Hello, World!" -// override val isHidden = true -// override val isTransparent = false -// }, -// ) -// -// postDelayed({ -// applyHeaderConfiguration( -// object : StackScreenHeaderConfigurationProviding { -// override val headerType = StackScreenHeaderType.LARGE -// override val title = "Hello, World!" -// override val isHidden = false -// override val isTransparent = false -// }, -// ) -// }, 3000) -// }, 3000) + // Handle case where header was already attached before this layout was created + stackScreen.headerConfiguration?.let { onHeaderConfigurationAvailable(it) } + } + + private fun onHeaderConfigurationAvailable(config: StackHeaderConfiguration?) { + if (config != null) { + // Wire header's own change listener so prop updates go directly to us + config.configurationChangeListener = WeakReference(headerConfigChangeListener) + applyHeaderConfiguration(config) + } else { + // Header removed — could reset to default state + } } /** @@ -84,6 +88,6 @@ internal class StackHeaderCoordinatorLayout( } } - internal fun applyHeaderConfiguration(headerConfigurationProviding: StackHeaderConfigurationProviding) = - headerCoordinator.applyHeaderConfiguration(this, headerConfigurationProviding) + internal fun applyHeaderConfiguration(config: StackHeaderConfigurationProviding) = + headerCoordinator.applyHeaderConfiguration(this, config) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt index 0eb6037678..ea25d4750b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt @@ -3,10 +3,76 @@ package com.swmansion.rnscreens.gamma.stack.header.configuration import android.annotation.SuppressLint import com.facebook.react.bridge.ReactContext import com.facebook.react.views.view.ReactViewGroup +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewType +import java.lang.ref.WeakReference @SuppressLint("ViewConstructor") class StackHeaderConfiguration( val reactContext: ReactContext, -) : ReactViewGroup(reactContext) { +) : ReactViewGroup(reactContext), + StackHeaderConfigurationProviding { + override var type: StackHeaderType = StackHeaderType.SMALL + override var title: String = "" + override var hidden: Boolean = false + override var transparent: Boolean = false -} \ No newline at end of file + override var leftSubview: StackHeaderSubview? = null + private set + override var centerSubview: StackHeaderSubview? = null + private set + override var rightSubview: StackHeaderSubview? = null + private set + + internal var configurationChangeListener: WeakReference? = null + + internal fun notifyConfigurationChanged() { + configurationChangeListener?.get()?.onHeaderConfigurationChanged(this) + } + + private val subviewLayoutChangeListener = + OnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ -> + val widthChanged = (right - left) != (oldRight - oldLeft) + if (widthChanged) { + notifyConfigurationChanged() + } + } + + internal fun addConfigSubview(headerSubview: StackHeaderSubview) { + when (headerSubview.type) { + StackHeaderSubviewType.LEFT -> leftSubview = headerSubview + StackHeaderSubviewType.CENTER -> centerSubview = headerSubview + StackHeaderSubviewType.RIGHT -> rightSubview = headerSubview + } + headerSubview.addOnLayoutChangeListener(subviewLayoutChangeListener) + notifyConfigurationChanged() + } + + internal fun removeConfigSubview(headerSubview: StackHeaderSubview) { + headerSubview.removeOnLayoutChangeListener(subviewLayoutChangeListener) + when (headerSubview.type) { + StackHeaderSubviewType.LEFT -> leftSubview = null + StackHeaderSubviewType.CENTER -> centerSubview = null + StackHeaderSubviewType.RIGHT -> rightSubview = null + } + notifyConfigurationChanged() + } + + internal fun removeConfigSubviewAt(index: Int) { + getConfigSubviewAt(index)?.let { removeConfigSubview(it) } + } + + internal fun removeAllConfigSubviews() { + leftSubview = null + centerSubview = null + rightSubview = null + + notifyConfigurationChanged() + } + + internal val configSubviewsCount: Int + get() = listOfNotNull(leftSubview, centerSubview, rightSubview).size + + internal fun getConfigSubviewAt(index: Int): StackHeaderSubview? = + listOfNotNull(leftSubview, centerSubview, rightSubview).getOrNull(index) +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationAttachObserver.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationAttachObserver.kt new file mode 100644 index 0000000000..7d39c58ed6 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationAttachObserver.kt @@ -0,0 +1,5 @@ +package com.swmansion.rnscreens.gamma.stack.header.configuration + +internal fun interface StackHeaderConfigurationAttachObserver { + fun onHeaderConfigurationChanged(config: StackHeaderConfiguration?) +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationChangeListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationChangeListener.kt new file mode 100644 index 0000000000..17f5e3f4e0 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationChangeListener.kt @@ -0,0 +1,5 @@ +package com.swmansion.rnscreens.gamma.stack.header.configuration + +internal fun interface StackHeaderConfigurationChangeListener { + fun onHeaderConfigurationChanged(config: StackHeaderConfigurationProviding) +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt index 54dca41df2..ded17ca0a7 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt @@ -1,8 +1,13 @@ package com.swmansion.rnscreens.gamma.stack.header.configuration +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview + internal interface StackHeaderConfigurationProviding { - val headerType: StackHeaderType + val type: StackHeaderType val title: String - val isHidden: Boolean - val isTransparent: Boolean + val hidden: Boolean + val transparent: Boolean + val leftSubview: StackHeaderSubview? + val centerSubview: StackHeaderSubview? + val rightSubview: StackHeaderSubview? } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt index ba5a65e24e..fa9a9906ab 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt @@ -1,11 +1,14 @@ package com.swmansion.rnscreens.gamma.stack.header.configuration +import android.view.View +import com.facebook.react.bridge.JSApplicationIllegalArgumentException import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.viewmanagers.RNSStackHeaderConfigurationManagerDelegate import com.facebook.react.viewmanagers.RNSStackHeaderConfigurationManagerInterface +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview @ReactModule(name = StackHeaderConfigurationViewManager.REACT_CLASS) open class StackHeaderConfigurationViewManager : @@ -23,12 +26,75 @@ open class StackHeaderConfigurationViewManager : override fun getDelegate(): ViewManagerDelegate = delegate - override fun setType(view: StackHeaderConfiguration, value: String?) = Unit - override fun setTitle(view: StackHeaderConfiguration, value: String?) = Unit - override fun setHidden(view: StackHeaderConfiguration, value: Boolean) = Unit - override fun setTransparent(view: StackHeaderConfiguration, value: Boolean) = Unit + override fun addView( + parent: StackHeaderConfiguration, + child: View, + index: Int, + ) { + require(child is StackHeaderSubview) { + "[RNScreens] StackHeaderConfiguration can only have children of type StackHeaderSubview. Received $child instead." + } + parent.addConfigSubview(child) + } + + override fun removeViewAt( + parent: StackHeaderConfiguration, + index: Int, + ) { + parent.removeConfigSubviewAt(index) + } + + override fun removeAllViews(parent: StackHeaderConfiguration) { + parent.removeAllConfigSubviews() + } + + override fun getChildCount(parent: StackHeaderConfiguration): Int = parent.configSubviewsCount + + override fun getChildAt( + parent: StackHeaderConfiguration, + index: Int, + ): View? = parent.getConfigSubviewAt(index) + + override fun onAfterUpdateTransaction(view: StackHeaderConfiguration) { + super.onAfterUpdateTransaction(view) + view.notifyConfigurationChanged() + } + + override fun setType( + view: StackHeaderConfiguration, + value: String?, + ) { + view.type = + when (value) { + "small" -> StackHeaderType.SMALL + "medium" -> StackHeaderType.MEDIUM + "large" -> StackHeaderType.LARGE + else -> throw JSApplicationIllegalArgumentException("[RNScreens] Invalid StackHeaderConfiguration type: $value.") + } + } + + override fun setTitle( + view: StackHeaderConfiguration, + value: String?, + ) { + view.title = value ?: "" + } + + override fun setHidden( + view: StackHeaderConfiguration, + value: Boolean, + ) { + view.hidden = value + } + + override fun setTransparent( + view: StackHeaderConfiguration, + value: Boolean, + ) { + view.transparent = value + } companion object { const val REACT_CLASS = "RNSStackHeaderConfiguration" } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderType.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderType.kt index 843dca0548..b9816eaaac 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderType.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderType.kt @@ -1,6 +1,6 @@ package com.swmansion.rnscreens.gamma.stack.header.configuration -internal enum class StackHeaderType { +enum class StackHeaderType { SMALL, MEDIUM, LARGE, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt index bef6f1a968..cdb0fa2cf6 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -1,6 +1,5 @@ package com.swmansion.rnscreens.gamma.stack.header.subview - import android.annotation.SuppressLint import com.facebook.react.bridge.ReactContext import com.facebook.react.views.view.ReactViewGroup @@ -9,5 +8,28 @@ import com.facebook.react.views.view.ReactViewGroup class StackHeaderSubview( val reactContext: ReactContext, ) : ReactViewGroup(reactContext) { + var type: StackHeaderSubviewType = StackHeaderSubviewType.CENTER + + override fun onLayout( + changed: Boolean, + left: Int, + top: Int, + right: Int, + bottom: Int, + ) { + super.onLayout(changed, left, top, right, bottom) + } -} \ No newline at end of file + // We want to rely on layout from Yoga instead of native Toolbar layout which tries to stretch + // subview to match parent. + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + if (width > 0 && height > 0) { + setMeasuredDimension(width, height) + } else { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt new file mode 100644 index 0000000000..5d8e145bb0 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt @@ -0,0 +1,7 @@ +package com.swmansion.rnscreens.gamma.stack.header.subview + +enum class StackHeaderSubviewType { + LEFT, + CENTER, + RIGHT, +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt index ff7a8628c2..1edc0efb51 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt @@ -1,5 +1,6 @@ package com.swmansion.rnscreens.gamma.stack.header.subview +import com.facebook.react.bridge.JSApplicationIllegalArgumentException import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager @@ -23,9 +24,20 @@ open class StackHeaderSubviewViewManager : override fun getDelegate(): ViewManagerDelegate = delegate - override fun setType(view: StackHeaderSubview, value: String?) = Unit + override fun setType( + view: StackHeaderSubview, + value: String?, + ) { + view.type = + when (value) { + "left" -> StackHeaderSubviewType.LEFT + "center" -> StackHeaderSubviewType.CENTER + "right" -> StackHeaderSubviewType.RIGHT + else -> throw JSApplicationIllegalArgumentException("[RNScreens] Invalid StackHeaderSubview type: $value") + } + } companion object { const val REACT_CLASS = "RNSStackHeaderSubview" } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt index 91fba70a19..f4edd5a8fe 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt @@ -7,6 +7,8 @@ import androidx.lifecycle.LifecycleOwner import com.facebook.react.uimanager.ThemedReactContext import com.swmansion.rnscreens.ext.findFragmentOrNull import com.swmansion.rnscreens.gamma.common.FragmentProviding +import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfiguration +import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationAttachObserver import com.swmansion.rnscreens.gamma.stack.host.StackHost import java.lang.ref.WeakReference import kotlin.properties.Delegates @@ -61,6 +63,28 @@ class StackScreen( height: Int? = null, ) = shadowStateProxy.updateStateIfNeeded(x, y, width, height) + // --- Header configuration --- + // StackScreen is a dumb pass-through. It stores the header and notifies an observer. + // The observer is set by whoever manages the header (e.g., StackHeaderCoordinatorLayout). + // WeakReference avoids a cycle: CoordinatorLayout → StackScreen → observer → CoordinatorLayout. + + internal var headerConfiguration: StackHeaderConfiguration? = null + private set + + internal var headerConfigurationAttachObserver: WeakReference? = null + + internal fun attachHeaderConfiguration(header: StackHeaderConfiguration) { + headerConfiguration = header + headerConfigurationAttachObserver?.get()?.onHeaderConfigurationChanged(header) + } + + internal fun detachHeaderConfiguration(header: StackHeaderConfiguration) { + if (headerConfiguration === header) { + headerConfiguration = null + headerConfigurationAttachObserver?.get()?.onHeaderConfigurationChanged(null) + } + } + internal lateinit var eventEmitter: StackScreenEventEmitter /** diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt index b1a0bdfd9e..08d89191a7 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt @@ -1,5 +1,6 @@ package com.swmansion.rnscreens.gamma.stack.screen +import android.view.View import com.facebook.react.bridge.JSApplicationIllegalArgumentException import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.ReactStylesDiffMap @@ -10,6 +11,7 @@ import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.viewmanagers.RNSStackScreenManagerDelegate import com.facebook.react.viewmanagers.RNSStackScreenManagerInterface import com.swmansion.rnscreens.gamma.helpers.makeEventRegistrationInfo +import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfiguration import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidAppearEvent import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidDisappearEvent import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDismissEvent @@ -29,6 +31,29 @@ class StackScreenViewManager : override fun createViewInstance(reactContext: ThemedReactContext) = StackScreen(reactContext) + override fun addView( + parent: StackScreen, + child: View, + index: Int, + ) { + if (child is StackHeaderConfiguration) { + parent.attachHeaderConfiguration(child) + } else { + super.addView(parent, child, index) + } + } + + override fun removeView( + parent: StackScreen, + view: View, + ) { + if (view is StackHeaderConfiguration) { + parent.detachHeaderConfiguration(view) + } else { + super.removeView(parent, view) + } + } + override fun addEventEmitters( reactContext: ThemedReactContext, view: StackScreen, diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.tsx b/apps/src/shared/gamma/containers/stack/StackContainer.tsx index 0af646169c..c85eab5983 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.tsx +++ b/apps/src/shared/gamma/containers/stack/StackContainer.tsx @@ -20,6 +20,7 @@ import { useRenderDebugInfo, } from 'react-native-screens/private'; import { useParentNavigationEffect } from './hooks/useParentNavigationEffect'; +import { Text } from 'react-native'; export function StackContainer({ routeConfigs }: StackContainerProps) { useSanitizeRouteConfigs(routeConfigs); @@ -82,6 +83,19 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { onNativeDismiss={onScreenNativelyDismissed}> + + + left + + {/* + center + */} + + rightvery + + ); diff --git a/src/components/gamma/stack/header/StackHeaderSubview.tsx b/src/components/gamma/stack/header/StackHeaderSubview.tsx index 4b0a9ddcff..8d6c9c0b63 100644 --- a/src/components/gamma/stack/header/StackHeaderSubview.tsx +++ b/src/components/gamma/stack/header/StackHeaderSubview.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { StyleSheet } from 'react-native'; import { StackHeaderSubviewProps } from './StackHeaderSubview.types'; import StackHeaderSubviewNativeComponent from '../../../../fabric/gamma/stack/StackHeaderSubviewNativeComponent'; @@ -11,7 +10,7 @@ function StackHeaderSubview(props: StackHeaderSubviewProps) { return ( {children} From 79b928dcc9900cc2e1453c67d20cefdb7bbf68ac Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 23 Mar 2026 15:55:25 +0100 Subject: [PATCH 24/92] drop support for center subview for collapsing header --- .../stack/header/StackHeaderAppBarLayout.kt | 16 +++++------- .../stack/header/StackHeaderCoordinator.kt | 26 +++++++++++-------- .../gamma/containers/stack/StackContainer.tsx | 6 ++--- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt index d579d57ffb..7d1174ff24 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt @@ -2,8 +2,6 @@ package com.swmansion.rnscreens.gamma.stack.header import android.annotation.SuppressLint import android.content.Context -import android.graphics.Color -import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import androidx.coordinatorlayout.widget.CoordinatorLayout @@ -103,13 +101,13 @@ internal sealed class StackHeaderAppBarLayout( scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED or SCROLL_FLAG_SNAP // scrollFlags = SCROLL_FLAG_NO_SCROLL } - addView( - View(context).apply { - layoutParams = CollapsingToolbarLayout.LayoutParams(1080, 900) - setBackgroundColor(Color.BLUE) - fitsSystemWindows = true - }, - ) +// addView( +// View(context).apply { +// layoutParams = CollapsingToolbarLayout.LayoutParams(1080, 900) +// setBackgroundColor(Color.BLUE) +// fitsSystemWindows = true +// }, +// ) addView(toolbar) } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 8c1643bc20..b76d53bb6d 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -2,6 +2,7 @@ package com.swmansion.rnscreens.gamma.stack.header import android.content.Context import android.text.TextUtils +import android.util.Log import android.view.Gravity import android.view.View import android.view.ViewGroup @@ -65,7 +66,6 @@ internal class StackHeaderCoordinator( // (MDC limitation: can't change custom views at runtime in CollapsingToolbarLayout) if (appBarLayout is StackHeaderAppBarLayout.Collapsing) { if (subviewWidthChanged(config.leftSubview, lastLeftSubviewWidth)) return true - if (subviewWidthChanged(config.centerSubview, lastCenterSubviewWidth)) return true if (subviewWidthChanged(config.rightSubview, lastRightSubviewWidth)) return true } @@ -102,6 +102,7 @@ internal class StackHeaderCoordinator( appBarLayout = appBar populateToolbar(appBar.toolbar, config) coordinatorLayout.addView(appBar, 0) + appBar.requestApplyInsets() } currentHeaderType = desiredType @@ -156,10 +157,14 @@ internal class StackHeaderCoordinator( } if (config.centerSubview != null) { - // Center subview replaces title for all header types - managedTitleView = null - detachFromCurrentParent(config.centerSubview) - toolbar.addView(config.centerSubview, centerGravityParams()) + if (appBarLayout is StackHeaderAppBarLayout.Small) { + toolbar.removeView(managedTitleView) + managedTitleView = null + detachFromCurrentParent(config.centerSubview) + toolbar.addView(config.centerSubview, centerGravityParams()) + } else { + Log.e(TAG, "[RNScreens] Center subview is supported only for small header type.") + } } else if (appBarLayout is StackHeaderAppBarLayout.Small) { // Small header: managed title view (we can't use native title // because we can't insert custom views before it) @@ -167,7 +172,6 @@ internal class StackHeaderCoordinator( managedTitleView = titleView toolbar.addView(titleView) } - // Collapsing: no title in toolbar — CTL handles it (canvas-drawn) } private fun createManagedTitleView(toolbar: Toolbar): AppCompatTextView = @@ -232,11 +236,7 @@ internal class StackHeaderCoordinator( } is StackHeaderAppBarLayout.Collapsing -> { - if (config.centerSubview != null) { - appBar.collapsingToolbarLayout.title = null - } else { - appBar.collapsingToolbarLayout.title = config.title - } + appBar.collapsingToolbarLayout.title = config.title } } } @@ -262,4 +262,8 @@ internal class StackHeaderCoordinator( stackScreenWrapper.layoutParams = params } } + + companion object { + const val TAG = "StackHeaderCoordinator" + } } diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.tsx b/apps/src/shared/gamma/containers/stack/StackContainer.tsx index c85eab5983..17b2229494 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.tsx +++ b/apps/src/shared/gamma/containers/stack/StackContainer.tsx @@ -85,13 +85,13 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { + type="large"> left - {/* + center - */} + rightvery From 16ac12642f717bc89f775375d107dc767774a240 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 23 Mar 2026 17:15:52 +0100 Subject: [PATCH 25/92] add background subview --- .../stack/header/StackHeaderAppBarLayout.kt | 22 +--- .../stack/header/StackHeaderCoordinator.kt | 111 +++++++++++++----- .../configuration/StackHeaderConfiguration.kt | 17 ++- .../StackHeaderConfigurationProviding.kt | 9 +- .../header/subview/StackHeaderSubview.kt | 19 ++- .../subview/StackHeaderSubviewCollapseMode.kt | 7 ++ ...tackHeaderSubviewPropertyChangeListener.kt | 5 + .../subview/StackHeaderSubviewProviding.kt | 9 ++ .../header/subview/StackHeaderSubviewType.kt | 1 + .../subview/StackHeaderSubviewViewManager.kt | 14 +++ .../gamma/containers/stack/StackContainer.tsx | 19 ++- .../gamma/stack/header/StackHeaderSubview.tsx | 8 +- .../stack/header/StackHeaderSubview.types.ts | 12 +- .../StackHeaderSubviewNativeComponent.ts | 10 +- 14 files changed, 203 insertions(+), 60 deletions(-) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewPropertyChangeListener.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt index 7d1174ff24..6e29733dcf 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt @@ -58,15 +58,6 @@ internal sealed class StackHeaderAppBarLayout( context: Context, val type: StackHeaderType, ) : StackHeaderAppBarLayout(context) { - init { - require( - type == StackHeaderType.MEDIUM || - type == StackHeaderType.LARGE, - ) { - "[RNScreens] Collapsing StackScreenAppBarLayout must be MEDIUM or LARGE type." - } - } - override val toolbar = MaterialToolbar(context).apply { elevation = 0f @@ -101,18 +92,17 @@ internal sealed class StackHeaderAppBarLayout( scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED or SCROLL_FLAG_SNAP // scrollFlags = SCROLL_FLAG_NO_SCROLL } -// addView( -// View(context).apply { -// layoutParams = CollapsingToolbarLayout.LayoutParams(1080, 900) -// setBackgroundColor(Color.BLUE) -// fitsSystemWindows = true -// }, -// ) addView(toolbar) } } init { + require( + type == StackHeaderType.MEDIUM || + type == StackHeaderType.LARGE, + ) { + "[RNScreens] Collapsing StackScreenAppBarLayout must be MEDIUM or LARGE type." + } addView(collapsingToolbarLayout) } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index b76d53bb6d..36f361a806 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -6,16 +6,18 @@ import android.util.Log import android.view.Gravity import android.view.View import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.widget.TextViewCompat import com.google.android.material.R -import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.appbar.CollapsingToolbarLayout import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationProviding import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderType -import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewCollapseMode +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewProviding internal class StackHeaderCoordinator( context: Context, @@ -24,15 +26,17 @@ internal class StackHeaderCoordinator( private var appBarLayout: StackHeaderAppBarLayout? = null private var currentHeaderType: StackHeaderType? = null - private var attachedLeftSubview: StackHeaderSubview? = null + private var attachedLeftSubview: StackHeaderSubviewProviding? = null private var lastLeftSubviewWidth: Int? = null - private var attachedCenterSubview: StackHeaderSubview? = null + private var attachedCenterSubview: StackHeaderSubviewProviding? = null private var lastCenterSubviewWidth: Int? = null - private var attachedRightSubview: StackHeaderSubview? = null + private var attachedRightSubview: StackHeaderSubviewProviding? = null private var lastRightSubviewWidth: Int? = null + private var attachedBackgroundSubview: StackHeaderSubviewProviding? = null + private var managedTitleView: AppCompatTextView? = null private val wrappedContext = @@ -61,6 +65,7 @@ internal class StackHeaderCoordinator( if (config.leftSubview !== attachedLeftSubview) return true if (config.centerSubview !== attachedCenterSubview) return true if (config.rightSubview !== attachedRightSubview) return true + if (config.backgroundSubview !== attachedBackgroundSubview) return true // Collapsing headers need rebuild when subview sizes change // (MDC limitation: can't change custom views at runtime in CollapsingToolbarLayout) @@ -73,18 +78,18 @@ internal class StackHeaderCoordinator( } private fun subviewWidthChanged( - subview: StackHeaderSubview?, + subview: StackHeaderSubviewProviding?, lastSubviewWidth: Int?, ): Boolean { if (subview == null && lastSubviewWidth == null) return false if (subview == null || lastSubviewWidth == null) return true - return subview.width != lastSubviewWidth + return subview.view.width != lastSubviewWidth } private fun snapshotSubviewWidths(config: StackHeaderConfigurationProviding) { - lastLeftSubviewWidth = config.leftSubview?.width - lastCenterSubviewWidth = config.centerSubview?.width - lastRightSubviewWidth = config.rightSubview?.width + lastLeftSubviewWidth = config.leftSubview?.view?.width + lastCenterSubviewWidth = config.centerSubview?.view?.width + lastRightSubviewWidth = config.rightSubview?.view?.width } // --- Full rebuild --- @@ -100,7 +105,7 @@ internal class StackHeaderCoordinator( if (desiredType != null) { val appBar = StackHeaderAppBarLayout.create(wrappedContext, desiredType) appBarLayout = appBar - populateToolbar(appBar.toolbar, config) + populateToolbar(appBar, config) coordinatorLayout.addView(appBar, 0) appBar.requestApplyInsets() } @@ -109,14 +114,15 @@ internal class StackHeaderCoordinator( attachedLeftSubview = config.leftSubview attachedCenterSubview = config.centerSubview attachedRightSubview = config.rightSubview + attachedBackgroundSubview = config.backgroundSubview snapshotSubviewWidths(config) } private fun teardown(coordinatorLayout: StackHeaderCoordinatorLayout) { - // Subviews need to be detached from toolbar before removing the app bar, + // Subviews need to be detached before removing the app bar, // otherwise they'd be destroyed with it - detachSubviewsFromToolbar() + detachSubviews() appBarLayout?.let { coordinatorLayout.removeView(it) } appBarLayout = null managedTitleView = null @@ -124,21 +130,30 @@ internal class StackHeaderCoordinator( attachedLeftSubview = null attachedCenterSubview = null attachedRightSubview = null + attachedBackgroundSubview = null } - private fun detachSubviewsFromToolbar() { - val toolbar = appBarLayout?.toolbar ?: return - attachedLeftSubview?.let { toolbar.removeView(it) } - attachedCenterSubview?.let { toolbar.removeView(it) } - attachedRightSubview?.let { toolbar.removeView(it) } + private fun detachSubviews() { + val appBar = appBarLayout ?: return + val toolbar = appBar.toolbar + + attachedLeftSubview?.let { toolbar.removeView(it.view) } + attachedCenterSubview?.let { toolbar.removeView(it.view) } + attachedRightSubview?.let { toolbar.removeView(it.view) } + + if (appBar is StackHeaderAppBarLayout.Collapsing) { + attachedBackgroundSubview?.let { appBar.collapsingToolbarLayout.removeView(it.view) } + } } // --- Toolbar population (called during rebuild) --- private fun populateToolbar( - toolbar: MaterialToolbar, + appBar: StackHeaderAppBarLayout, config: StackHeaderConfigurationProviding, ) { + val toolbar = appBar.toolbar + // Never use native title — we manage our own toolbar.title = null @@ -147,31 +162,48 @@ internal class StackHeaderCoordinator( // so it gets the remaining space after left and right are accounted for. config.leftSubview?.let { - detachFromCurrentParent(it) - toolbar.addView(it, startGravityParams()) + detachFromCurrentParent(it.view) + toolbar.addView(it.view, startGravityParams()) } config.rightSubview?.let { - detachFromCurrentParent(it) - toolbar.addView(it, endGravityParams()) + detachFromCurrentParent(it.view) + toolbar.addView(it.view, endGravityParams()) } - if (config.centerSubview != null) { - if (appBarLayout is StackHeaderAppBarLayout.Small) { + val centerSubview = config.centerSubview + if (centerSubview != null) { + if (appBar is StackHeaderAppBarLayout.Small) { toolbar.removeView(managedTitleView) managedTitleView = null - detachFromCurrentParent(config.centerSubview) - toolbar.addView(config.centerSubview, centerGravityParams()) + detachFromCurrentParent(centerSubview.view) + toolbar.addView(centerSubview.view, centerGravityParams()) } else { Log.e(TAG, "[RNScreens] Center subview is supported only for small header type.") } - } else if (appBarLayout is StackHeaderAppBarLayout.Small) { + } else if (appBar is StackHeaderAppBarLayout.Small) { // Small header: managed title view (we can't use native title // because we can't insert custom views before it) val titleView = createManagedTitleView(toolbar) managedTitleView = titleView toolbar.addView(titleView) } + + // Background subview goes into CollapsingToolbarLayout, behind the toolbar + val backgroundSubview = config.backgroundSubview + if (appBar is StackHeaderAppBarLayout.Collapsing && backgroundSubview != null) { + detachFromCurrentParent(backgroundSubview.view) + backgroundSubview.view.fitsSystemWindows = true + appBar.collapsingToolbarLayout.addView( + backgroundSubview.view, + 0, + CollapsingToolbarLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT).apply { + collapseMode = toNativeCollapseMode(backgroundSubview.collapseMode) + }, + ) + } else if (backgroundSubview != null) { + Log.e(TAG, "[RNScreens] Background subview is supported only for collapsing header types (medium, large).") + } } private fun createManagedTitleView(toolbar: Toolbar): AppCompatTextView = @@ -237,11 +269,32 @@ internal class StackHeaderCoordinator( is StackHeaderAppBarLayout.Collapsing -> { appBar.collapsingToolbarLayout.title = config.title + applyBackgroundCollapseMode(config) } } } - // --- Content behavior (unchanged) --- + private fun applyBackgroundCollapseMode( + config: StackHeaderConfigurationProviding, + ) { + val backgroundSubview = config.backgroundSubview ?: return + val params = backgroundSubview.view.layoutParams as? CollapsingToolbarLayout.LayoutParams ?: return + val desired = toNativeCollapseMode(backgroundSubview.collapseMode) + if (params.collapseMode != desired) { + params.collapseMode = desired + } + } + + // --- Collapse mode mapping --- + + private fun toNativeCollapseMode(mode: StackHeaderSubviewCollapseMode): Int = + when (mode) { + StackHeaderSubviewCollapseMode.OFF -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_OFF + StackHeaderSubviewCollapseMode.PIN -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PIN + StackHeaderSubviewCollapseMode.PARALLAX -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PARALLAX + } + + // --- Content behavior --- private fun applyContentBehavior( coordinatorLayout: StackHeaderCoordinatorLayout, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt index ea25d4750b..8b688585fd 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import com.facebook.react.bridge.ReactContext import com.facebook.react.views.view.ReactViewGroup import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewPropertyChangeListener import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewType import java.lang.ref.WeakReference @@ -11,7 +12,8 @@ import java.lang.ref.WeakReference class StackHeaderConfiguration( val reactContext: ReactContext, ) : ReactViewGroup(reactContext), - StackHeaderConfigurationProviding { + StackHeaderConfigurationProviding, + StackHeaderSubviewPropertyChangeListener { override var type: StackHeaderType = StackHeaderType.SMALL override var title: String = "" override var hidden: Boolean = false @@ -23,6 +25,8 @@ class StackHeaderConfiguration( private set override var rightSubview: StackHeaderSubview? = null private set + override var backgroundSubview: StackHeaderSubview? = null + private set internal var configurationChangeListener: WeakReference? = null @@ -30,6 +34,8 @@ class StackHeaderConfiguration( configurationChangeListener?.get()?.onHeaderConfigurationChanged(this) } + override fun onSubviewPropertyChanged() = notifyConfigurationChanged() + private val subviewLayoutChangeListener = OnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ -> val widthChanged = (right - left) != (oldRight - oldLeft) @@ -43,17 +49,21 @@ class StackHeaderConfiguration( StackHeaderSubviewType.LEFT -> leftSubview = headerSubview StackHeaderSubviewType.CENTER -> centerSubview = headerSubview StackHeaderSubviewType.RIGHT -> rightSubview = headerSubview + StackHeaderSubviewType.BACKGROUND -> backgroundSubview = headerSubview } headerSubview.addOnLayoutChangeListener(subviewLayoutChangeListener) + headerSubview.propertyChangeListener = WeakReference(this) notifyConfigurationChanged() } internal fun removeConfigSubview(headerSubview: StackHeaderSubview) { headerSubview.removeOnLayoutChangeListener(subviewLayoutChangeListener) + headerSubview.propertyChangeListener = null when (headerSubview.type) { StackHeaderSubviewType.LEFT -> leftSubview = null StackHeaderSubviewType.CENTER -> centerSubview = null StackHeaderSubviewType.RIGHT -> rightSubview = null + StackHeaderSubviewType.BACKGROUND -> backgroundSubview = null } notifyConfigurationChanged() } @@ -66,13 +76,14 @@ class StackHeaderConfiguration( leftSubview = null centerSubview = null rightSubview = null + backgroundSubview = null notifyConfigurationChanged() } internal val configSubviewsCount: Int - get() = listOfNotNull(leftSubview, centerSubview, rightSubview).size + get() = listOfNotNull(leftSubview, centerSubview, rightSubview, backgroundSubview).size internal fun getConfigSubviewAt(index: Int): StackHeaderSubview? = - listOfNotNull(leftSubview, centerSubview, rightSubview).getOrNull(index) + listOfNotNull(leftSubview, centerSubview, rightSubview, backgroundSubview).getOrNull(index) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt index ded17ca0a7..4c265c523a 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt @@ -1,13 +1,14 @@ package com.swmansion.rnscreens.gamma.stack.header.configuration -import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewProviding internal interface StackHeaderConfigurationProviding { val type: StackHeaderType val title: String val hidden: Boolean val transparent: Boolean - val leftSubview: StackHeaderSubview? - val centerSubview: StackHeaderSubview? - val rightSubview: StackHeaderSubview? + val leftSubview: StackHeaderSubviewProviding? + val centerSubview: StackHeaderSubviewProviding? + val rightSubview: StackHeaderSubviewProviding? + val backgroundSubview: StackHeaderSubviewProviding? } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt index cdb0fa2cf6..6bc20f4e46 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -1,14 +1,29 @@ package com.swmansion.rnscreens.gamma.stack.header.subview import android.annotation.SuppressLint +import android.view.View import com.facebook.react.bridge.ReactContext import com.facebook.react.views.view.ReactViewGroup +import java.lang.ref.WeakReference @SuppressLint("ViewConstructor") class StackHeaderSubview( val reactContext: ReactContext, -) : ReactViewGroup(reactContext) { - var type: StackHeaderSubviewType = StackHeaderSubviewType.CENTER +) : ReactViewGroup(reactContext), + StackHeaderSubviewProviding { + override var type: StackHeaderSubviewType = StackHeaderSubviewType.CENTER + + override var collapseMode: StackHeaderSubviewCollapseMode = StackHeaderSubviewCollapseMode.PIN + set(value) { + if (field != value) { + field = value + propertyChangeListener?.get()?.onSubviewPropertyChanged() + } + } + + override val view: View get() = this + + internal var propertyChangeListener: WeakReference? = null override fun onLayout( changed: Boolean, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt new file mode 100644 index 0000000000..16f96d6c91 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt @@ -0,0 +1,7 @@ +package com.swmansion.rnscreens.gamma.stack.header.subview + +enum class StackHeaderSubviewCollapseMode { + OFF, + PIN, + PARALLAX, +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewPropertyChangeListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewPropertyChangeListener.kt new file mode 100644 index 0000000000..43fc023b23 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewPropertyChangeListener.kt @@ -0,0 +1,5 @@ +package com.swmansion.rnscreens.gamma.stack.header.subview + +internal fun interface StackHeaderSubviewPropertyChangeListener { + fun onSubviewPropertyChanged() +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt new file mode 100644 index 0000000000..0f8c0cb987 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt @@ -0,0 +1,9 @@ +package com.swmansion.rnscreens.gamma.stack.header.subview + +import android.view.View + +interface StackHeaderSubviewProviding { + val type: StackHeaderSubviewType + val collapseMode: StackHeaderSubviewCollapseMode + val view: View +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt index 5d8e145bb0..643fc522c8 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt @@ -4,4 +4,5 @@ enum class StackHeaderSubviewType { LEFT, CENTER, RIGHT, + BACKGROUND, } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt index 1edc0efb51..206f43fb9b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt @@ -33,10 +33,24 @@ open class StackHeaderSubviewViewManager : "left" -> StackHeaderSubviewType.LEFT "center" -> StackHeaderSubviewType.CENTER "right" -> StackHeaderSubviewType.RIGHT + "background" -> StackHeaderSubviewType.BACKGROUND else -> throw JSApplicationIllegalArgumentException("[RNScreens] Invalid StackHeaderSubview type: $value") } } + override fun setCollapseMode( + view: StackHeaderSubview, + value: String?, + ) { + view.collapseMode = + when (value) { + "off" -> StackHeaderSubviewCollapseMode.OFF + "pin" -> StackHeaderSubviewCollapseMode.PIN + "parallax" -> StackHeaderSubviewCollapseMode.PARALLAX + else -> throw JSApplicationIllegalArgumentException("[RNScreens] Invalid StackHeaderSubview collapseMode: $value") + } + } + companion object { const val REACT_CLASS = "RNSStackHeaderSubview" } diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.tsx b/apps/src/shared/gamma/containers/stack/StackContainer.tsx index 17b2229494..bad8e00dd5 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.tsx +++ b/apps/src/shared/gamma/containers/stack/StackContainer.tsx @@ -20,7 +20,8 @@ import { useRenderDebugInfo, } from 'react-native-screens/private'; import { useParentNavigationEffect } from './hooks/useParentNavigationEffect'; -import { Text } from 'react-native'; +import { Text, View } from 'react-native'; +import LongText from '../../../../../src/shared/LongText'; export function StackContainer({ routeConfigs }: StackContainerProps) { useSanitizeRouteConfigs(routeConfigs); @@ -86,7 +87,19 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { - + + + + + + {/* left @@ -94,7 +107,7 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { rightvery - + */} diff --git a/src/components/gamma/stack/header/StackHeaderSubview.tsx b/src/components/gamma/stack/header/StackHeaderSubview.tsx index 8d6c9c0b63..c3eff3dfaf 100644 --- a/src/components/gamma/stack/header/StackHeaderSubview.tsx +++ b/src/components/gamma/stack/header/StackHeaderSubview.tsx @@ -10,7 +10,13 @@ function StackHeaderSubview(props: StackHeaderSubviewProps) { return ( {children} diff --git a/src/components/gamma/stack/header/StackHeaderSubview.types.ts b/src/components/gamma/stack/header/StackHeaderSubview.types.ts index 9f12c3900d..6368e1d632 100644 --- a/src/components/gamma/stack/header/StackHeaderSubview.types.ts +++ b/src/components/gamma/stack/header/StackHeaderSubview.types.ts @@ -1,9 +1,19 @@ import { ViewProps } from 'react-native'; -export type StackHeaderSubviewTypeAndroid = 'left' | 'center' | 'right'; +export type StackHeaderSubviewTypeAndroid = + | 'left' + | 'center' + | 'right' + | 'background'; + +export type StackHeaderSubviewBackgroundCollapseModeAndroid = + | 'off' + | 'pin' + | 'parallax'; export type StackHeaderSubviewProps = { children?: ViewProps['children']; type?: StackHeaderSubviewTypeAndroid; + collapseMode?: StackHeaderSubviewBackgroundCollapseModeAndroid; }; diff --git a/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts index ab4b8bf6c0..5a644cb9f0 100644 --- a/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts +++ b/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts @@ -3,10 +3,18 @@ import type { CodegenTypes as CT, ViewProps } from 'react-native'; import { codegenNativeComponent } from 'react-native'; -type StackHeaderSubviewTypeAndroid = 'left' | 'center' | 'right'; +type StackHeaderSubviewTypeAndroid = 'left' | 'center' | 'right' | 'background'; +type StackHeaderSubviewBackgroundCollapseModeAndroid = + | 'off' + | 'pin' + | 'parallax'; export interface NativeProps extends ViewProps { type?: CT.WithDefault; + collapseMode?: CT.WithDefault< + StackHeaderSubviewBackgroundCollapseModeAndroid, + 'pin' + >; } export default codegenNativeComponent('RNSStackHeaderSubview', { From 7b5ffb4ad37b35703565a4b58b8517af96bf7d5a Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 23 Mar 2026 18:39:31 +0100 Subject: [PATCH 26/92] refactor --- .../stack/header/StackHeaderCoordinator.kt | 220 +++++++++--------- .../header/StackHeaderCoordinatorLayout.kt | 60 ++--- .../OnHeaderConfigurationAttachListener.kt | 5 + .../OnHeaderConfigurationChangeListener.kt | 5 + .../configuration/StackHeaderConfiguration.kt | 34 +-- .../StackHeaderConfigurationAttachObserver.kt | 5 - .../StackHeaderConfigurationChangeListener.kt | 5 - .../OnStackHeaderSubviewChangeListener.kt | 5 + .../header/subview/StackHeaderSubview.kt | 28 ++- ...tackHeaderSubviewPropertyChangeListener.kt | 5 - .../subview/StackHeaderSubviewViewManager.kt | 2 +- .../gamma/stack/screen/StackScreen.kt | 13 +- 12 files changed, 193 insertions(+), 194 deletions(-) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationAttachListener.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationChangeListener.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationAttachObserver.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationChangeListener.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/OnStackHeaderSubviewChangeListener.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewPropertyChangeListener.kt diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 36f361a806..b0944847d6 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -23,27 +23,30 @@ internal class StackHeaderCoordinator( context: Context, private val onHeaderHeightChanged: (headerHeight: Int) -> Unit, ) { + private val wrappedContext = + ContextThemeWrapper( + context, + R.style.Theme_Material3_DayNight_NoActionBar, + ) + private var appBarLayout: StackHeaderAppBarLayout? = null - private var currentHeaderType: StackHeaderType? = null + private var currentHeaderTypeOrNull: StackHeaderType? = null private var attachedLeftSubview: StackHeaderSubviewProviding? = null - private var lastLeftSubviewWidth: Int? = null - private var attachedCenterSubview: StackHeaderSubviewProviding? = null - private var lastCenterSubviewWidth: Int? = null - private var attachedRightSubview: StackHeaderSubviewProviding? = null - private var lastRightSubviewWidth: Int? = null - private var attachedBackgroundSubview: StackHeaderSubviewProviding? = null - private var managedTitleView: AppCompatTextView? = null + // Width snapshots for collapsing header rebuild detection. + // CollapsingToolbarLayout can't resize custom views at runtime, + // so we must rebuild the hierarchy when toolbar subview widths change. + private var lastLeftSubviewWidth: Int? = null + private var lastCenterSubviewWidth: Int? = null + private var lastRightSubviewWidth: Int? = null - private val wrappedContext = - ContextThemeWrapper( - context, - R.style.Theme_Material3_DayNight_NoActionBar, - ) + // For small header, we need to use custom title view in order to + // render a subview to the left of the title. + private var managedTitleView: AppCompatTextView? = null internal fun applyHeaderConfiguration( coordinatorLayout: StackHeaderCoordinatorLayout, @@ -57,42 +60,33 @@ internal class StackHeaderCoordinator( coordinatorLayout.maybeRequestLayoutContainer() } - // --- Rebuild detection --- + // region Rebuild detection private fun requiresRebuild(config: StackHeaderConfigurationProviding): Boolean { - val desiredType = if (config.hidden) null else config.type - if (desiredType != currentHeaderType) return true + val desiredTypeOrNull = if (config.hidden) null else config.type + if (desiredTypeOrNull != currentHeaderTypeOrNull) return true if (config.leftSubview !== attachedLeftSubview) return true if (config.centerSubview !== attachedCenterSubview) return true if (config.rightSubview !== attachedRightSubview) return true if (config.backgroundSubview !== attachedBackgroundSubview) return true - // Collapsing headers need rebuild when subview sizes change - // (MDC limitation: can't change custom views at runtime in CollapsingToolbarLayout) if (appBarLayout is StackHeaderAppBarLayout.Collapsing) { - if (subviewWidthChanged(config.leftSubview, lastLeftSubviewWidth)) return true - if (subviewWidthChanged(config.rightSubview, lastRightSubviewWidth)) return true + if (config.leftSubview?.view?.width != lastLeftSubviewWidth) return true + if (config.rightSubview?.view?.width != lastRightSubviewWidth) return true } return false } - private fun subviewWidthChanged( - subview: StackHeaderSubviewProviding?, - lastSubviewWidth: Int?, - ): Boolean { - if (subview == null && lastSubviewWidth == null) return false - if (subview == null || lastSubviewWidth == null) return true - return subview.view.width != lastSubviewWidth - } - private fun snapshotSubviewWidths(config: StackHeaderConfigurationProviding) { lastLeftSubviewWidth = config.leftSubview?.view?.width lastCenterSubviewWidth = config.centerSubview?.view?.width lastRightSubviewWidth = config.rightSubview?.view?.width } - // --- Full rebuild --- + // endregion + + // region Full rebuild private fun rebuild( coordinatorLayout: StackHeaderCoordinatorLayout, @@ -100,33 +94,30 @@ internal class StackHeaderCoordinator( ) { teardown(coordinatorLayout) - val desiredType = if (config.hidden) null else config.type + val desiredTypeOrNull = if (config.hidden) null else config.type - if (desiredType != null) { - val appBar = StackHeaderAppBarLayout.create(wrappedContext, desiredType) + if (desiredTypeOrNull != null) { + val appBar = StackHeaderAppBarLayout.create(wrappedContext, desiredTypeOrNull) appBarLayout = appBar - populateToolbar(appBar, config) + populateAppBar(appBar, config) coordinatorLayout.addView(appBar, 0) appBar.requestApplyInsets() } - currentHeaderType = desiredType + currentHeaderTypeOrNull = desiredTypeOrNull attachedLeftSubview = config.leftSubview attachedCenterSubview = config.centerSubview attachedRightSubview = config.rightSubview attachedBackgroundSubview = config.backgroundSubview - snapshotSubviewWidths(config) } private fun teardown(coordinatorLayout: StackHeaderCoordinatorLayout) { - // Subviews need to be detached before removing the app bar, - // otherwise they'd be destroyed with it detachSubviews() appBarLayout?.let { coordinatorLayout.removeView(it) } appBarLayout = null managedTitleView = null - currentHeaderType = null + currentHeaderTypeOrNull = null attachedLeftSubview = null attachedCenterSubview = null attachedRightSubview = null @@ -135,32 +126,28 @@ internal class StackHeaderCoordinator( private fun detachSubviews() { val appBar = appBarLayout ?: return - val toolbar = appBar.toolbar - attachedLeftSubview?.let { toolbar.removeView(it.view) } - attachedCenterSubview?.let { toolbar.removeView(it.view) } - attachedRightSubview?.let { toolbar.removeView(it.view) } + attachedLeftSubview?.let { appBar.toolbar.removeView(it.view) } + attachedCenterSubview?.let { appBar.toolbar.removeView(it.view) } + attachedRightSubview?.let { appBar.toolbar.removeView(it.view) } if (appBar is StackHeaderAppBarLayout.Collapsing) { attachedBackgroundSubview?.let { appBar.collapsingToolbarLayout.removeView(it.view) } } } - // --- Toolbar population (called during rebuild) --- + // endregion - private fun populateToolbar( + // region App bar population + + private fun populateAppBar( appBar: StackHeaderAppBarLayout, config: StackHeaderConfigurationProviding, ) { val toolbar = appBar.toolbar - // Never use native title — we manage our own - toolbar.title = null - - // Order matters for measurement: left first, then right, then title/center last. - // Toolbar measures children by index order. Title should be measured last - // so it gets the remaining space after left and right are accounted for. - + // Toolbar measures children in insertion order. Left and right go first so the + // title/center gets the remaining space. config.leftSubview?.let { detachFromCurrentParent(it.view) toolbar.addView(it.view, startGravityParams()) @@ -171,44 +158,61 @@ internal class StackHeaderCoordinator( toolbar.addView(it.view, endGravityParams()) } + populateTitleOrCenter(appBar, toolbar, config) + populateBackground(appBar, config) + } + + private fun populateTitleOrCenter( + appBar: StackHeaderAppBarLayout, + toolbar: Toolbar, + config: StackHeaderConfigurationProviding, + ) { val centerSubview = config.centerSubview if (centerSubview != null) { if (appBar is StackHeaderAppBarLayout.Small) { toolbar.removeView(managedTitleView) managedTitleView = null + detachFromCurrentParent(centerSubview.view) + toolbar.addView(centerSubview.view, centerGravityParams()) } else { Log.e(TAG, "[RNScreens] Center subview is supported only for small header type.") } } else if (appBar is StackHeaderAppBarLayout.Small) { - // Small header: managed title view (we can't use native title - // because we can't insert custom views before it) + // Small header needs a managed title view because we can't use + // Toolbar's native title - it would be laid out to the left of left subview. val titleView = createManagedTitleView(toolbar) managedTitleView = titleView toolbar.addView(titleView) } + } - // Background subview goes into CollapsingToolbarLayout, behind the toolbar - val backgroundSubview = config.backgroundSubview - if (appBar is StackHeaderAppBarLayout.Collapsing && backgroundSubview != null) { - detachFromCurrentParent(backgroundSubview.view) - backgroundSubview.view.fitsSystemWindows = true - appBar.collapsingToolbarLayout.addView( - backgroundSubview.view, - 0, - CollapsingToolbarLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT).apply { - collapseMode = toNativeCollapseMode(backgroundSubview.collapseMode) - }, - ) - } else if (backgroundSubview != null) { + private fun populateBackground( + appBar: StackHeaderAppBarLayout, + config: StackHeaderConfigurationProviding, + ) { + val backgroundSubview = config.backgroundSubview ?: return + + if (appBar !is StackHeaderAppBarLayout.Collapsing) { Log.e(TAG, "[RNScreens] Background subview is supported only for collapsing header types (medium, large).") + return } + + detachFromCurrentParent(backgroundSubview.view) + + // Needed to extend the background under the status bar + backgroundSubview.view.fitsSystemWindows = true + + appBar.collapsingToolbarLayout.addView( + backgroundSubview.view, + 0, + CollapsingToolbarLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT), + ) } private fun createManagedTitleView(toolbar: Toolbar): AppCompatTextView = AppCompatTextView(toolbar.context).apply { - // Matches configuration from Toolbar.java setSingleLine() ellipsize = TextUtils.TruncateAt.END TextViewCompat.setTextAppearance( @@ -222,7 +226,7 @@ internal class StackHeaderCoordinator( Toolbar.LayoutParams.WRAP_CONTENT, Gravity.START, ).apply { - // TODO: this seems to be a problem with collapsing margins. + // TODO: there seems to be a problem with collapsing margins. // We will expose customization either way but we should // have consistent behavior and defaults. marginStart = toolbar.titleMarginStart + toolbar.contentInsetStart @@ -232,32 +236,9 @@ internal class StackHeaderCoordinator( } } - private fun detachFromCurrentParent(view: View?) { - (view?.parent as? ViewGroup)?.removeView(view) - } + // endregion - private fun startGravityParams() = - Toolbar.LayoutParams( - Toolbar.LayoutParams.WRAP_CONTENT, - Toolbar.LayoutParams.WRAP_CONTENT, - Gravity.START, - ) - - private fun centerGravityParams() = - Toolbar.LayoutParams( - Toolbar.LayoutParams.WRAP_CONTENT, - Toolbar.LayoutParams.WRAP_CONTENT, - Gravity.CENTER_HORIZONTAL, - ) - - private fun endGravityParams() = - Toolbar.LayoutParams( - Toolbar.LayoutParams.WRAP_CONTENT, - Toolbar.LayoutParams.WRAP_CONTENT, - Gravity.END, - ) - - // --- In-place prop updates (no rebuild needed) --- + // region In-place prop updates (no rebuild) private fun applyProps(config: StackHeaderConfigurationProviding) { val appBar = appBarLayout ?: return @@ -274,9 +255,7 @@ internal class StackHeaderCoordinator( } } - private fun applyBackgroundCollapseMode( - config: StackHeaderConfigurationProviding, - ) { + private fun applyBackgroundCollapseMode(config: StackHeaderConfigurationProviding) { val backgroundSubview = config.backgroundSubview ?: return val params = backgroundSubview.view.layoutParams as? CollapsingToolbarLayout.LayoutParams ?: return val desired = toNativeCollapseMode(backgroundSubview.collapseMode) @@ -285,16 +264,9 @@ internal class StackHeaderCoordinator( } } - // --- Collapse mode mapping --- + // endregion - private fun toNativeCollapseMode(mode: StackHeaderSubviewCollapseMode): Int = - when (mode) { - StackHeaderSubviewCollapseMode.OFF -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_OFF - StackHeaderSubviewCollapseMode.PIN -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PIN - StackHeaderSubviewCollapseMode.PARALLAX -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PARALLAX - } - - // --- Content behavior --- + // region Content behavior private fun applyContentBehavior( coordinatorLayout: StackHeaderCoordinatorLayout, @@ -316,7 +288,41 @@ internal class StackHeaderCoordinator( } } + // endregion + companion object { - const val TAG = "StackHeaderCoordinator" + private const val TAG = "StackHeaderCoordinator" + + private fun detachFromCurrentParent(view: View) { + (view.parent as? ViewGroup)?.removeView(view) + } + + private fun startGravityParams() = + Toolbar.LayoutParams( + Toolbar.LayoutParams.WRAP_CONTENT, + Toolbar.LayoutParams.WRAP_CONTENT, + Gravity.START, + ) + + private fun centerGravityParams() = + Toolbar.LayoutParams( + Toolbar.LayoutParams.WRAP_CONTENT, + Toolbar.LayoutParams.WRAP_CONTENT, + Gravity.CENTER_HORIZONTAL, + ) + + private fun endGravityParams() = + Toolbar.LayoutParams( + Toolbar.LayoutParams.WRAP_CONTENT, + Toolbar.LayoutParams.WRAP_CONTENT, + Gravity.END, + ) + + private fun toNativeCollapseMode(mode: StackHeaderSubviewCollapseMode): Int = + when (mode) { + StackHeaderSubviewCollapseMode.OFF -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_OFF + StackHeaderSubviewCollapseMode.PIN -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PIN + StackHeaderSubviewCollapseMode.PARALLAX -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PARALLAX + } } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index c6220e05b4..63b0e3597c 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -5,9 +5,9 @@ import android.content.Context import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.swmansion.rnscreens.gamma.stack.header.configuration.OnHeaderConfigurationAttachListener +import com.swmansion.rnscreens.gamma.stack.header.configuration.OnHeaderConfigurationChangeListener import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfiguration -import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationAttachObserver -import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationChangeListener import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationProviding import com.swmansion.rnscreens.gamma.stack.host.StackContainer import com.swmansion.rnscreens.gamma.stack.screen.StackScreen @@ -23,12 +23,23 @@ internal class StackHeaderCoordinatorLayout( stackScreen.updateStateIfNeeded(y = headerHeight) } - internal var stackScreenWrapper: FrameLayout + /** + * This callback is used to detect when header configuration is attached. + * This allows us to configure listener for header configuration changes. + */ + private val onHeaderConfigurationAttach = + OnHeaderConfigurationAttachListener { config -> + handleHeaderConfigurationAttach(config) + } private var isHeaderUpdatePending = false - private val headerConfigChangeListener = - StackHeaderConfigurationChangeListener { config -> + /** + * This callback is used to listen for header configuration changes. + * We use [isHeaderUpdatePending] to batch changes and pass them to [headerCoordinator]. + */ + private val onHeaderConfigurationChange = + OnHeaderConfigurationChangeListener { config -> if (!isHeaderUpdatePending) { isHeaderUpdatePending = true post { @@ -38,10 +49,7 @@ internal class StackHeaderCoordinatorLayout( } } - private val headerAttachObserver = - StackHeaderConfigurationAttachObserver { config -> - onHeaderConfigurationAvailable(config) - } + internal var stackScreenWrapper: FrameLayout init { // Needed when Transition API is in use to ensure that shadows do not disappear, @@ -59,20 +67,26 @@ internal class StackHeaderCoordinatorLayout( LayoutParams(MATCH_PARENT, MATCH_PARENT), ) - // Wire observer on StackScreen for header attach/detach notifications - stackScreen.headerConfigurationAttachObserver = WeakReference(headerAttachObserver) + // Setup header configuration attach listener. If header configuration is already available, + // use it immediately. + stackScreen.onHeaderConfigurationAttachListener = WeakReference(onHeaderConfigurationAttach) + stackScreen.headerConfiguration?.let { handleHeaderConfigurationAttach(it) } + } - // Handle case where header was already attached before this layout was created - stackScreen.headerConfiguration?.let { onHeaderConfigurationAvailable(it) } + internal fun maybeRequestLayoutContainer() { + // TODO: do we need to rely on parent here? + post { + stackContainerOrNull()?.forceSubtreeMeasureAndLayoutPass() + } } - private fun onHeaderConfigurationAvailable(config: StackHeaderConfiguration?) { + internal fun applyHeaderConfiguration(config: StackHeaderConfigurationProviding) = + headerCoordinator.applyHeaderConfiguration(this, config) + + private fun handleHeaderConfigurationAttach(config: StackHeaderConfiguration?) { if (config != null) { - // Wire header's own change listener so prop updates go directly to us - config.configurationChangeListener = WeakReference(headerConfigChangeListener) + config.onConfigurationChangeListener = WeakReference(onHeaderConfigurationChange) applyHeaderConfiguration(config) - } else { - // Header removed — could reset to default state } } @@ -80,14 +94,4 @@ internal class StackHeaderCoordinatorLayout( * Will crash in case parent is not StackContainer. */ private fun stackContainerOrNull(): StackContainer? = this.parent as StackContainer? - - // TODO: do we need to rely on parent here? - internal fun maybeRequestLayoutContainer() { - post { - stackContainerOrNull()?.forceSubtreeMeasureAndLayoutPass() - } - } - - internal fun applyHeaderConfiguration(config: StackHeaderConfigurationProviding) = - headerCoordinator.applyHeaderConfiguration(this, config) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationAttachListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationAttachListener.kt new file mode 100644 index 0000000000..31298760a5 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationAttachListener.kt @@ -0,0 +1,5 @@ +package com.swmansion.rnscreens.gamma.stack.header.configuration + +internal fun interface OnHeaderConfigurationAttachListener { + fun onHeaderConfigurationAttach(config: StackHeaderConfiguration?) +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationChangeListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationChangeListener.kt new file mode 100644 index 0000000000..60df908369 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationChangeListener.kt @@ -0,0 +1,5 @@ +package com.swmansion.rnscreens.gamma.stack.header.configuration + +internal fun interface OnHeaderConfigurationChangeListener { + fun onHeaderConfigurationChange(config: StackHeaderConfigurationProviding) +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt index 8b688585fd..022338192a 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt @@ -3,8 +3,8 @@ package com.swmansion.rnscreens.gamma.stack.header.configuration import android.annotation.SuppressLint import com.facebook.react.bridge.ReactContext import com.facebook.react.views.view.ReactViewGroup +import com.swmansion.rnscreens.gamma.stack.header.subview.OnStackHeaderSubviewChangeListener import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview -import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewPropertyChangeListener import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewType import java.lang.ref.WeakReference @@ -13,7 +13,7 @@ class StackHeaderConfiguration( val reactContext: ReactContext, ) : ReactViewGroup(reactContext), StackHeaderConfigurationProviding, - StackHeaderSubviewPropertyChangeListener { + OnStackHeaderSubviewChangeListener { override var type: StackHeaderType = StackHeaderType.SMALL override var title: String = "" override var hidden: Boolean = false @@ -28,21 +28,13 @@ class StackHeaderConfiguration( override var backgroundSubview: StackHeaderSubview? = null private set - internal var configurationChangeListener: WeakReference? = null + internal var onConfigurationChangeListener: WeakReference? = null internal fun notifyConfigurationChanged() { - configurationChangeListener?.get()?.onHeaderConfigurationChanged(this) + onConfigurationChangeListener?.get()?.onHeaderConfigurationChange(this) } - override fun onSubviewPropertyChanged() = notifyConfigurationChanged() - - private val subviewLayoutChangeListener = - OnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ -> - val widthChanged = (right - left) != (oldRight - oldLeft) - if (widthChanged) { - notifyConfigurationChanged() - } - } + override fun onStackHeaderSubviewChange() = notifyConfigurationChanged() internal fun addConfigSubview(headerSubview: StackHeaderSubview) { when (headerSubview.type) { @@ -51,14 +43,12 @@ class StackHeaderConfiguration( StackHeaderSubviewType.RIGHT -> rightSubview = headerSubview StackHeaderSubviewType.BACKGROUND -> backgroundSubview = headerSubview } - headerSubview.addOnLayoutChangeListener(subviewLayoutChangeListener) - headerSubview.propertyChangeListener = WeakReference(this) + headerSubview.onStackHeaderSubviewChangeListener = WeakReference(this) notifyConfigurationChanged() } internal fun removeConfigSubview(headerSubview: StackHeaderSubview) { - headerSubview.removeOnLayoutChangeListener(subviewLayoutChangeListener) - headerSubview.propertyChangeListener = null + headerSubview.onStackHeaderSubviewChangeListener = null when (headerSubview.type) { StackHeaderSubviewType.LEFT -> leftSubview = null StackHeaderSubviewType.CENTER -> centerSubview = null @@ -73,12 +63,10 @@ class StackHeaderConfiguration( } internal fun removeAllConfigSubviews() { - leftSubview = null - centerSubview = null - rightSubview = null - backgroundSubview = null - - notifyConfigurationChanged() + leftSubview?.let { removeConfigSubview(it) } + centerSubview?.let { removeConfigSubview(it) } + rightSubview?.let { removeConfigSubview(it) } + backgroundSubview?.let { removeConfigSubview(it) } } internal val configSubviewsCount: Int diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationAttachObserver.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationAttachObserver.kt deleted file mode 100644 index 7d39c58ed6..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationAttachObserver.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.header.configuration - -internal fun interface StackHeaderConfigurationAttachObserver { - fun onHeaderConfigurationChanged(config: StackHeaderConfiguration?) -} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationChangeListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationChangeListener.kt deleted file mode 100644 index 17f5e3f4e0..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationChangeListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.header.configuration - -internal fun interface StackHeaderConfigurationChangeListener { - fun onHeaderConfigurationChanged(config: StackHeaderConfigurationProviding) -} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/OnStackHeaderSubviewChangeListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/OnStackHeaderSubviewChangeListener.kt new file mode 100644 index 0000000000..c9f38e3f1e --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/OnStackHeaderSubviewChangeListener.kt @@ -0,0 +1,5 @@ +package com.swmansion.rnscreens.gamma.stack.header.subview + +internal fun interface OnStackHeaderSubviewChangeListener { + fun onStackHeaderSubviewChange() +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt index 6bc20f4e46..cc6ca32509 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -1,10 +1,10 @@ package com.swmansion.rnscreens.gamma.stack.header.subview import android.annotation.SuppressLint -import android.view.View import com.facebook.react.bridge.ReactContext import com.facebook.react.views.view.ReactViewGroup import java.lang.ref.WeakReference +import kotlin.properties.Delegates @SuppressLint("ViewConstructor") class StackHeaderSubview( @@ -13,17 +13,19 @@ class StackHeaderSubview( StackHeaderSubviewProviding { override var type: StackHeaderSubviewType = StackHeaderSubviewType.CENTER - override var collapseMode: StackHeaderSubviewCollapseMode = StackHeaderSubviewCollapseMode.PIN - set(value) { - if (field != value) { - field = value - propertyChangeListener?.get()?.onSubviewPropertyChanged() - } + override var collapseMode: StackHeaderSubviewCollapseMode by Delegates.observable( + StackHeaderSubviewCollapseMode.PIN, + ) { _, oldValue, newValue -> + if (oldValue != newValue) { + onStackHeaderSubviewChangeListener?.get()?.onStackHeaderSubviewChange() } + } + + override val view = this - override val view: View get() = this + internal var onStackHeaderSubviewChangeListener: WeakReference? = null - internal var propertyChangeListener: WeakReference? = null + private var lastNotifiedWidth: Int = 0 override fun onLayout( changed: Boolean, @@ -33,10 +35,14 @@ class StackHeaderSubview( bottom: Int, ) { super.onLayout(changed, left, top, right, bottom) + val newWidth = right - left + if (newWidth != lastNotifiedWidth) { + lastNotifiedWidth = newWidth + onStackHeaderSubviewChangeListener?.get()?.onStackHeaderSubviewChange() + } } - // We want to rely on layout from Yoga instead of native Toolbar layout which tries to stretch - // subview to match parent. + // Rely on Yoga layout instead of native Toolbar layout which stretches subview to match parent. override fun onMeasure( widthMeasureSpec: Int, heightMeasureSpec: Int, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewPropertyChangeListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewPropertyChangeListener.kt deleted file mode 100644 index 43fc023b23..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewPropertyChangeListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.header.subview - -internal fun interface StackHeaderSubviewPropertyChangeListener { - fun onSubviewPropertyChanged() -} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt index 206f43fb9b..7a16737919 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt @@ -47,7 +47,7 @@ open class StackHeaderSubviewViewManager : "off" -> StackHeaderSubviewCollapseMode.OFF "pin" -> StackHeaderSubviewCollapseMode.PIN "parallax" -> StackHeaderSubviewCollapseMode.PARALLAX - else -> throw JSApplicationIllegalArgumentException("[RNScreens] Invalid StackHeaderSubview collapseMode: $value") + else -> throw JSApplicationIllegalArgumentException("[RNScreens] Invalid StackHeaderSubview collapseMode: $value") } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt index f4edd5a8fe..9354297d23 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt @@ -7,8 +7,8 @@ import androidx.lifecycle.LifecycleOwner import com.facebook.react.uimanager.ThemedReactContext import com.swmansion.rnscreens.ext.findFragmentOrNull import com.swmansion.rnscreens.gamma.common.FragmentProviding +import com.swmansion.rnscreens.gamma.stack.header.configuration.OnHeaderConfigurationAttachListener import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfiguration -import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationAttachObserver import com.swmansion.rnscreens.gamma.stack.host.StackHost import java.lang.ref.WeakReference import kotlin.properties.Delegates @@ -63,25 +63,20 @@ class StackScreen( height: Int? = null, ) = shadowStateProxy.updateStateIfNeeded(x, y, width, height) - // --- Header configuration --- - // StackScreen is a dumb pass-through. It stores the header and notifies an observer. - // The observer is set by whoever manages the header (e.g., StackHeaderCoordinatorLayout). - // WeakReference avoids a cycle: CoordinatorLayout → StackScreen → observer → CoordinatorLayout. - internal var headerConfiguration: StackHeaderConfiguration? = null private set - internal var headerConfigurationAttachObserver: WeakReference? = null + internal var onHeaderConfigurationAttachListener: WeakReference? = null internal fun attachHeaderConfiguration(header: StackHeaderConfiguration) { headerConfiguration = header - headerConfigurationAttachObserver?.get()?.onHeaderConfigurationChanged(header) + onHeaderConfigurationAttachListener?.get()?.onHeaderConfigurationAttach(header) } internal fun detachHeaderConfiguration(header: StackHeaderConfiguration) { if (headerConfiguration === header) { headerConfiguration = null - headerConfigurationAttachObserver?.get()?.onHeaderConfigurationChanged(null) + onHeaderConfigurationAttachListener?.get()?.onHeaderConfigurationAttach(null) } } From eee2d8150787acfe2bfa51c6de5f0091f4a31ac0 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 23 Mar 2026 19:17:17 +0100 Subject: [PATCH 27/92] handle removing and changing header configuration --- .../stack/header/StackHeaderCoordinator.kt | 51 ++++++++++++++----- .../header/StackHeaderCoordinatorLayout.kt | 22 ++++---- .../stack/screen/StackScreenViewManager.kt | 26 ++++++++++ 3 files changed, 76 insertions(+), 23 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index b0944847d6..73fa94853e 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -49,6 +49,18 @@ internal class StackHeaderCoordinator( private var managedTitleView: AppCompatTextView? = null internal fun applyHeaderConfiguration( + coordinatorLayout: StackHeaderCoordinatorLayout, + config: StackHeaderConfigurationProviding?, + ) { + if (config != null) { + updateHeader(coordinatorLayout, config) + } else { + removeHeader(coordinatorLayout) + } + coordinatorLayout.maybeRequestLayoutContainer() + } + + private fun updateHeader( coordinatorLayout: StackHeaderCoordinatorLayout, config: StackHeaderConfigurationProviding, ) { @@ -57,7 +69,11 @@ internal class StackHeaderCoordinator( } applyProps(config) applyContentBehavior(coordinatorLayout, config) - coordinatorLayout.maybeRequestLayoutContainer() + } + + private fun removeHeader(coordinatorLayout: StackHeaderCoordinatorLayout) { + teardown(coordinatorLayout) + removeContentBehavior(coordinatorLayout) } // region Rebuild detection @@ -272,19 +288,28 @@ internal class StackHeaderCoordinator( coordinatorLayout: StackHeaderCoordinatorLayout, config: StackHeaderConfigurationProviding, ) { - val stackScreenWrapper = coordinatorLayout.stackScreenWrapper - val params = stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams val needsBehavior = appBarLayout != null && !config.transparent && !config.hidden - val hasBehavior = params.behavior != null - if (needsBehavior != hasBehavior) { - params.behavior = - if (needsBehavior) { - StackHeaderScrollingViewBehavior(onHeaderHeightChanged) - } else { - onHeaderHeightChanged(0) - null - } - stackScreenWrapper.layoutParams = params + if (needsBehavior) { + setContentBehavior(coordinatorLayout) + } else { + removeContentBehavior(coordinatorLayout) + } + } + + private fun setContentBehavior(coordinatorLayout: StackHeaderCoordinatorLayout) { + val params = coordinatorLayout.stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams + if (params.behavior == null) { + params.behavior = StackHeaderScrollingViewBehavior(onHeaderHeightChanged) + coordinatorLayout.stackScreenWrapper.layoutParams = params + } + } + + private fun removeContentBehavior(coordinatorLayout: StackHeaderCoordinatorLayout) { + val params = coordinatorLayout.stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams + if (params.behavior != null) { + params.behavior = null + coordinatorLayout.stackScreenWrapper.layoutParams = params + onHeaderHeightChanged(0) } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index 63b0e3597c..540140b5e8 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -8,7 +8,6 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import com.swmansion.rnscreens.gamma.stack.header.configuration.OnHeaderConfigurationAttachListener import com.swmansion.rnscreens.gamma.stack.header.configuration.OnHeaderConfigurationChangeListener import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfiguration -import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationProviding import com.swmansion.rnscreens.gamma.stack.host.StackContainer import com.swmansion.rnscreens.gamma.stack.screen.StackScreen import java.lang.ref.WeakReference @@ -39,16 +38,20 @@ internal class StackHeaderCoordinatorLayout( * We use [isHeaderUpdatePending] to batch changes and pass them to [headerCoordinator]. */ private val onHeaderConfigurationChange = - OnHeaderConfigurationChangeListener { config -> + OnHeaderConfigurationChangeListener { if (!isHeaderUpdatePending) { isHeaderUpdatePending = true + // Read currentConfiguration when the runnable executes, not when it's posted, + // to avoid applying a stale config that was swapped out in the meantime. post { isHeaderUpdatePending = false - applyHeaderConfiguration(config) + headerCoordinator.applyHeaderConfiguration(this, currentConfiguration) } } } + private var currentConfiguration: StackHeaderConfiguration? = null + internal var stackScreenWrapper: FrameLayout init { @@ -67,10 +70,8 @@ internal class StackHeaderCoordinatorLayout( LayoutParams(MATCH_PARENT, MATCH_PARENT), ) - // Setup header configuration attach listener. If header configuration is already available, - // use it immediately. stackScreen.onHeaderConfigurationAttachListener = WeakReference(onHeaderConfigurationAttach) - stackScreen.headerConfiguration?.let { handleHeaderConfigurationAttach(it) } + handleHeaderConfigurationAttach(stackScreen.headerConfiguration) } internal fun maybeRequestLayoutContainer() { @@ -80,14 +81,15 @@ internal class StackHeaderCoordinatorLayout( } } - internal fun applyHeaderConfiguration(config: StackHeaderConfigurationProviding) = - headerCoordinator.applyHeaderConfiguration(this, config) - private fun handleHeaderConfigurationAttach(config: StackHeaderConfiguration?) { + // Disconnect old configuration to prevent spurious updates from a detached config + currentConfiguration?.onConfigurationChangeListener = null + currentConfiguration = config + if (config != null) { config.onConfigurationChangeListener = WeakReference(onHeaderConfigurationChange) - applyHeaderConfiguration(config) } + headerCoordinator.applyHeaderConfiguration(this, config) } /** diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt index 08d89191a7..ec1a477acb 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt @@ -54,6 +54,32 @@ class StackScreenViewManager : } } + // Header configuration is not added as a native child (it's stored as a reference + // on StackScreen), but React tracks it by index. Since it's always the last child + // in the React tree, we only need to handle the last index specially. + override fun removeViewAt( + parent: StackScreen, + index: Int, + ) { + if (index == getChildCount(parent) - 1 && parent.headerConfiguration != null) { + parent.headerConfiguration?.let { parent.detachHeaderConfiguration(it) } + } else { + super.removeViewAt(parent, index) + } + } + + override fun getChildCount(parent: StackScreen): Int = parent.childCount + if (parent.headerConfiguration != null) 1 else 0 + + override fun getChildAt( + parent: StackScreen, + index: Int, + ): View? { + if (index == parent.childCount && parent.headerConfiguration != null) { + return parent.headerConfiguration + } + return parent.getChildAt(index) + } + override fun addEventEmitters( reactContext: ThemedReactContext, view: StackScreen, From 297319ff79cbd2bb394aa9dd2bcf605dc9b7ed49 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Tue, 24 Mar 2026 08:08:05 +0100 Subject: [PATCH 28/92] shadow state WIP --- .../gamma/common/ShadowStateProxy.kt | 58 ++++++++++++++++++ .../stack/header/StackHeaderAppBarLayout.kt | 2 +- .../stack/header/StackHeaderCoordinator.kt | 61 ++++++++++++++++++- .../StackHeaderScrollingViewBehavior.kt | 4 +- .../configuration/StackHeaderConfiguration.kt | 13 ++++ .../StackHeaderConfigurationProviding.kt | 2 + .../StackHeaderConfigurationViewManager.kt | 11 ++++ .../header/subview/StackHeaderSubview.kt | 9 +++ .../subview/StackHeaderSubviewProviding.kt | 2 + .../subview/StackHeaderSubviewViewManager.kt | 11 ++++ .../gamma/stack/screen/StackScreen.kt | 12 +++- .../screen/StackScreenShadowStateProxy.kt | 59 ------------------ .../gamma/containers/stack/StackContainer.tsx | 21 +++++-- ...ckHeaderConfigurationComponentDescriptor.h | 23 +++++++ .../RNSStackHeaderConfigurationShadowNode.cpp | 8 +++ .../RNSStackHeaderConfigurationShadowNode.h | 4 ++ .../RNSStackHeaderConfigurationState.cpp | 12 +++- .../RNSStackHeaderConfigurationState.h | 30 +++++++++ .../RNSStackHeaderSubviewShadowNode.cpp | 8 +++ .../RNSStackHeaderSubviewShadowNode.h | 4 ++ .../rnscreens/RNSStackHeaderSubviewState.cpp | 11 +++- .../rnscreens/RNSStackHeaderSubviewState.h | 25 ++++++++ 22 files changed, 316 insertions(+), 74 deletions(-) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/common/ShadowStateProxy.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/common/ShadowStateProxy.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/common/ShadowStateProxy.kt new file mode 100644 index 0000000000..7f762b0e13 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/common/ShadowStateProxy.kt @@ -0,0 +1,58 @@ +package com.swmansion.rnscreens.gamma.common + +import com.facebook.react.bridge.WritableNativeMap +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.StateWrapper +import kotlin.math.abs + +internal class ShadowStateProxy( + private val includesFrameSize: Boolean = true, +) { + internal var stateWrapper: StateWrapper? = null + + private var lastFrameWidthInDp: Float = 0f + private var lastFrameHeightInDp: Float = 0f + private var lastContentOffsetXInDp: Float = 0f + private var lastContentOffsetYInDp: Float = 0f + + fun updateStateIfNeeded( + frameWidth: Int? = null, + frameHeight: Int? = null, + contentOffsetX: Int? = null, + contentOffsetY: Int? = null, + ) { + val widthInDp = frameWidth?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastFrameWidthInDp + val heightInDp = frameHeight?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastFrameHeightInDp + val offsetXInDp = contentOffsetX?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastContentOffsetXInDp + val offsetYInDp = contentOffsetY?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastContentOffsetYInDp + + if ( + abs(lastFrameWidthInDp - widthInDp) < DELTA && + abs(lastFrameHeightInDp - heightInDp) < DELTA && + abs(lastContentOffsetXInDp - offsetXInDp) < DELTA && + abs(lastContentOffsetYInDp - offsetYInDp) < DELTA + ) { + return + } + + lastFrameWidthInDp = widthInDp + lastFrameHeightInDp = heightInDp + lastContentOffsetXInDp = offsetXInDp + lastContentOffsetYInDp = offsetYInDp + + val map = + WritableNativeMap().apply { + if (includesFrameSize) { + putDouble("frameWidth", widthInDp.toDouble()) + putDouble("frameHeight", heightInDp.toDouble()) + } + putDouble("contentOffsetX", offsetXInDp.toDouble()) + putDouble("contentOffsetY", offsetYInDp.toDouble()) + } + stateWrapper?.updateState(map) + } + + companion object { + private const val DELTA = 0.9f + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt index 6e29733dcf..c703a05cef 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt @@ -89,7 +89,7 @@ internal sealed class StackHeaderAppBarLayout( resolveDimensionAttr(context, sizeAttr), ).apply { // TODO: debug only for medium/large header, must be moved to configuration - scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED or SCROLL_FLAG_SNAP + scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED // scrollFlags = SCROLL_FLAG_NO_SCROLL } addView(toolbar) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 73fa94853e..f7a3c44e90 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -31,6 +31,7 @@ internal class StackHeaderCoordinator( private var appBarLayout: StackHeaderAppBarLayout? = null private var currentHeaderTypeOrNull: StackHeaderType? = null + private var currentConfig: StackHeaderConfigurationProviding? = null private var attachedLeftSubview: StackHeaderSubviewProviding? = null private var attachedCenterSubview: StackHeaderSubviewProviding? = null @@ -52,6 +53,7 @@ internal class StackHeaderCoordinator( coordinatorLayout: StackHeaderCoordinatorLayout, config: StackHeaderConfigurationProviding?, ) { + currentConfig = config if (config != null) { updateHeader(coordinatorLayout, config) } else { @@ -299,7 +301,10 @@ internal class StackHeaderCoordinator( private fun setContentBehavior(coordinatorLayout: StackHeaderCoordinatorLayout) { val params = coordinatorLayout.stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams if (params.behavior == null) { - params.behavior = StackHeaderScrollingViewBehavior(onHeaderHeightChanged) + params.behavior = StackHeaderScrollingViewBehavior { contentTop, dependency -> + onHeaderHeightChanged(contentTop) + updateShadowState(contentTop, dependency) + } coordinatorLayout.stackScreenWrapper.layoutParams = params } } @@ -315,6 +320,60 @@ internal class StackHeaderCoordinator( // endregion + // region Shadow state updates (Yoga synchronization) + + /** + * Called on every AppBarLayout change (scroll, size, position) via + * [StackHeaderScrollingViewBehavior.onDependentViewChanged]. + * + * @param contentTop Y position of the content area (StackScreen wrapper) in the CoordinatorLayout + * @param dependency the AppBarLayout view + */ + private fun updateShadowState(contentTop: Int, dependency: View) { + val config = currentConfig ?: return + val appBar = appBarLayout ?: return + + // Header configuration: report AppBarLayout size and its offset relative to content + config.updateHeaderFrame( + width = appBar.width, + height = appBar.height, + contentOffsetY = -contentTop, + ) + + // Subviews: report position relative to AppBarLayout + updateSubviewOffsets(appBar, config) + } + + private fun updateSubviewOffsets( + appBar: StackHeaderAppBarLayout, + config: StackHeaderConfigurationProviding, + ) { + config.leftSubview?.let { updateSubviewOffset(it, appBar) } + config.centerSubview?.let { updateSubviewOffset(it, appBar) } + config.rightSubview?.let { updateSubviewOffset(it, appBar) } + config.backgroundSubview?.let { updateSubviewOffset(it, appBar) } + } + + private fun updateSubviewOffset( + subview: StackHeaderSubviewProviding, + appBar: StackHeaderAppBarLayout, + ) { + val view = subview.view + if (view.width == 0 && view.height == 0) return + + val appBarPos = IntArray(2) + val subviewPos = IntArray(2) + appBar.getLocationInWindow(appBarPos) + view.getLocationInWindow(subviewPos) + + subview.updateContentOriginOffset( + x = subviewPos[0] - appBarPos[0], + y = subviewPos[1] - appBarPos[1], + ) + } + + // endregion + companion object { private const val TAG = "StackHeaderCoordinator" diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderScrollingViewBehavior.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderScrollingViewBehavior.kt index 99e00311ed..43c1eb7c32 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderScrollingViewBehavior.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderScrollingViewBehavior.kt @@ -5,7 +5,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.appbar.AppBarLayout internal class StackHeaderScrollingViewBehavior( - private val onHeaderHeightChanged: (headerHeight: Int) -> Unit, + private val onDependencyChanged: (contentTop: Int, dependency: View) -> Unit, ) : AppBarLayout.ScrollingViewBehavior() { override fun onDependentViewChanged( parent: CoordinatorLayout, @@ -13,7 +13,7 @@ internal class StackHeaderScrollingViewBehavior( dependency: View, ): Boolean { val result = super.onDependentViewChanged(parent, child, dependency) - onHeaderHeightChanged(child.top) + onDependencyChanged(child.top, dependency) return result } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt index 022338192a..290fef7e32 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt @@ -3,6 +3,7 @@ package com.swmansion.rnscreens.gamma.stack.header.configuration import android.annotation.SuppressLint import com.facebook.react.bridge.ReactContext import com.facebook.react.views.view.ReactViewGroup +import com.swmansion.rnscreens.gamma.common.ShadowStateProxy import com.swmansion.rnscreens.gamma.stack.header.subview.OnStackHeaderSubviewChangeListener import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewType @@ -28,6 +29,18 @@ class StackHeaderConfiguration( override var backgroundSubview: StackHeaderSubview? = null private set + private val shadowStateProxy = ShadowStateProxy() + + var stateWrapper by shadowStateProxy::stateWrapper + + override fun updateHeaderFrame(width: Int, height: Int, contentOffsetY: Int) { + shadowStateProxy.updateStateIfNeeded( + frameWidth = width, + frameHeight = height, + contentOffsetY = contentOffsetY, + ) + } + internal var onConfigurationChangeListener: WeakReference? = null internal fun notifyConfigurationChanged() { diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt index 4c265c523a..30d95ddf7b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt @@ -11,4 +11,6 @@ internal interface StackHeaderConfigurationProviding { val centerSubview: StackHeaderSubviewProviding? val rightSubview: StackHeaderSubviewProviding? val backgroundSubview: StackHeaderSubviewProviding? + + fun updateHeaderFrame(width: Int, height: Int, contentOffsetY: Int) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt index fa9a9906ab..dcda6f2492 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt @@ -3,6 +3,8 @@ package com.swmansion.rnscreens.gamma.stack.header.configuration import android.view.View import com.facebook.react.bridge.JSApplicationIllegalArgumentException import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate @@ -55,6 +57,15 @@ open class StackHeaderConfigurationViewManager : index: Int, ): View? = parent.getConfigSubviewAt(index) + override fun updateState( + view: StackHeaderConfiguration, + props: ReactStylesDiffMap?, + stateWrapper: StateWrapper?, + ): Any? { + view.stateWrapper = stateWrapper + return super.updateState(view, props, stateWrapper) + } + override fun onAfterUpdateTransaction(view: StackHeaderConfiguration) { super.onAfterUpdateTransaction(view) view.notifyConfigurationChanged() diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt index cc6ca32509..4c940fd736 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -3,6 +3,7 @@ package com.swmansion.rnscreens.gamma.stack.header.subview import android.annotation.SuppressLint import com.facebook.react.bridge.ReactContext import com.facebook.react.views.view.ReactViewGroup +import com.swmansion.rnscreens.gamma.common.ShadowStateProxy import java.lang.ref.WeakReference import kotlin.properties.Delegates @@ -23,6 +24,14 @@ class StackHeaderSubview( override val view = this + private val shadowStateProxy = ShadowStateProxy(includesFrameSize = false) + + var stateWrapper by shadowStateProxy::stateWrapper + + override fun updateContentOriginOffset(x: Int, y: Int) { + shadowStateProxy.updateStateIfNeeded(contentOffsetX = x, contentOffsetY = y) + } + internal var onStackHeaderSubviewChangeListener: WeakReference? = null private var lastNotifiedWidth: Int = 0 diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt index 0f8c0cb987..040b4c129b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt @@ -6,4 +6,6 @@ interface StackHeaderSubviewProviding { val type: StackHeaderSubviewType val collapseMode: StackHeaderSubviewCollapseMode val view: View + + fun updateContentOriginOffset(x: Int, y: Int) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt index 7a16737919..5e3fff9a9a 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt @@ -2,6 +2,8 @@ package com.swmansion.rnscreens.gamma.stack.header.subview import com.facebook.react.bridge.JSApplicationIllegalArgumentException import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate @@ -51,6 +53,15 @@ open class StackHeaderSubviewViewManager : } } + override fun updateState( + view: StackHeaderSubview, + props: ReactStylesDiffMap?, + stateWrapper: StateWrapper?, + ): Any? { + view.stateWrapper = stateWrapper + return super.updateState(view, props, stateWrapper) + } + companion object { const val REACT_CLASS = "RNSStackHeaderSubview" } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt index 9354297d23..d21dd17e20 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.LifecycleOwner import com.facebook.react.uimanager.ThemedReactContext import com.swmansion.rnscreens.ext.findFragmentOrNull import com.swmansion.rnscreens.gamma.common.FragmentProviding +import com.swmansion.rnscreens.gamma.common.ShadowStateProxy import com.swmansion.rnscreens.gamma.stack.header.configuration.OnHeaderConfigurationAttachListener import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfiguration import com.swmansion.rnscreens.gamma.stack.host.StackHost @@ -52,7 +53,7 @@ class StackScreen( field = value } - private val shadowStateProxy = StackScreenShadowStateProxy() + private val shadowStateProxy = ShadowStateProxy() var stateWrapper by shadowStateProxy::stateWrapper @@ -61,7 +62,12 @@ class StackScreen( y: Int? = null, width: Int? = null, height: Int? = null, - ) = shadowStateProxy.updateStateIfNeeded(x, y, width, height) + ) = shadowStateProxy.updateStateIfNeeded( + contentOffsetX = x, + contentOffsetY = y, + frameWidth = width, + frameHeight = height, + ) internal var headerConfiguration: StackHeaderConfiguration? = null private set @@ -114,7 +120,7 @@ class StackScreen( r: Int, b: Int, ) { - shadowStateProxy.updateStateIfNeeded(width = r - l, height = b - t) + shadowStateProxy.updateStateIfNeeded(frameWidth = r - l, frameHeight = b - t) } override fun getAssociatedFragment(): Fragment? = diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt deleted file mode 100644 index 6a40cf3104..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.screen - -import com.facebook.react.bridge.WritableMap -import com.facebook.react.bridge.WritableNativeMap -import com.facebook.react.uimanager.PixelUtil -import com.facebook.react.uimanager.StateWrapper -import kotlin.math.abs - -internal class StackScreenShadowStateProxy { - internal var stateWrapper: StateWrapper? = null - - private var lastXInDp: Float = 0f - private var lastYInDp: Float = 0f - private var lastWidthInDp: Float = 0f - private var lastHeightInDp: Float = 0f - - fun updateStateIfNeeded( - x: Int? = null, - y: Int? = null, - width: Int? = null, - height: Int? = null, - ) { - val xInDp: Float = x?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastXInDp - val yInDp: Float = y?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastYInDp - val widthInDp: Float = - width?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastWidthInDp - val heightInDp: Float = - height?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastHeightInDp - - // Check incoming state values. If they're already the correct value, return early to prevent - // infinite UpdateState/SetState loop. - if ( - abs(lastXInDp - xInDp) < DELTA && - abs(lastYInDp - yInDp) < DELTA && - abs(lastWidthInDp - widthInDp) < DELTA && - abs(lastHeightInDp - heightInDp) < DELTA - ) { - return - } - - lastXInDp = xInDp - lastYInDp = yInDp - lastWidthInDp = widthInDp - lastHeightInDp = heightInDp - - val map: WritableMap = - WritableNativeMap().apply { - putDouble("frameWidth", widthInDp.toDouble()) - putDouble("frameHeight", heightInDp.toDouble()) - putDouble("contentOffsetX", xInDp.toDouble()) - putDouble("contentOffsetY", yInDp.toDouble()) - } - stateWrapper?.updateState(map) - } - - companion object { - private const val DELTA = 0.9f - } -} diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.tsx b/apps/src/shared/gamma/containers/stack/StackContainer.tsx index bad8e00dd5..3b8ff178d9 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.tsx +++ b/apps/src/shared/gamma/containers/stack/StackContainer.tsx @@ -22,6 +22,7 @@ import { import { useParentNavigationEffect } from './hooks/useParentNavigationEffect'; import { Text, View } from 'react-native'; import LongText from '../../../../../src/shared/LongText'; +import PressableWithFeedback from '../../../../../src/shared/PressableWithFeedback'; export function StackContainer({ routeConfigs }: StackContainerProps) { useSanitizeRouteConfigs(routeConfigs); @@ -96,18 +97,26 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { height: 120, backgroundColor: 'blue', }}> - + + Pressable + - {/* - left + + + left + - center + + center + - rightvery - */} + + right + + diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationComponentDescriptor.h index 6e1bf6f87f..90935b527b 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationComponentDescriptor.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationComponentDescriptor.h @@ -1,5 +1,9 @@ #pragma once +#ifdef ANDROID +#include +#endif // ANDROID + #include #include #include "RNSStackHeaderConfigurationShadowNode.h" @@ -15,6 +19,25 @@ class RNSStackHeaderConfigurationComponentDescriptor final void adopt(ShadowNode &shadowNode) const override { react_native_assert( dynamic_cast(&shadowNode)); + +#ifdef ANDROID + auto &configShadowNode = + static_cast(shadowNode); + react_native_assert( + dynamic_cast(&configShadowNode)); + auto &layoutableShadowNode = + static_cast(configShadowNode); + + auto state = std::static_pointer_cast< + const RNSStackHeaderConfigurationShadowNode::ConcreteState>( + shadowNode.getState()); + auto stateData = state->getData(); + + if (stateData.frameSize.width != 0 && stateData.frameSize.height != 0) { + layoutableShadowNode.setSize( + Size{stateData.frameSize.width, stateData.frameSize.height}); + } +#endif // ANDROID ConcreteComponentDescriptor::adopt(shadowNode); } }; diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.cpp index f7876ee9be..a70f00aa73 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.cpp +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.cpp @@ -5,4 +5,12 @@ namespace facebook::react { extern const char RNSStackHeaderConfigurationComponentName[] = "RNSStackHeaderConfiguration"; +#ifdef ANDROID +Point RNSStackHeaderConfigurationShadowNode::getContentOriginOffset( + bool /*includeTransform*/) const { + auto stateData = getStateData(); + return stateData.contentOffset; +} +#endif // ANDROID + } // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.h index d8fdb6de0c..88e54536c1 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.h @@ -19,6 +19,10 @@ class JSI_EXPORT RNSStackHeaderConfigurationShadowNode final public: using ConcreteViewShadowNode::ConcreteViewShadowNode; using StateData = ConcreteViewShadowNode::ConcreteStateData; + +#ifdef ANDROID + Point getContentOriginOffset(bool includeTransform) const override; +#endif // ANDROID }; } // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.cpp index 8cb2ba0e9c..05ca5ec467 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.cpp +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.cpp @@ -1,3 +1,13 @@ #include "RNSStackHeaderConfigurationState.h" -namespace facebook::react {} // namespace facebook::react +namespace facebook::react { + +#ifdef ANDROID +folly::dynamic RNSStackHeaderConfigurationState::getDynamic() const { + return folly::dynamic::object("frameWidth", frameSize.width)( + "frameHeight", frameSize.height)("contentOffsetX", contentOffset.x)( + "contentOffsetY", contentOffset.y); +} +#endif // ANDROID + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.h index 97e5866858..665066f601 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.h @@ -2,6 +2,14 @@ #include +#ifdef ANDROID +#include +#include +#include +#include +#include +#endif // ANDROID + namespace facebook::react { class JSI_EXPORT RNSStackHeaderConfigurationState final { @@ -9,6 +17,28 @@ class JSI_EXPORT RNSStackHeaderConfigurationState final { using Shared = std::shared_ptr; RNSStackHeaderConfigurationState() {}; + +#ifdef ANDROID + RNSStackHeaderConfigurationState( + RNSStackHeaderConfigurationState const &previousState, + folly::dynamic data) + : frameSize( + Size{ + (Float)data["frameWidth"].getDouble(), + (Float)data["frameHeight"].getDouble()}), + contentOffset( + Point{ + (Float)data["contentOffsetX"].getDouble(), + (Float)data["contentOffsetY"].getDouble()}) {}; + + Size frameSize{}; + Point contentOffset{}; + + folly::dynamic getDynamic() const; + MapBuffer getMapBuffer() const { + return MapBufferBuilder::EMPTY(); + }; +#endif // ANDROID }; } // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp index 711318af72..d7dcd8bd11 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp @@ -5,4 +5,12 @@ namespace facebook::react { extern const char RNSStackHeaderSubviewComponentName[] = "RNSStackHeaderSubview"; +#ifdef ANDROID +Point RNSStackHeaderSubviewShadowNode::getContentOriginOffset( + bool /*includeTransform*/) const { + auto stateData = getStateData(); + return stateData.contentOffset; +} +#endif // ANDROID + } // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h index f89b6fbef7..8e98a4f8be 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h @@ -19,6 +19,10 @@ class JSI_EXPORT RNSStackHeaderSubviewShadowNode final public: using ConcreteViewShadowNode::ConcreteViewShadowNode; using StateData = ConcreteViewShadowNode::ConcreteStateData; + +#ifdef ANDROID + Point getContentOriginOffset(bool includeTransform) const override; +#endif // ANDROID }; } // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.cpp index 1e481f0231..df1fb09233 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.cpp +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.cpp @@ -1,3 +1,12 @@ #include "RNSStackHeaderSubviewState.h" -namespace facebook::react {} // namespace facebook::react +namespace facebook::react { + +#ifdef ANDROID +folly::dynamic RNSStackHeaderSubviewState::getDynamic() const { + return folly::dynamic::object("contentOffsetX", contentOffset.x)( + "contentOffsetY", contentOffset.y); +} +#endif // ANDROID + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.h index 15b7c4e6fd..f2173e40a6 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.h @@ -2,6 +2,14 @@ #include +#ifdef ANDROID +#include +#include +#include +#include +#include +#endif // ANDROID + namespace facebook::react { class JSI_EXPORT RNSStackHeaderSubviewState final { @@ -9,6 +17,23 @@ class JSI_EXPORT RNSStackHeaderSubviewState final { using Shared = std::shared_ptr; RNSStackHeaderSubviewState() {}; + +#ifdef ANDROID + RNSStackHeaderSubviewState( + RNSStackHeaderSubviewState const &previousState, + folly::dynamic data) + : contentOffset( + Point{ + (Float)data["contentOffsetX"].getDouble(), + (Float)data["contentOffsetY"].getDouble()}) {}; + + Point contentOffset{}; + + folly::dynamic getDynamic() const; + MapBuffer getMapBuffer() const { + return MapBufferBuilder::EMPTY(); + }; +#endif // ANDROID }; } // namespace facebook::react From 7c37c7469f687cef2518fb84e70172816f9a562c Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Tue, 24 Mar 2026 08:31:34 +0100 Subject: [PATCH 29/92] fix header configuration offset --- .../stack/header/StackHeaderCoordinator.kt | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index f7a3c44e90..2a88af00e3 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -301,10 +301,11 @@ internal class StackHeaderCoordinator( private fun setContentBehavior(coordinatorLayout: StackHeaderCoordinatorLayout) { val params = coordinatorLayout.stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams if (params.behavior == null) { - params.behavior = StackHeaderScrollingViewBehavior { contentTop, dependency -> - onHeaderHeightChanged(contentTop) - updateShadowState(contentTop, dependency) - } + params.behavior = + StackHeaderScrollingViewBehavior { contentTop, dependency -> + onHeaderHeightChanged(contentTop) + updateShadowState(contentTop, dependency) + } coordinatorLayout.stackScreenWrapper.layoutParams = params } } @@ -329,18 +330,23 @@ internal class StackHeaderCoordinator( * @param contentTop Y position of the content area (StackScreen wrapper) in the CoordinatorLayout * @param dependency the AppBarLayout view */ - private fun updateShadowState(contentTop: Int, dependency: View) { + private fun updateShadowState( + contentTop: Int, + dependency: View, + ) { val config = currentConfig ?: return val appBar = appBarLayout ?: return - // Header configuration: report AppBarLayout size and its offset relative to content + // For header configuration we need to: + // - cancel out the StackScreen's Y offset (contentTop), + // - handle AppBarLayout's negative offset when collapsed. config.updateHeaderFrame( width = appBar.width, height = appBar.height, - contentOffsetY = -contentTop, + contentOffsetY = appBar.top - contentTop, ) - // Subviews: report position relative to AppBarLayout + // For subviews report position relative to AppBarLayout updateSubviewOffsets(appBar, config) } From 00cc9ab11b0d3a46540eb012a85bf141599703a2 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Tue, 24 Mar 2026 09:52:34 +0100 Subject: [PATCH 30/92] drop support for collapse mode pin --- .../gamma/stack/header/StackHeaderCoordinator.kt | 1 - .../gamma/stack/header/subview/StackHeaderSubview.kt | 7 +++++-- .../header/subview/StackHeaderSubviewCollapseMode.kt | 1 - .../header/subview/StackHeaderSubviewViewManager.kt | 1 - .../src/shared/gamma/containers/stack/StackContainer.tsx | 7 ++++--- src/components/gamma/stack/header/StackHeaderSubview.tsx | 9 ++++----- .../gamma/stack/header/StackHeaderSubview.types.ts | 1 - .../gamma/stack/StackHeaderSubviewNativeComponent.ts | 7 ++----- 8 files changed, 15 insertions(+), 19 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 2a88af00e3..c24b75f6d5 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -411,7 +411,6 @@ internal class StackHeaderCoordinator( private fun toNativeCollapseMode(mode: StackHeaderSubviewCollapseMode): Int = when (mode) { StackHeaderSubviewCollapseMode.OFF -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_OFF - StackHeaderSubviewCollapseMode.PIN -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PIN StackHeaderSubviewCollapseMode.PARALLAX -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PARALLAX } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt index 4c940fd736..4ad12b2254 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -15,7 +15,7 @@ class StackHeaderSubview( override var type: StackHeaderSubviewType = StackHeaderSubviewType.CENTER override var collapseMode: StackHeaderSubviewCollapseMode by Delegates.observable( - StackHeaderSubviewCollapseMode.PIN, + StackHeaderSubviewCollapseMode.PARALLAX, ) { _, oldValue, newValue -> if (oldValue != newValue) { onStackHeaderSubviewChangeListener?.get()?.onStackHeaderSubviewChange() @@ -28,7 +28,10 @@ class StackHeaderSubview( var stateWrapper by shadowStateProxy::stateWrapper - override fun updateContentOriginOffset(x: Int, y: Int) { + override fun updateContentOriginOffset( + x: Int, + y: Int, + ) { shadowStateProxy.updateStateIfNeeded(contentOffsetX = x, contentOffsetY = y) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt index 16f96d6c91..a66b4d2829 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt @@ -2,6 +2,5 @@ package com.swmansion.rnscreens.gamma.stack.header.subview enum class StackHeaderSubviewCollapseMode { OFF, - PIN, PARALLAX, } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt index 5e3fff9a9a..8c1853f6c0 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt @@ -47,7 +47,6 @@ open class StackHeaderSubviewViewManager : view.collapseMode = when (value) { "off" -> StackHeaderSubviewCollapseMode.OFF - "pin" -> StackHeaderSubviewCollapseMode.PIN "parallax" -> StackHeaderSubviewCollapseMode.PARALLAX else -> throw JSApplicationIllegalArgumentException("[RNScreens] Invalid StackHeaderSubview collapseMode: $value") } diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.tsx b/apps/src/shared/gamma/containers/stack/StackContainer.tsx index 3b8ff178d9..4f99dc7460 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.tsx +++ b/apps/src/shared/gamma/containers/stack/StackContainer.tsx @@ -93,11 +93,12 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { collapseMode="parallax"> - + Pressable diff --git a/src/components/gamma/stack/header/StackHeaderSubview.tsx b/src/components/gamma/stack/header/StackHeaderSubview.tsx index c3eff3dfaf..74b3006486 100644 --- a/src/components/gamma/stack/header/StackHeaderSubview.tsx +++ b/src/components/gamma/stack/header/StackHeaderSubview.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { StackHeaderSubviewProps } from './StackHeaderSubview.types'; import StackHeaderSubviewNativeComponent from '../../../../fabric/gamma/stack/StackHeaderSubviewNativeComponent'; +import { StyleSheet } from 'react-native'; /** * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE @@ -11,11 +12,9 @@ function StackHeaderSubview(props: StackHeaderSubviewProps) { {children} diff --git a/src/components/gamma/stack/header/StackHeaderSubview.types.ts b/src/components/gamma/stack/header/StackHeaderSubview.types.ts index 6368e1d632..d6c2218454 100644 --- a/src/components/gamma/stack/header/StackHeaderSubview.types.ts +++ b/src/components/gamma/stack/header/StackHeaderSubview.types.ts @@ -8,7 +8,6 @@ export type StackHeaderSubviewTypeAndroid = export type StackHeaderSubviewBackgroundCollapseModeAndroid = | 'off' - | 'pin' | 'parallax'; export type StackHeaderSubviewProps = { diff --git a/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts index 5a644cb9f0..0aa036896a 100644 --- a/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts +++ b/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts @@ -4,16 +4,13 @@ import type { CodegenTypes as CT, ViewProps } from 'react-native'; import { codegenNativeComponent } from 'react-native'; type StackHeaderSubviewTypeAndroid = 'left' | 'center' | 'right' | 'background'; -type StackHeaderSubviewBackgroundCollapseModeAndroid = - | 'off' - | 'pin' - | 'parallax'; +type StackHeaderSubviewBackgroundCollapseModeAndroid = 'off' | 'parallax'; export interface NativeProps extends ViewProps { type?: CT.WithDefault; collapseMode?: CT.WithDefault< StackHeaderSubviewBackgroundCollapseModeAndroid, - 'pin' + 'parallax' >; } From c3223c1d124d29ed018948af3b9d5f9da78c0dc5 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Tue, 24 Mar 2026 11:09:33 +0100 Subject: [PATCH 31/92] fix order of header subviews --- .../configuration/StackHeaderConfiguration.kt | 20 +++++++++++-------- .../StackHeaderConfigurationViewManager.kt | 7 +++++++ .../header/subview/StackHeaderSubviewType.kt | 2 +- .../gamma/containers/stack/StackContainer.tsx | 3 ++- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt index 290fef7e32..dba745a63b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt @@ -20,20 +20,24 @@ class StackHeaderConfiguration( override var hidden: Boolean = false override var transparent: Boolean = false + override var backgroundSubview: StackHeaderSubview? = null + private set override var leftSubview: StackHeaderSubview? = null private set override var centerSubview: StackHeaderSubview? = null private set override var rightSubview: StackHeaderSubview? = null private set - override var backgroundSubview: StackHeaderSubview? = null - private set private val shadowStateProxy = ShadowStateProxy() var stateWrapper by shadowStateProxy::stateWrapper - override fun updateHeaderFrame(width: Int, height: Int, contentOffsetY: Int) { + override fun updateHeaderFrame( + width: Int, + height: Int, + contentOffsetY: Int, + ) { shadowStateProxy.updateStateIfNeeded( frameWidth = width, frameHeight = height, @@ -51,10 +55,10 @@ class StackHeaderConfiguration( internal fun addConfigSubview(headerSubview: StackHeaderSubview) { when (headerSubview.type) { + StackHeaderSubviewType.BACKGROUND -> backgroundSubview = headerSubview StackHeaderSubviewType.LEFT -> leftSubview = headerSubview StackHeaderSubviewType.CENTER -> centerSubview = headerSubview StackHeaderSubviewType.RIGHT -> rightSubview = headerSubview - StackHeaderSubviewType.BACKGROUND -> backgroundSubview = headerSubview } headerSubview.onStackHeaderSubviewChangeListener = WeakReference(this) notifyConfigurationChanged() @@ -63,10 +67,10 @@ class StackHeaderConfiguration( internal fun removeConfigSubview(headerSubview: StackHeaderSubview) { headerSubview.onStackHeaderSubviewChangeListener = null when (headerSubview.type) { + StackHeaderSubviewType.BACKGROUND -> backgroundSubview = null StackHeaderSubviewType.LEFT -> leftSubview = null StackHeaderSubviewType.CENTER -> centerSubview = null StackHeaderSubviewType.RIGHT -> rightSubview = null - StackHeaderSubviewType.BACKGROUND -> backgroundSubview = null } notifyConfigurationChanged() } @@ -76,15 +80,15 @@ class StackHeaderConfiguration( } internal fun removeAllConfigSubviews() { + backgroundSubview?.let { removeConfigSubview(it) } leftSubview?.let { removeConfigSubview(it) } centerSubview?.let { removeConfigSubview(it) } rightSubview?.let { removeConfigSubview(it) } - backgroundSubview?.let { removeConfigSubview(it) } } internal val configSubviewsCount: Int - get() = listOfNotNull(leftSubview, centerSubview, rightSubview, backgroundSubview).size + get() = listOfNotNull(backgroundSubview, leftSubview, centerSubview, rightSubview).size internal fun getConfigSubviewAt(index: Int): StackHeaderSubview? = - listOfNotNull(leftSubview, centerSubview, rightSubview, backgroundSubview).getOrNull(index) + listOfNotNull(backgroundSubview, leftSubview, centerSubview, rightSubview).getOrNull(index) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt index dcda6f2492..0ce23cea3a 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt @@ -39,6 +39,13 @@ open class StackHeaderConfigurationViewManager : parent.addConfigSubview(child) } + override fun removeView(parent: StackHeaderConfiguration, view: View) { + require(view is StackHeaderSubview) { + "[RNScreens] StackHeaderConfiguration can only have children of type StackHeaderSubview. Attempted to remove $view instead." + } + parent.removeConfigSubview(view) + } + override fun removeViewAt( parent: StackHeaderConfiguration, index: Int, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt index 643fc522c8..4573f6214a 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt @@ -1,8 +1,8 @@ package com.swmansion.rnscreens.gamma.stack.header.subview enum class StackHeaderSubviewType { + BACKGROUND, LEFT, CENTER, RIGHT, - BACKGROUND, } diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.tsx b/apps/src/shared/gamma/containers/stack/StackContainer.tsx index 4f99dc7460..cdbf6d48dd 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.tsx +++ b/apps/src/shared/gamma/containers/stack/StackContainer.tsx @@ -90,7 +90,8 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { type="large"> + collapseMode="parallax" + key="background"> Date: Tue, 24 Mar 2026 15:35:55 +0100 Subject: [PATCH 32/92] handle RTL --- .../stack/header/StackHeaderAppBarLayout.kt | 3 +- .../stack/header/StackHeaderCoordinator.kt | 132 ++++++++++-------- .../configuration/StackHeaderConfiguration.kt | 24 ++-- .../StackHeaderConfigurationProviding.kt | 6 +- .../header/subview/StackHeaderSubview.kt | 2 +- .../header/subview/StackHeaderSubviewType.kt | 4 +- .../subview/StackHeaderSubviewViewManager.kt | 4 +- .../gamma/containers/stack/StackContainer.tsx | 10 +- .../RNSStackHeaderSubviewShadowNode.cpp | 16 ++- .../RNSStackHeaderSubviewShadowNode.h | 11 +- .../gamma/stack/header/StackHeaderSubview.tsx | 2 +- .../stack/header/StackHeaderSubview.types.ts | 6 +- .../StackHeaderSubviewNativeComponent.ts | 10 +- 13 files changed, 141 insertions(+), 89 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt index c703a05cef..bb54c5db30 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt @@ -10,7 +10,6 @@ import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL -import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP import com.google.android.material.appbar.CollapsingToolbarLayout import com.google.android.material.appbar.MaterialToolbar import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderType @@ -71,7 +70,7 @@ internal sealed class StackHeaderAppBarLayout( } } - val collapsingToolbarLayout: CollapsingToolbarLayout = + internal val collapsingToolbarLayout: CollapsingToolbarLayout = run { val (styleAttr, sizeAttr) = when (type) { diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index c24b75f6d5..3067f380c1 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -2,11 +2,13 @@ package com.swmansion.rnscreens.gamma.stack.header import android.content.Context import android.text.TextUtils +import android.util.LayoutDirection import android.util.Log import android.view.Gravity import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.Toolbar @@ -16,6 +18,7 @@ import com.google.android.material.R import com.google.android.material.appbar.CollapsingToolbarLayout import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationProviding import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderType +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewCollapseMode import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewProviding @@ -33,20 +36,20 @@ internal class StackHeaderCoordinator( private var currentHeaderTypeOrNull: StackHeaderType? = null private var currentConfig: StackHeaderConfigurationProviding? = null - private var attachedLeftSubview: StackHeaderSubviewProviding? = null + private var attachedLeadingSubview: StackHeaderSubviewProviding? = null private var attachedCenterSubview: StackHeaderSubviewProviding? = null - private var attachedRightSubview: StackHeaderSubviewProviding? = null + private var attachedTrailingSubview: StackHeaderSubviewProviding? = null private var attachedBackgroundSubview: StackHeaderSubviewProviding? = null // Width snapshots for collapsing header rebuild detection. // CollapsingToolbarLayout can't resize custom views at runtime, // so we must rebuild the hierarchy when toolbar subview widths change. - private var lastLeftSubviewWidth: Int? = null + private var lastLeadingSubviewWidth: Int? = null private var lastCenterSubviewWidth: Int? = null - private var lastRightSubviewWidth: Int? = null + private var lastTrailingSubviewWidth: Int? = null // For small header, we need to use custom title view in order to - // render a subview to the left of the title. + // render a subview to the leading side of the title. private var managedTitleView: AppCompatTextView? = null internal fun applyHeaderConfiguration( @@ -83,23 +86,23 @@ internal class StackHeaderCoordinator( private fun requiresRebuild(config: StackHeaderConfigurationProviding): Boolean { val desiredTypeOrNull = if (config.hidden) null else config.type if (desiredTypeOrNull != currentHeaderTypeOrNull) return true - if (config.leftSubview !== attachedLeftSubview) return true + if (config.leadingSubview !== attachedLeadingSubview) return true if (config.centerSubview !== attachedCenterSubview) return true - if (config.rightSubview !== attachedRightSubview) return true + if (config.trailingSubview !== attachedTrailingSubview) return true if (config.backgroundSubview !== attachedBackgroundSubview) return true if (appBarLayout is StackHeaderAppBarLayout.Collapsing) { - if (config.leftSubview?.view?.width != lastLeftSubviewWidth) return true - if (config.rightSubview?.view?.width != lastRightSubviewWidth) return true + if (config.leadingSubview?.view?.width != lastLeadingSubviewWidth) return true + if (config.trailingSubview?.view?.width != lastTrailingSubviewWidth) return true } return false } private fun snapshotSubviewWidths(config: StackHeaderConfigurationProviding) { - lastLeftSubviewWidth = config.leftSubview?.view?.width + lastLeadingSubviewWidth = config.leadingSubview?.view?.width lastCenterSubviewWidth = config.centerSubview?.view?.width - lastRightSubviewWidth = config.rightSubview?.view?.width + lastTrailingSubviewWidth = config.trailingSubview?.view?.width } // endregion @@ -117,15 +120,18 @@ internal class StackHeaderCoordinator( if (desiredTypeOrNull != null) { val appBar = StackHeaderAppBarLayout.create(wrappedContext, desiredTypeOrNull) appBarLayout = appBar - populateAppBar(appBar, config) coordinatorLayout.addView(appBar, 0) appBar.requestApplyInsets() + + maybeApplyRtlCollapsingToolbarLayoutWorkaround(coordinatorLayout, config, appBar) + + populateAppBar(appBar, config) } currentHeaderTypeOrNull = desiredTypeOrNull - attachedLeftSubview = config.leftSubview + attachedLeadingSubview = config.leadingSubview attachedCenterSubview = config.centerSubview - attachedRightSubview = config.rightSubview + attachedTrailingSubview = config.trailingSubview attachedBackgroundSubview = config.backgroundSubview snapshotSubviewWidths(config) } @@ -136,18 +142,18 @@ internal class StackHeaderCoordinator( appBarLayout = null managedTitleView = null currentHeaderTypeOrNull = null - attachedLeftSubview = null + attachedLeadingSubview = null attachedCenterSubview = null - attachedRightSubview = null + attachedTrailingSubview = null attachedBackgroundSubview = null } private fun detachSubviews() { val appBar = appBarLayout ?: return - attachedLeftSubview?.let { appBar.toolbar.removeView(it.view) } + attachedLeadingSubview?.let { appBar.toolbar.removeView(it.view) } attachedCenterSubview?.let { appBar.toolbar.removeView(it.view) } - attachedRightSubview?.let { appBar.toolbar.removeView(it.view) } + attachedTrailingSubview?.let { appBar.toolbar.removeView(it.view) } if (appBar is StackHeaderAppBarLayout.Collapsing) { attachedBackgroundSubview?.let { appBar.collapsingToolbarLayout.removeView(it.view) } @@ -164,16 +170,16 @@ internal class StackHeaderCoordinator( ) { val toolbar = appBar.toolbar - // Toolbar measures children in insertion order. Left and right go first so the + // Toolbar measures children in insertion order. Leading and trailing go first so the // title/center gets the remaining space. - config.leftSubview?.let { + config.leadingSubview?.let { detachFromCurrentParent(it.view) - toolbar.addView(it.view, startGravityParams()) + toolbar.addView(it.view, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.START)) } - config.rightSubview?.let { + config.trailingSubview?.let { detachFromCurrentParent(it.view) - toolbar.addView(it.view, endGravityParams()) + toolbar.addView(it.view, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.END)) } populateTitleOrCenter(appBar, toolbar, config) @@ -192,14 +198,13 @@ internal class StackHeaderCoordinator( managedTitleView = null detachFromCurrentParent(centerSubview.view) - - toolbar.addView(centerSubview.view, centerGravityParams()) + toolbar.addView(centerSubview.view, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.CENTER_HORIZONTAL)) } else { Log.e(TAG, "[RNScreens] Center subview is supported only for small header type.") } } else if (appBar is StackHeaderAppBarLayout.Small) { // Small header needs a managed title view because we can't use - // Toolbar's native title - it would be laid out to the left of left subview. + // Toolbar's native title - it would be laid out to the leading side of leading subview. val titleView = createManagedTitleView(toolbar) managedTitleView = titleView toolbar.addView(titleView) @@ -354,9 +359,9 @@ internal class StackHeaderCoordinator( appBar: StackHeaderAppBarLayout, config: StackHeaderConfigurationProviding, ) { - config.leftSubview?.let { updateSubviewOffset(it, appBar) } + config.leadingSubview?.let { updateSubviewOffset(it, appBar) } config.centerSubview?.let { updateSubviewOffset(it, appBar) } - config.rightSubview?.let { updateSubviewOffset(it, appBar) } + config.trailingSubview?.let { updateSubviewOffset(it, appBar) } config.backgroundSubview?.let { updateSubviewOffset(it, appBar) } } @@ -380,38 +385,55 @@ internal class StackHeaderCoordinator( // endregion - companion object { - private const val TAG = "StackHeaderCoordinator" + private fun detachFromCurrentParent(view: View) { + (view.parent as? ViewGroup)?.removeView(view) + } - private fun detachFromCurrentParent(view: View) { - (view.parent as? ViewGroup)?.removeView(view) + private fun maybeApplyRtlCollapsingToolbarLayoutWorkaround(coordinatorLayout: StackHeaderCoordinatorLayout, config: StackHeaderConfigurationProviding, appBar: StackHeaderAppBarLayout) { + // For collapsing headers, CTL lazily adds a MATCH_PARENT dummy view + // to the Toolbar during the first onMeasure (ensureToolbar). We need + // our subviews at higher indices than the dummy view so they get + // positioned first in RTL layout. Forcing a measure triggers the + // dummy view creation. + if (appBar is StackHeaderAppBarLayout.Collapsing && config.isRtl) { + appBar.measure( + View.MeasureSpec.makeMeasureSpec(coordinatorLayout.width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + ) + moveDummyViewToFront(appBar.toolbar) } + } - private fun startGravityParams() = - Toolbar.LayoutParams( - Toolbar.LayoutParams.WRAP_CONTENT, - Toolbar.LayoutParams.WRAP_CONTENT, - Gravity.START, - ) + /** + * CollapsingToolbarLayout adds a MATCH_PARENT dummy view to the Toolbar + * for title bounds tracking. In RTL, the Toolbar iterates custom views + * in reverse child order - so the dummy view (if last) gets processed + * first and consumes the entire layout cursor. Moving it to index 0 + * ensures our subviews are processed first. + * + * See https://github.com/material-components/material-components-android/issues/1867. + */ + private fun moveDummyViewToFront(toolbar: Toolbar) { + for (i in 0 until toolbar.childCount) { + val child = toolbar.getChildAt(i) + if (child !is StackHeaderSubview) { + val lp = child.layoutParams + toolbar.removeViewAt(i) + toolbar.addView(child, 0, lp) + return + } + } + } - private fun centerGravityParams() = - Toolbar.LayoutParams( - Toolbar.LayoutParams.WRAP_CONTENT, - Toolbar.LayoutParams.WRAP_CONTENT, - Gravity.CENTER_HORIZONTAL, - ) + private fun toNativeCollapseMode(mode: StackHeaderSubviewCollapseMode): Int = + when (mode) { + StackHeaderSubviewCollapseMode.OFF -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_OFF + StackHeaderSubviewCollapseMode.PARALLAX -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PARALLAX + } + + companion object { + private const val TAG = "StackHeaderCoordinator" - private fun endGravityParams() = - Toolbar.LayoutParams( - Toolbar.LayoutParams.WRAP_CONTENT, - Toolbar.LayoutParams.WRAP_CONTENT, - Gravity.END, - ) - private fun toNativeCollapseMode(mode: StackHeaderSubviewCollapseMode): Int = - when (mode) { - StackHeaderSubviewCollapseMode.OFF -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_OFF - StackHeaderSubviewCollapseMode.PARALLAX -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PARALLAX - } } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt index dba745a63b..2c4d37b560 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt @@ -1,6 +1,7 @@ package com.swmansion.rnscreens.gamma.stack.header.configuration import android.annotation.SuppressLint +import android.util.LayoutDirection import com.facebook.react.bridge.ReactContext import com.facebook.react.views.view.ReactViewGroup import com.swmansion.rnscreens.gamma.common.ShadowStateProxy @@ -22,13 +23,16 @@ class StackHeaderConfiguration( override var backgroundSubview: StackHeaderSubview? = null private set - override var leftSubview: StackHeaderSubview? = null + override var leadingSubview: StackHeaderSubview? = null private set override var centerSubview: StackHeaderSubview? = null private set - override var rightSubview: StackHeaderSubview? = null + override var trailingSubview: StackHeaderSubview? = null private set + override val isRtl: Boolean + get() = layoutDirection == LayoutDirection.RTL + private val shadowStateProxy = ShadowStateProxy() var stateWrapper by shadowStateProxy::stateWrapper @@ -56,9 +60,9 @@ class StackHeaderConfiguration( internal fun addConfigSubview(headerSubview: StackHeaderSubview) { when (headerSubview.type) { StackHeaderSubviewType.BACKGROUND -> backgroundSubview = headerSubview - StackHeaderSubviewType.LEFT -> leftSubview = headerSubview + StackHeaderSubviewType.LEADING -> leadingSubview = headerSubview StackHeaderSubviewType.CENTER -> centerSubview = headerSubview - StackHeaderSubviewType.RIGHT -> rightSubview = headerSubview + StackHeaderSubviewType.TRAILING -> trailingSubview = headerSubview } headerSubview.onStackHeaderSubviewChangeListener = WeakReference(this) notifyConfigurationChanged() @@ -68,9 +72,9 @@ class StackHeaderConfiguration( headerSubview.onStackHeaderSubviewChangeListener = null when (headerSubview.type) { StackHeaderSubviewType.BACKGROUND -> backgroundSubview = null - StackHeaderSubviewType.LEFT -> leftSubview = null + StackHeaderSubviewType.LEADING -> leadingSubview = null StackHeaderSubviewType.CENTER -> centerSubview = null - StackHeaderSubviewType.RIGHT -> rightSubview = null + StackHeaderSubviewType.TRAILING -> trailingSubview = null } notifyConfigurationChanged() } @@ -81,14 +85,14 @@ class StackHeaderConfiguration( internal fun removeAllConfigSubviews() { backgroundSubview?.let { removeConfigSubview(it) } - leftSubview?.let { removeConfigSubview(it) } + leadingSubview?.let { removeConfigSubview(it) } centerSubview?.let { removeConfigSubview(it) } - rightSubview?.let { removeConfigSubview(it) } + trailingSubview?.let { removeConfigSubview(it) } } internal val configSubviewsCount: Int - get() = listOfNotNull(backgroundSubview, leftSubview, centerSubview, rightSubview).size + get() = listOfNotNull(backgroundSubview, leadingSubview, centerSubview, trailingSubview).size internal fun getConfigSubviewAt(index: Int): StackHeaderSubview? = - listOfNotNull(backgroundSubview, leftSubview, centerSubview, rightSubview).getOrNull(index) + listOfNotNull(backgroundSubview, leadingSubview, centerSubview, trailingSubview).getOrNull(index) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt index 30d95ddf7b..f652b84a27 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt @@ -7,10 +7,12 @@ internal interface StackHeaderConfigurationProviding { val title: String val hidden: Boolean val transparent: Boolean - val leftSubview: StackHeaderSubviewProviding? + val leadingSubview: StackHeaderSubviewProviding? val centerSubview: StackHeaderSubviewProviding? - val rightSubview: StackHeaderSubviewProviding? + val trailingSubview: StackHeaderSubviewProviding? val backgroundSubview: StackHeaderSubviewProviding? + val isRtl: Boolean + fun updateHeaderFrame(width: Int, height: Int, contentOffsetY: Int) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt index 4ad12b2254..e1c15132e8 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -15,7 +15,7 @@ class StackHeaderSubview( override var type: StackHeaderSubviewType = StackHeaderSubviewType.CENTER override var collapseMode: StackHeaderSubviewCollapseMode by Delegates.observable( - StackHeaderSubviewCollapseMode.PARALLAX, + StackHeaderSubviewCollapseMode.OFF, ) { _, oldValue, newValue -> if (oldValue != newValue) { onStackHeaderSubviewChangeListener?.get()?.onStackHeaderSubviewChange() diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt index 4573f6214a..46e843f4b6 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt @@ -2,7 +2,7 @@ package com.swmansion.rnscreens.gamma.stack.header.subview enum class StackHeaderSubviewType { BACKGROUND, - LEFT, + LEADING, CENTER, - RIGHT, + TRAILING, } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt index 8c1853f6c0..31aeaf3bb5 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt @@ -32,9 +32,9 @@ open class StackHeaderSubviewViewManager : ) { view.type = when (value) { - "left" -> StackHeaderSubviewType.LEFT + "leading" -> StackHeaderSubviewType.LEADING "center" -> StackHeaderSubviewType.CENTER - "right" -> StackHeaderSubviewType.RIGHT + "trailing" -> StackHeaderSubviewType.TRAILING "background" -> StackHeaderSubviewType.BACKGROUND else -> throw JSApplicationIllegalArgumentException("[RNScreens] Invalid StackHeaderSubview type: $value") } diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.tsx b/apps/src/shared/gamma/containers/stack/StackContainer.tsx index cdbf6d48dd..d9a85b9ea7 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.tsx +++ b/apps/src/shared/gamma/containers/stack/StackContainer.tsx @@ -96,6 +96,8 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { style={{ backgroundColor: 'blue', flex: 1, + // width: '50%', + // height: '100%', alignItems: 'center', justifyContent: 'center', }}> @@ -104,9 +106,9 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { - + - left + leading @@ -114,9 +116,9 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { center - + - right + trailing diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp index d7dcd8bd11..d9dc5a4e7c 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp @@ -5,12 +5,24 @@ namespace facebook::react { extern const char RNSStackHeaderSubviewComponentName[] = "RNSStackHeaderSubview"; -#ifdef ANDROID Point RNSStackHeaderSubviewShadowNode::getContentOriginOffset( bool /*includeTransform*/) const { auto stateData = getStateData(); return stateData.contentOffset; } -#endif // ANDROID + +void RNSStackHeaderSubviewShadowNode::layout( + facebook::react::LayoutContext layoutContext) { + YogaLayoutableShadowNode::layout(layoutContext); + applyFrameCorrections(); +} + +// Subviews are reparented into native Toolbar which handles positioning via +// gravity (START/END). We force x=0 so that Fabric doesn't override the +// Toolbar's layout with Yoga-computed RTL coordinates. +void RNSStackHeaderSubviewShadowNode::applyFrameCorrections() { + ensureUnsealed(); + layoutMetrics_.frame.origin.x = 0; +} } // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h index 8e98a4f8be..01856256eb 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "RNSStackHeaderSubviewState.h" namespace facebook::react { @@ -20,9 +21,15 @@ class JSI_EXPORT RNSStackHeaderSubviewShadowNode final using ConcreteViewShadowNode::ConcreteViewShadowNode; using StateData = ConcreteViewShadowNode::ConcreteStateData; -#ifdef ANDROID +#pragma mark - ShadowNode overrides + Point getContentOriginOffset(bool includeTransform) const override; -#endif // ANDROID + + void layout(LayoutContext layoutContext) override; + +#pragma mark - Custom interface + private: + void applyFrameCorrections(); }; } // namespace facebook::react diff --git a/src/components/gamma/stack/header/StackHeaderSubview.tsx b/src/components/gamma/stack/header/StackHeaderSubview.tsx index 74b3006486..a759b474e2 100644 --- a/src/components/gamma/stack/header/StackHeaderSubview.tsx +++ b/src/components/gamma/stack/header/StackHeaderSubview.tsx @@ -14,7 +14,7 @@ function StackHeaderSubview(props: StackHeaderSubviewProps) { style={ filteredProps.type === 'background' ? StyleSheet.absoluteFill - : { position: 'absolute', left: 0, top: 0 } + : { position: 'absolute', start: 0, top: 0 } } {...filteredProps}> {children} diff --git a/src/components/gamma/stack/header/StackHeaderSubview.types.ts b/src/components/gamma/stack/header/StackHeaderSubview.types.ts index d6c2218454..e7e580f101 100644 --- a/src/components/gamma/stack/header/StackHeaderSubview.types.ts +++ b/src/components/gamma/stack/header/StackHeaderSubview.types.ts @@ -1,10 +1,10 @@ import { ViewProps } from 'react-native'; export type StackHeaderSubviewTypeAndroid = - | 'left' + | 'background' + | 'leading' | 'center' - | 'right' - | 'background'; + | 'trailing'; export type StackHeaderSubviewBackgroundCollapseModeAndroid = | 'off' diff --git a/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts index 0aa036896a..d01f95a728 100644 --- a/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts +++ b/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts @@ -3,14 +3,18 @@ import type { CodegenTypes as CT, ViewProps } from 'react-native'; import { codegenNativeComponent } from 'react-native'; -type StackHeaderSubviewTypeAndroid = 'left' | 'center' | 'right' | 'background'; +type StackHeaderSubviewTypeAndroid = + | 'background' + | 'leading' + | 'center' + | 'trailing'; type StackHeaderSubviewBackgroundCollapseModeAndroid = 'off' | 'parallax'; export interface NativeProps extends ViewProps { - type?: CT.WithDefault; + type?: CT.WithDefault; collapseMode?: CT.WithDefault< StackHeaderSubviewBackgroundCollapseModeAndroid, - 'parallax' + 'off' >; } From a6c45915d080f7daa86603ebbf73cacd51bf45fd Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Tue, 24 Mar 2026 16:07:54 +0100 Subject: [PATCH 33/92] clean up --- .../com/swmansion/rnscreens/ext/ViewExt.kt | 4 +++ .../stack/header/StackHeaderAppBarLayout.kt | 2 -- .../stack/header/StackHeaderCoordinator.kt | 34 ++++++------------- .../configuration/StackHeaderConfiguration.kt | 6 +++- .../StackHeaderConfigurationProviding.kt | 6 +++- .../StackHeaderConfigurationViewManager.kt | 5 ++- .../header/subview/StackHeaderSubview.kt | 4 ++- .../subview/StackHeaderSubviewCollapseMode.kt | 9 +++++ .../subview/StackHeaderSubviewProviding.kt | 5 ++- .../gamma/stack/screen/StackScreen.kt | 2 +- 10 files changed, 46 insertions(+), 31 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/ext/ViewExt.kt b/android/src/main/java/com/swmansion/rnscreens/ext/ViewExt.kt index 1fe4281d0c..37e54fedac 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ext/ViewExt.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ext/ViewExt.kt @@ -52,3 +52,7 @@ internal fun View.findFragmentOrNull(): Fragment? = * before being attached to window. */ internal fun View.isMeasured(): Boolean = this.measuredWidth != 0 || this.measuredHeight != 0 || this.isLaidOut + +internal fun View.detachFromCurrentParent() { + (parent as? ViewGroup)?.removeView(this) +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt index bb54c5db30..052ef5f4ce 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt @@ -42,7 +42,6 @@ internal sealed class StackHeaderAppBarLayout( layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { // TODO: debug only for small header, must be moved to configuration -// scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_SNAP scrollFlags = SCROLL_FLAG_NO_SCROLL } } @@ -89,7 +88,6 @@ internal sealed class StackHeaderAppBarLayout( ).apply { // TODO: debug only for medium/large header, must be moved to configuration scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED -// scrollFlags = SCROLL_FLAG_NO_SCROLL } addView(toolbar) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 3067f380c1..9b1b807fbb 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -2,11 +2,9 @@ package com.swmansion.rnscreens.gamma.stack.header import android.content.Context import android.text.TextUtils -import android.util.LayoutDirection import android.util.Log import android.view.Gravity import android.view.View -import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import androidx.appcompat.view.ContextThemeWrapper @@ -16,10 +14,10 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.widget.TextViewCompat import com.google.android.material.R import com.google.android.material.appbar.CollapsingToolbarLayout +import com.swmansion.rnscreens.ext.detachFromCurrentParent import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationProviding import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderType import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview -import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewCollapseMode import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewProviding internal class StackHeaderCoordinator( @@ -45,7 +43,6 @@ internal class StackHeaderCoordinator( // CollapsingToolbarLayout can't resize custom views at runtime, // so we must rebuild the hierarchy when toolbar subview widths change. private var lastLeadingSubviewWidth: Int? = null - private var lastCenterSubviewWidth: Int? = null private var lastTrailingSubviewWidth: Int? = null // For small header, we need to use custom title view in order to @@ -101,7 +98,6 @@ internal class StackHeaderCoordinator( private fun snapshotSubviewWidths(config: StackHeaderConfigurationProviding) { lastLeadingSubviewWidth = config.leadingSubview?.view?.width - lastCenterSubviewWidth = config.centerSubview?.view?.width lastTrailingSubviewWidth = config.trailingSubview?.view?.width } @@ -173,12 +169,12 @@ internal class StackHeaderCoordinator( // Toolbar measures children in insertion order. Leading and trailing go first so the // title/center gets the remaining space. config.leadingSubview?.let { - detachFromCurrentParent(it.view) + it.view.detachFromCurrentParent() toolbar.addView(it.view, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.START)) } config.trailingSubview?.let { - detachFromCurrentParent(it.view) + it.view.detachFromCurrentParent() toolbar.addView(it.view, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.END)) } @@ -197,7 +193,7 @@ internal class StackHeaderCoordinator( toolbar.removeView(managedTitleView) managedTitleView = null - detachFromCurrentParent(centerSubview.view) + centerSubview.view.detachFromCurrentParent() toolbar.addView(centerSubview.view, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.CENTER_HORIZONTAL)) } else { Log.e(TAG, "[RNScreens] Center subview is supported only for small header type.") @@ -222,7 +218,7 @@ internal class StackHeaderCoordinator( return } - detachFromCurrentParent(backgroundSubview.view) + backgroundSubview.view.detachFromCurrentParent() // Needed to extend the background under the status bar backgroundSubview.view.fitsSystemWindows = true @@ -281,7 +277,7 @@ internal class StackHeaderCoordinator( private fun applyBackgroundCollapseMode(config: StackHeaderConfigurationProviding) { val backgroundSubview = config.backgroundSubview ?: return val params = backgroundSubview.view.layoutParams as? CollapsingToolbarLayout.LayoutParams ?: return - val desired = toNativeCollapseMode(backgroundSubview.collapseMode) + val desired = backgroundSubview.collapseMode.toNativeCollapseMode() if (params.collapseMode != desired) { params.collapseMode = desired } @@ -385,11 +381,11 @@ internal class StackHeaderCoordinator( // endregion - private fun detachFromCurrentParent(view: View) { - (view.parent as? ViewGroup)?.removeView(view) - } - - private fun maybeApplyRtlCollapsingToolbarLayoutWorkaround(coordinatorLayout: StackHeaderCoordinatorLayout, config: StackHeaderConfigurationProviding, appBar: StackHeaderAppBarLayout) { + private fun maybeApplyRtlCollapsingToolbarLayoutWorkaround( + coordinatorLayout: StackHeaderCoordinatorLayout, + config: StackHeaderConfigurationProviding, + appBar: StackHeaderAppBarLayout, + ) { // For collapsing headers, CTL lazily adds a MATCH_PARENT dummy view // to the Toolbar during the first onMeasure (ensureToolbar). We need // our subviews at higher indices than the dummy view so they get @@ -425,15 +421,7 @@ internal class StackHeaderCoordinator( } } - private fun toNativeCollapseMode(mode: StackHeaderSubviewCollapseMode): Int = - when (mode) { - StackHeaderSubviewCollapseMode.OFF -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_OFF - StackHeaderSubviewCollapseMode.PARALLAX -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PARALLAX - } - companion object { private const val TAG = "StackHeaderCoordinator" - - } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt index 2c4d37b560..a9c15804ce 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt @@ -17,9 +17,13 @@ class StackHeaderConfiguration( StackHeaderConfigurationProviding, OnStackHeaderSubviewChangeListener { override var type: StackHeaderType = StackHeaderType.SMALL + internal set override var title: String = "" + internal set override var hidden: Boolean = false + internal set override var transparent: Boolean = false + internal set override var backgroundSubview: StackHeaderSubview? = null private set @@ -35,7 +39,7 @@ class StackHeaderConfiguration( private val shadowStateProxy = ShadowStateProxy() - var stateWrapper by shadowStateProxy::stateWrapper + internal var stateWrapper by shadowStateProxy::stateWrapper override fun updateHeaderFrame( width: Int, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt index f652b84a27..0b9c8c095f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt @@ -14,5 +14,9 @@ internal interface StackHeaderConfigurationProviding { val isRtl: Boolean - fun updateHeaderFrame(width: Int, height: Int, contentOffsetY: Int) + fun updateHeaderFrame( + width: Int, + height: Int, + contentOffsetY: Int, + ) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt index 0ce23cea3a..37dffd2e27 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt @@ -39,7 +39,10 @@ open class StackHeaderConfigurationViewManager : parent.addConfigSubview(child) } - override fun removeView(parent: StackHeaderConfiguration, view: View) { + override fun removeView( + parent: StackHeaderConfiguration, + view: View, + ) { require(view is StackHeaderSubview) { "[RNScreens] StackHeaderConfiguration can only have children of type StackHeaderSubview. Attempted to remove $view instead." } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt index e1c15132e8..483077f4f1 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -13,6 +13,7 @@ class StackHeaderSubview( ) : ReactViewGroup(reactContext), StackHeaderSubviewProviding { override var type: StackHeaderSubviewType = StackHeaderSubviewType.CENTER + internal set override var collapseMode: StackHeaderSubviewCollapseMode by Delegates.observable( StackHeaderSubviewCollapseMode.OFF, @@ -21,12 +22,13 @@ class StackHeaderSubview( onStackHeaderSubviewChangeListener?.get()?.onStackHeaderSubviewChange() } } + internal set override val view = this private val shadowStateProxy = ShadowStateProxy(includesFrameSize = false) - var stateWrapper by shadowStateProxy::stateWrapper + internal var stateWrapper by shadowStateProxy::stateWrapper override fun updateContentOriginOffset( x: Int, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt index a66b4d2829..cec030c3e8 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt @@ -1,6 +1,15 @@ package com.swmansion.rnscreens.gamma.stack.header.subview +import com.google.android.material.appbar.CollapsingToolbarLayout + enum class StackHeaderSubviewCollapseMode { OFF, PARALLAX, + ; + + internal fun toNativeCollapseMode(): Int = + when (this) { + OFF -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_OFF + PARALLAX -> CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PARALLAX + } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt index 040b4c129b..02bccf151d 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt @@ -7,5 +7,8 @@ interface StackHeaderSubviewProviding { val collapseMode: StackHeaderSubviewCollapseMode val view: View - fun updateContentOriginOffset(x: Int, y: Int) + fun updateContentOriginOffset( + x: Int, + y: Int, + ) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt index d21dd17e20..842c4b8830 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt @@ -55,7 +55,7 @@ class StackScreen( private val shadowStateProxy = ShadowStateProxy() - var stateWrapper by shadowStateProxy::stateWrapper + internal var stateWrapper by shadowStateProxy::stateWrapper fun updateStateIfNeeded( x: Int? = null, From a53ffc8ccab7d29012b8387beeda2898a4e7d277 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 1 Apr 2026 18:28:10 +0200 Subject: [PATCH 34/92] rename HeaderConfiguration to HeaderConfig --- .../swmansion/rnscreens/RNScreensPackage.kt | 4 +- .../stack/header/StackHeaderAppBarLayout.kt | 6 +- .../stack/header/StackHeaderCoordinator.kt | 36 ++++++------ .../header/StackHeaderCoordinatorLayout.kt | 44 +++++++-------- .../config/OnHeaderConfigAttachListener.kt | 5 ++ .../config/OnHeaderConfigChangeListener.kt | 5 ++ .../StackHeaderConfig.kt} | 18 +++--- .../StackHeaderConfigProviding.kt} | 4 +- .../StackHeaderConfigViewManager.kt} | 56 +++++++++---------- .../StackHeaderType.kt | 2 +- .../OnHeaderConfigurationAttachListener.kt | 5 -- .../OnHeaderConfigurationChangeListener.kt | 5 -- .../gamma/stack/screen/StackScreen.kt | 22 ++++---- .../stack/screen/StackScreenViewManager.kt | 22 ++++---- android/src/main/jni/rnscreens.h | 2 +- .../gamma/containers/stack/StackContainer.tsx | 8 +-- ...RNSStackHeaderConfigComponentDescriptor.h} | 13 ++--- .../RNSStackHeaderConfigShadowNode.cpp | 15 +++++ ...ode.h => RNSStackHeaderConfigShadowNode.h} | 14 ++--- ...tate.cpp => RNSStackHeaderConfigState.cpp} | 4 +- ...ionState.h => RNSStackHeaderConfigState.h} | 10 ++-- .../RNSStackHeaderConfigurationShadowNode.cpp | 16 ------ react-native.config.js | 2 +- .../gamma/stack/header/StackHeaderConfig.tsx | 21 +++++++ ...on.types.ts => StackHeaderConfig.types.ts} | 2 +- .../stack/header/StackHeaderConfig.web.tsx | 5 ++ .../stack/header/StackHeaderConfiguration.tsx | 21 ------- .../header/StackHeaderConfiguration.web.tsx | 5 -- src/components/gamma/stack/index.ts | 12 +--- ...ts => StackHeaderConfigNativeComponent.ts} | 9 +-- 30 files changed, 191 insertions(+), 202 deletions(-) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigAttachListener.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigChangeListener.kt rename android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/{configuration/StackHeaderConfiguration.kt => config/StackHeaderConfig.kt} (86%) rename android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/{configuration/StackHeaderConfigurationProviding.kt => config/StackHeaderConfigProviding.kt} (81%) rename android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/{configuration/StackHeaderConfigurationViewManager.kt => config/StackHeaderConfigViewManager.kt} (57%) rename android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/{configuration => config}/StackHeaderType.kt (50%) delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationAttachListener.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationChangeListener.kt rename common/cpp/react/renderer/components/rnscreens/{RNSStackHeaderConfigurationComponentDescriptor.h => RNSStackHeaderConfigComponentDescriptor.h} (70%) create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.cpp rename common/cpp/react/renderer/components/rnscreens/{RNSStackHeaderConfigurationShadowNode.h => RNSStackHeaderConfigShadowNode.h} (59%) rename common/cpp/react/renderer/components/rnscreens/{RNSStackHeaderConfigurationState.cpp => RNSStackHeaderConfigState.cpp} (70%) rename common/cpp/react/renderer/components/rnscreens/{RNSStackHeaderConfigurationState.h => RNSStackHeaderConfigState.h} (77%) delete mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.cpp create mode 100644 src/components/gamma/stack/header/StackHeaderConfig.tsx rename src/components/gamma/stack/header/{StackHeaderConfiguration.types.ts => StackHeaderConfig.types.ts} (84%) create mode 100644 src/components/gamma/stack/header/StackHeaderConfig.web.tsx delete mode 100644 src/components/gamma/stack/header/StackHeaderConfiguration.tsx delete mode 100644 src/components/gamma/stack/header/StackHeaderConfiguration.web.tsx rename src/fabric/gamma/stack/{StackHeaderConfigurationNativeComponent.ts => StackHeaderConfigNativeComponent.ts} (77%) diff --git a/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt b/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt index 0026abe91b..bd040bd1ed 100644 --- a/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt +++ b/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt @@ -8,7 +8,7 @@ import com.facebook.react.module.model.ReactModuleInfo import com.facebook.react.module.model.ReactModuleInfoProvider import com.facebook.react.uimanager.ViewManager import com.swmansion.rnscreens.gamma.scrollviewmarker.ScrollViewMarkerViewManager -import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationViewManager +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigViewManager import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewViewManager import com.swmansion.rnscreens.gamma.stack.host.StackHostViewManager import com.swmansion.rnscreens.gamma.stack.screen.StackScreenViewManager @@ -59,7 +59,7 @@ class RNScreensPackage : BaseReactPackage() { StackHostViewManager(), StackScreenViewManager(), ScrollViewMarkerViewManager(), - StackHeaderConfigurationViewManager(), + StackHeaderConfigViewManager(), StackHeaderSubviewViewManager(), ) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt index 052ef5f4ce..6a373aebc7 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt @@ -12,7 +12,7 @@ import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL import com.google.android.material.appbar.CollapsingToolbarLayout import com.google.android.material.appbar.MaterialToolbar -import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderType +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderType import com.swmansion.rnscreens.utils.resolveDimensionAttr internal sealed class StackHeaderAppBarLayout( @@ -41,7 +41,7 @@ internal sealed class StackHeaderAppBarLayout( elevation = 0f layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { - // TODO: debug only for small header, must be moved to configuration + // TODO: debug only for small header, must be moved to config scrollFlags = SCROLL_FLAG_NO_SCROLL } } @@ -86,7 +86,7 @@ internal sealed class StackHeaderAppBarLayout( MATCH_PARENT, resolveDimensionAttr(context, sizeAttr), ).apply { - // TODO: debug only for medium/large header, must be moved to configuration + // TODO: debug only for medium/large header, must be moved to config scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED } addView(toolbar) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 9b1b807fbb..f5f97db6e6 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -15,8 +15,8 @@ import androidx.core.widget.TextViewCompat import com.google.android.material.R import com.google.android.material.appbar.CollapsingToolbarLayout import com.swmansion.rnscreens.ext.detachFromCurrentParent -import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfigurationProviding -import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderType +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigProviding +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderType import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewProviding @@ -32,7 +32,7 @@ internal class StackHeaderCoordinator( private var appBarLayout: StackHeaderAppBarLayout? = null private var currentHeaderTypeOrNull: StackHeaderType? = null - private var currentConfig: StackHeaderConfigurationProviding? = null + private var currentConfig: StackHeaderConfigProviding? = null private var attachedLeadingSubview: StackHeaderSubviewProviding? = null private var attachedCenterSubview: StackHeaderSubviewProviding? = null @@ -49,9 +49,9 @@ internal class StackHeaderCoordinator( // render a subview to the leading side of the title. private var managedTitleView: AppCompatTextView? = null - internal fun applyHeaderConfiguration( + internal fun applyHeaderConfig( coordinatorLayout: StackHeaderCoordinatorLayout, - config: StackHeaderConfigurationProviding?, + config: StackHeaderConfigProviding?, ) { currentConfig = config if (config != null) { @@ -64,7 +64,7 @@ internal class StackHeaderCoordinator( private fun updateHeader( coordinatorLayout: StackHeaderCoordinatorLayout, - config: StackHeaderConfigurationProviding, + config: StackHeaderConfigProviding, ) { if (requiresRebuild(config)) { rebuild(coordinatorLayout, config) @@ -80,7 +80,7 @@ internal class StackHeaderCoordinator( // region Rebuild detection - private fun requiresRebuild(config: StackHeaderConfigurationProviding): Boolean { + private fun requiresRebuild(config: StackHeaderConfigProviding): Boolean { val desiredTypeOrNull = if (config.hidden) null else config.type if (desiredTypeOrNull != currentHeaderTypeOrNull) return true if (config.leadingSubview !== attachedLeadingSubview) return true @@ -96,7 +96,7 @@ internal class StackHeaderCoordinator( return false } - private fun snapshotSubviewWidths(config: StackHeaderConfigurationProviding) { + private fun snapshotSubviewWidths(config: StackHeaderConfigProviding) { lastLeadingSubviewWidth = config.leadingSubview?.view?.width lastTrailingSubviewWidth = config.trailingSubview?.view?.width } @@ -107,7 +107,7 @@ internal class StackHeaderCoordinator( private fun rebuild( coordinatorLayout: StackHeaderCoordinatorLayout, - config: StackHeaderConfigurationProviding, + config: StackHeaderConfigProviding, ) { teardown(coordinatorLayout) @@ -162,7 +162,7 @@ internal class StackHeaderCoordinator( private fun populateAppBar( appBar: StackHeaderAppBarLayout, - config: StackHeaderConfigurationProviding, + config: StackHeaderConfigProviding, ) { val toolbar = appBar.toolbar @@ -185,7 +185,7 @@ internal class StackHeaderCoordinator( private fun populateTitleOrCenter( appBar: StackHeaderAppBarLayout, toolbar: Toolbar, - config: StackHeaderConfigurationProviding, + config: StackHeaderConfigProviding, ) { val centerSubview = config.centerSubview if (centerSubview != null) { @@ -209,7 +209,7 @@ internal class StackHeaderCoordinator( private fun populateBackground( appBar: StackHeaderAppBarLayout, - config: StackHeaderConfigurationProviding, + config: StackHeaderConfigProviding, ) { val backgroundSubview = config.backgroundSubview ?: return @@ -259,7 +259,7 @@ internal class StackHeaderCoordinator( // region In-place prop updates (no rebuild) - private fun applyProps(config: StackHeaderConfigurationProviding) { + private fun applyProps(config: StackHeaderConfigProviding) { val appBar = appBarLayout ?: return when (appBar) { @@ -274,7 +274,7 @@ internal class StackHeaderCoordinator( } } - private fun applyBackgroundCollapseMode(config: StackHeaderConfigurationProviding) { + private fun applyBackgroundCollapseMode(config: StackHeaderConfigProviding) { val backgroundSubview = config.backgroundSubview ?: return val params = backgroundSubview.view.layoutParams as? CollapsingToolbarLayout.LayoutParams ?: return val desired = backgroundSubview.collapseMode.toNativeCollapseMode() @@ -289,7 +289,7 @@ internal class StackHeaderCoordinator( private fun applyContentBehavior( coordinatorLayout: StackHeaderCoordinatorLayout, - config: StackHeaderConfigurationProviding, + config: StackHeaderConfigProviding, ) { val needsBehavior = appBarLayout != null && !config.transparent && !config.hidden if (needsBehavior) { @@ -338,7 +338,7 @@ internal class StackHeaderCoordinator( val config = currentConfig ?: return val appBar = appBarLayout ?: return - // For header configuration we need to: + // For header config we need to: // - cancel out the StackScreen's Y offset (contentTop), // - handle AppBarLayout's negative offset when collapsed. config.updateHeaderFrame( @@ -353,7 +353,7 @@ internal class StackHeaderCoordinator( private fun updateSubviewOffsets( appBar: StackHeaderAppBarLayout, - config: StackHeaderConfigurationProviding, + config: StackHeaderConfigProviding, ) { config.leadingSubview?.let { updateSubviewOffset(it, appBar) } config.centerSubview?.let { updateSubviewOffset(it, appBar) } @@ -383,7 +383,7 @@ internal class StackHeaderCoordinator( private fun maybeApplyRtlCollapsingToolbarLayoutWorkaround( coordinatorLayout: StackHeaderCoordinatorLayout, - config: StackHeaderConfigurationProviding, + config: StackHeaderConfigProviding, appBar: StackHeaderAppBarLayout, ) { // For collapsing headers, CTL lazily adds a MATCH_PARENT dummy view diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index 540140b5e8..237e778926 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -5,9 +5,9 @@ import android.content.Context import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.swmansion.rnscreens.gamma.stack.header.configuration.OnHeaderConfigurationAttachListener -import com.swmansion.rnscreens.gamma.stack.header.configuration.OnHeaderConfigurationChangeListener -import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfiguration +import com.swmansion.rnscreens.gamma.stack.header.config.OnHeaderConfigAttachListener +import com.swmansion.rnscreens.gamma.stack.header.config.OnHeaderConfigChangeListener +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfig import com.swmansion.rnscreens.gamma.stack.host.StackContainer import com.swmansion.rnscreens.gamma.stack.screen.StackScreen import java.lang.ref.WeakReference @@ -23,34 +23,34 @@ internal class StackHeaderCoordinatorLayout( } /** - * This callback is used to detect when header configuration is attached. - * This allows us to configure listener for header configuration changes. + * This callback is used to detect when header config is attached. + * This allows us to configure listener for header config changes. */ - private val onHeaderConfigurationAttach = - OnHeaderConfigurationAttachListener { config -> - handleHeaderConfigurationAttach(config) + private val onHeaderConfigAttach = + OnHeaderConfigAttachListener { config -> + handleHeaderConfigAttach(config) } private var isHeaderUpdatePending = false /** - * This callback is used to listen for header configuration changes. + * This callback is used to listen for header config changes. * We use [isHeaderUpdatePending] to batch changes and pass them to [headerCoordinator]. */ - private val onHeaderConfigurationChange = - OnHeaderConfigurationChangeListener { + private val onHeaderConfigChange = + OnHeaderConfigChangeListener { if (!isHeaderUpdatePending) { isHeaderUpdatePending = true - // Read currentConfiguration when the runnable executes, not when it's posted, + // Read currentConfig when the runnable executes, not when it's posted, // to avoid applying a stale config that was swapped out in the meantime. post { isHeaderUpdatePending = false - headerCoordinator.applyHeaderConfiguration(this, currentConfiguration) + headerCoordinator.applyHeaderConfig(this, currentConfig) } } } - private var currentConfiguration: StackHeaderConfiguration? = null + private var currentConfig: StackHeaderConfig? = null internal var stackScreenWrapper: FrameLayout @@ -70,8 +70,8 @@ internal class StackHeaderCoordinatorLayout( LayoutParams(MATCH_PARENT, MATCH_PARENT), ) - stackScreen.onHeaderConfigurationAttachListener = WeakReference(onHeaderConfigurationAttach) - handleHeaderConfigurationAttach(stackScreen.headerConfiguration) + stackScreen.onHeaderConfigAttachListener = WeakReference(onHeaderConfigAttach) + handleHeaderConfigAttach(stackScreen.headerConfig) } internal fun maybeRequestLayoutContainer() { @@ -81,15 +81,15 @@ internal class StackHeaderCoordinatorLayout( } } - private fun handleHeaderConfigurationAttach(config: StackHeaderConfiguration?) { - // Disconnect old configuration to prevent spurious updates from a detached config - currentConfiguration?.onConfigurationChangeListener = null - currentConfiguration = config + private fun handleHeaderConfigAttach(config: StackHeaderConfig?) { + // Disconnect old config to prevent spurious updates from a detached config + currentConfig?.onConfigChangeListener = null + currentConfig = config if (config != null) { - config.onConfigurationChangeListener = WeakReference(onHeaderConfigurationChange) + config.onConfigChangeListener = WeakReference(onHeaderConfigChange) } - headerCoordinator.applyHeaderConfiguration(this, config) + headerCoordinator.applyHeaderConfig(this, config) } /** diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigAttachListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigAttachListener.kt new file mode 100644 index 0000000000..3c4d2f1ee3 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigAttachListener.kt @@ -0,0 +1,5 @@ +package com.swmansion.rnscreens.gamma.stack.header.config + +internal fun interface OnHeaderConfigAttachListener { + fun onHeaderConfigAttach(config: StackHeaderConfig?) +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigChangeListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigChangeListener.kt new file mode 100644 index 0000000000..0ca047411d --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigChangeListener.kt @@ -0,0 +1,5 @@ +package com.swmansion.rnscreens.gamma.stack.header.config + +internal fun interface OnHeaderConfigChangeListener { + fun onHeaderConfigChange(config: StackHeaderConfigProviding) +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt similarity index 86% rename from android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt rename to android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index a9c15804ce..3076d74058 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfiguration.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -1,4 +1,4 @@ -package com.swmansion.rnscreens.gamma.stack.header.configuration +package com.swmansion.rnscreens.gamma.stack.header.config import android.annotation.SuppressLint import android.util.LayoutDirection @@ -11,10 +11,10 @@ import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewType import java.lang.ref.WeakReference @SuppressLint("ViewConstructor") -class StackHeaderConfiguration( +class StackHeaderConfig( val reactContext: ReactContext, ) : ReactViewGroup(reactContext), - StackHeaderConfigurationProviding, + StackHeaderConfigProviding, OnStackHeaderSubviewChangeListener { override var type: StackHeaderType = StackHeaderType.SMALL internal set @@ -53,13 +53,13 @@ class StackHeaderConfiguration( ) } - internal var onConfigurationChangeListener: WeakReference? = null + internal var onConfigChangeListener: WeakReference? = null - internal fun notifyConfigurationChanged() { - onConfigurationChangeListener?.get()?.onHeaderConfigurationChange(this) + internal fun notifyConfigChanged() { + onConfigChangeListener?.get()?.onHeaderConfigChange(this) } - override fun onStackHeaderSubviewChange() = notifyConfigurationChanged() + override fun onStackHeaderSubviewChange() = notifyConfigChanged() internal fun addConfigSubview(headerSubview: StackHeaderSubview) { when (headerSubview.type) { @@ -69,7 +69,7 @@ class StackHeaderConfiguration( StackHeaderSubviewType.TRAILING -> trailingSubview = headerSubview } headerSubview.onStackHeaderSubviewChangeListener = WeakReference(this) - notifyConfigurationChanged() + notifyConfigChanged() } internal fun removeConfigSubview(headerSubview: StackHeaderSubview) { @@ -80,7 +80,7 @@ class StackHeaderConfiguration( StackHeaderSubviewType.CENTER -> centerSubview = null StackHeaderSubviewType.TRAILING -> trailingSubview = null } - notifyConfigurationChanged() + notifyConfigChanged() } internal fun removeConfigSubviewAt(index: Int) { diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt similarity index 81% rename from android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt rename to android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt index 0b9c8c095f..980f217bf2 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt @@ -1,8 +1,8 @@ -package com.swmansion.rnscreens.gamma.stack.header.configuration +package com.swmansion.rnscreens.gamma.stack.header.config import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewProviding -internal interface StackHeaderConfigurationProviding { +internal interface StackHeaderConfigProviding { val type: StackHeaderType val title: String val hidden: Boolean diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt similarity index 57% rename from android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt rename to android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt index 37dffd2e27..2f6cd026c0 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderConfigurationViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt @@ -1,4 +1,4 @@ -package com.swmansion.rnscreens.gamma.stack.header.configuration +package com.swmansion.rnscreens.gamma.stack.header.config import android.view.View import com.facebook.react.bridge.JSApplicationIllegalArgumentException @@ -8,67 +8,67 @@ import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate -import com.facebook.react.viewmanagers.RNSStackHeaderConfigurationManagerDelegate -import com.facebook.react.viewmanagers.RNSStackHeaderConfigurationManagerInterface +import com.facebook.react.viewmanagers.RNSStackHeaderConfigManagerDelegate +import com.facebook.react.viewmanagers.RNSStackHeaderConfigManagerInterface import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview -@ReactModule(name = StackHeaderConfigurationViewManager.REACT_CLASS) -open class StackHeaderConfigurationViewManager : - ViewGroupManager(), - RNSStackHeaderConfigurationManagerInterface { - private val delegate: ViewManagerDelegate +@ReactModule(name = StackHeaderConfigViewManager.REACT_CLASS) +open class StackHeaderConfigViewManager : + ViewGroupManager(), + RNSStackHeaderConfigManagerInterface { + private val delegate: ViewManagerDelegate init { - delegate = RNSStackHeaderConfigurationManagerDelegate(this) + delegate = RNSStackHeaderConfigManagerDelegate(this) } override fun getName() = REACT_CLASS - override fun createViewInstance(reactContext: ThemedReactContext) = StackHeaderConfiguration(reactContext) + override fun createViewInstance(reactContext: ThemedReactContext) = StackHeaderConfig(reactContext) - override fun getDelegate(): ViewManagerDelegate = delegate + override fun getDelegate(): ViewManagerDelegate = delegate override fun addView( - parent: StackHeaderConfiguration, + parent: StackHeaderConfig, child: View, index: Int, ) { require(child is StackHeaderSubview) { - "[RNScreens] StackHeaderConfiguration can only have children of type StackHeaderSubview. Received $child instead." + "[RNScreens] StackHeaderConfig can only have children of type StackHeaderSubview. Received $child instead." } parent.addConfigSubview(child) } override fun removeView( - parent: StackHeaderConfiguration, + parent: StackHeaderConfig, view: View, ) { require(view is StackHeaderSubview) { - "[RNScreens] StackHeaderConfiguration can only have children of type StackHeaderSubview. Attempted to remove $view instead." + "[RNScreens] StackHeaderConfig can only have children of type StackHeaderSubview. Attempted to remove $view instead." } parent.removeConfigSubview(view) } override fun removeViewAt( - parent: StackHeaderConfiguration, + parent: StackHeaderConfig, index: Int, ) { parent.removeConfigSubviewAt(index) } - override fun removeAllViews(parent: StackHeaderConfiguration) { + override fun removeAllViews(parent: StackHeaderConfig) { parent.removeAllConfigSubviews() } - override fun getChildCount(parent: StackHeaderConfiguration): Int = parent.configSubviewsCount + override fun getChildCount(parent: StackHeaderConfig): Int = parent.configSubviewsCount override fun getChildAt( - parent: StackHeaderConfiguration, + parent: StackHeaderConfig, index: Int, ): View? = parent.getConfigSubviewAt(index) override fun updateState( - view: StackHeaderConfiguration, + view: StackHeaderConfig, props: ReactStylesDiffMap?, stateWrapper: StateWrapper?, ): Any? { @@ -76,13 +76,13 @@ open class StackHeaderConfigurationViewManager : return super.updateState(view, props, stateWrapper) } - override fun onAfterUpdateTransaction(view: StackHeaderConfiguration) { + override fun onAfterUpdateTransaction(view: StackHeaderConfig) { super.onAfterUpdateTransaction(view) - view.notifyConfigurationChanged() + view.notifyConfigChanged() } override fun setType( - view: StackHeaderConfiguration, + view: StackHeaderConfig, value: String?, ) { view.type = @@ -90,32 +90,32 @@ open class StackHeaderConfigurationViewManager : "small" -> StackHeaderType.SMALL "medium" -> StackHeaderType.MEDIUM "large" -> StackHeaderType.LARGE - else -> throw JSApplicationIllegalArgumentException("[RNScreens] Invalid StackHeaderConfiguration type: $value.") + else -> throw JSApplicationIllegalArgumentException("[RNScreens] Invalid StackHeaderConfig type: $value.") } } override fun setTitle( - view: StackHeaderConfiguration, + view: StackHeaderConfig, value: String?, ) { view.title = value ?: "" } override fun setHidden( - view: StackHeaderConfiguration, + view: StackHeaderConfig, value: Boolean, ) { view.hidden = value } override fun setTransparent( - view: StackHeaderConfiguration, + view: StackHeaderConfig, value: Boolean, ) { view.transparent = value } companion object { - const val REACT_CLASS = "RNSStackHeaderConfiguration" + const val REACT_CLASS = "RNSStackHeaderConfig" } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderType.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderType.kt similarity index 50% rename from android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderType.kt rename to android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderType.kt index b9816eaaac..601b21a500 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/StackHeaderType.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderType.kt @@ -1,4 +1,4 @@ -package com.swmansion.rnscreens.gamma.stack.header.configuration +package com.swmansion.rnscreens.gamma.stack.header.config enum class StackHeaderType { SMALL, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationAttachListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationAttachListener.kt deleted file mode 100644 index 31298760a5..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationAttachListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.header.configuration - -internal fun interface OnHeaderConfigurationAttachListener { - fun onHeaderConfigurationAttach(config: StackHeaderConfiguration?) -} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationChangeListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationChangeListener.kt deleted file mode 100644 index 60df908369..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/configuration/OnHeaderConfigurationChangeListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.header.configuration - -internal fun interface OnHeaderConfigurationChangeListener { - fun onHeaderConfigurationChange(config: StackHeaderConfigurationProviding) -} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt index 842c4b8830..dcf19f4490 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt @@ -8,8 +8,8 @@ import com.facebook.react.uimanager.ThemedReactContext import com.swmansion.rnscreens.ext.findFragmentOrNull import com.swmansion.rnscreens.gamma.common.FragmentProviding import com.swmansion.rnscreens.gamma.common.ShadowStateProxy -import com.swmansion.rnscreens.gamma.stack.header.configuration.OnHeaderConfigurationAttachListener -import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfiguration +import com.swmansion.rnscreens.gamma.stack.header.config.OnHeaderConfigAttachListener +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfig import com.swmansion.rnscreens.gamma.stack.host.StackHost import java.lang.ref.WeakReference import kotlin.properties.Delegates @@ -69,20 +69,20 @@ class StackScreen( frameHeight = height, ) - internal var headerConfiguration: StackHeaderConfiguration? = null + internal var headerConfig: StackHeaderConfig? = null private set - internal var onHeaderConfigurationAttachListener: WeakReference? = null + internal var onHeaderConfigAttachListener: WeakReference? = null - internal fun attachHeaderConfiguration(header: StackHeaderConfiguration) { - headerConfiguration = header - onHeaderConfigurationAttachListener?.get()?.onHeaderConfigurationAttach(header) + internal fun attachHeaderConfig(header: StackHeaderConfig) { + headerConfig = header + onHeaderConfigAttachListener?.get()?.onHeaderConfigAttach(header) } - internal fun detachHeaderConfiguration(header: StackHeaderConfiguration) { - if (headerConfiguration === header) { - headerConfiguration = null - onHeaderConfigurationAttachListener?.get()?.onHeaderConfigurationAttach(null) + internal fun detachHeaderConfig(header: StackHeaderConfig) { + if (headerConfig === header) { + headerConfig = null + onHeaderConfigAttachListener?.get()?.onHeaderConfigAttach(null) } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt index ec1a477acb..679084bb1b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt @@ -11,7 +11,7 @@ import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.viewmanagers.RNSStackScreenManagerDelegate import com.facebook.react.viewmanagers.RNSStackScreenManagerInterface import com.swmansion.rnscreens.gamma.helpers.makeEventRegistrationInfo -import com.swmansion.rnscreens.gamma.stack.header.configuration.StackHeaderConfiguration +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfig import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidAppearEvent import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidDisappearEvent import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDismissEvent @@ -36,8 +36,8 @@ class StackScreenViewManager : child: View, index: Int, ) { - if (child is StackHeaderConfiguration) { - parent.attachHeaderConfiguration(child) + if (child is StackHeaderConfig) { + parent.attachHeaderConfig(child) } else { super.addView(parent, child, index) } @@ -47,35 +47,35 @@ class StackScreenViewManager : parent: StackScreen, view: View, ) { - if (view is StackHeaderConfiguration) { - parent.detachHeaderConfiguration(view) + if (view is StackHeaderConfig) { + parent.detachHeaderConfig(view) } else { super.removeView(parent, view) } } - // Header configuration is not added as a native child (it's stored as a reference + // Header config is not added as a native child (it's stored as a reference // on StackScreen), but React tracks it by index. Since it's always the last child // in the React tree, we only need to handle the last index specially. override fun removeViewAt( parent: StackScreen, index: Int, ) { - if (index == getChildCount(parent) - 1 && parent.headerConfiguration != null) { - parent.headerConfiguration?.let { parent.detachHeaderConfiguration(it) } + if (index == getChildCount(parent) - 1 && parent.headerConfig != null) { + parent.headerConfig?.let { parent.detachHeaderConfig(it) } } else { super.removeViewAt(parent, index) } } - override fun getChildCount(parent: StackScreen): Int = parent.childCount + if (parent.headerConfiguration != null) 1 else 0 + override fun getChildCount(parent: StackScreen): Int = parent.childCount + if (parent.headerConfig != null) 1 else 0 override fun getChildAt( parent: StackScreen, index: Int, ): View? { - if (index == parent.childCount && parent.headerConfiguration != null) { - return parent.headerConfiguration + if (index == parent.childCount && parent.headerConfig != null) { + return parent.headerConfig } return parent.getChildAt(index) } diff --git a/android/src/main/jni/rnscreens.h b/android/src/main/jni/rnscreens.h index 47868368d9..8be4c22a90 100644 --- a/android/src/main/jni/rnscreens.h +++ b/android/src/main/jni/rnscreens.h @@ -24,7 +24,7 @@ #include #include #include -#include +#include #include namespace facebook { diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.tsx b/apps/src/shared/gamma/containers/stack/StackContainer.tsx index d9a85b9ea7..9b153bff97 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.tsx +++ b/apps/src/shared/gamma/containers/stack/StackContainer.tsx @@ -85,10 +85,10 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { onNativeDismiss={onScreenNativelyDismissed}> - - @@ -120,8 +120,8 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { trailing - - + */} + ); diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigComponentDescriptor.h similarity index 70% rename from common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationComponentDescriptor.h rename to common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigComponentDescriptor.h index 90935b527b..2a969b2aa2 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationComponentDescriptor.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigComponentDescriptor.h @@ -6,30 +6,29 @@ #include #include -#include "RNSStackHeaderConfigurationShadowNode.h" +#include "RNSStackHeaderConfigShadowNode.h" namespace facebook::react { -class RNSStackHeaderConfigurationComponentDescriptor final - : public ConcreteComponentDescriptor< - RNSStackHeaderConfigurationShadowNode> { +class RNSStackHeaderConfigComponentDescriptor final + : public ConcreteComponentDescriptor { public: using ConcreteComponentDescriptor::ConcreteComponentDescriptor; void adopt(ShadowNode &shadowNode) const override { react_native_assert( - dynamic_cast(&shadowNode)); + dynamic_cast(&shadowNode)); #ifdef ANDROID auto &configShadowNode = - static_cast(shadowNode); + static_cast(shadowNode); react_native_assert( dynamic_cast(&configShadowNode)); auto &layoutableShadowNode = static_cast(configShadowNode); auto state = std::static_pointer_cast< - const RNSStackHeaderConfigurationShadowNode::ConcreteState>( + const RNSStackHeaderConfigShadowNode::ConcreteState>( shadowNode.getState()); auto stateData = state->getData(); diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.cpp new file mode 100644 index 0000000000..77b0aadb9c --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.cpp @@ -0,0 +1,15 @@ +#include "RNSStackHeaderConfigShadowNode.h" + +namespace facebook::react { + +extern const char RNSStackHeaderConfigComponentName[] = "RNSStackHeaderConfig"; + +#ifdef ANDROID +Point RNSStackHeaderConfigShadowNode::getContentOriginOffset( + bool /*includeTransform*/) const { + auto stateData = getStateData(); + return stateData.contentOffset; +} +#endif // ANDROID + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.h similarity index 59% rename from common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.h rename to common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.h index 88e54536c1..733bd638dd 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.h @@ -4,18 +4,18 @@ #include #include #include -#include "RNSStackHeaderConfigurationState.h" +#include "RNSStackHeaderConfigState.h" namespace facebook::react { -JSI_EXPORT extern const char RNSStackHeaderConfigurationComponentName[]; +JSI_EXPORT extern const char RNSStackHeaderConfigComponentName[]; -class JSI_EXPORT RNSStackHeaderConfigurationShadowNode final +class JSI_EXPORT RNSStackHeaderConfigShadowNode final : public ConcreteViewShadowNode< - RNSStackHeaderConfigurationComponentName, - RNSStackHeaderConfigurationProps, - RNSStackHeaderConfigurationEventEmitter, - RNSStackHeaderConfigurationState> { + RNSStackHeaderConfigComponentName, + RNSStackHeaderConfigProps, + RNSStackHeaderConfigEventEmitter, + RNSStackHeaderConfigState> { public: using ConcreteViewShadowNode::ConcreteViewShadowNode; using StateData = ConcreteViewShadowNode::ConcreteStateData; diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.cpp similarity index 70% rename from common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.cpp rename to common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.cpp index 05ca5ec467..9db860c70a 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.cpp +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.cpp @@ -1,9 +1,9 @@ -#include "RNSStackHeaderConfigurationState.h" +#include "RNSStackHeaderConfigState.h" namespace facebook::react { #ifdef ANDROID -folly::dynamic RNSStackHeaderConfigurationState::getDynamic() const { +folly::dynamic RNSStackHeaderConfigState::getDynamic() const { return folly::dynamic::object("frameWidth", frameSize.width)( "frameHeight", frameSize.height)("contentOffsetX", contentOffset.x)( "contentOffsetY", contentOffset.y); diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.h similarity index 77% rename from common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.h rename to common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.h index 665066f601..7826cdea0b 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationState.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.h @@ -12,15 +12,15 @@ namespace facebook::react { -class JSI_EXPORT RNSStackHeaderConfigurationState final { +class JSI_EXPORT RNSStackHeaderConfigState final { public: - using Shared = std::shared_ptr; + using Shared = std::shared_ptr; - RNSStackHeaderConfigurationState() {}; + RNSStackHeaderConfigState() {}; #ifdef ANDROID - RNSStackHeaderConfigurationState( - RNSStackHeaderConfigurationState const &previousState, + RNSStackHeaderConfigState( + RNSStackHeaderConfigState const &previousState, folly::dynamic data) : frameSize( Size{ diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.cpp deleted file mode 100644 index a70f00aa73..0000000000 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigurationShadowNode.cpp +++ /dev/null @@ -1,16 +0,0 @@ -#include "RNSStackHeaderConfigurationShadowNode.h" - -namespace facebook::react { - -extern const char RNSStackHeaderConfigurationComponentName[] = - "RNSStackHeaderConfiguration"; - -#ifdef ANDROID -Point RNSStackHeaderConfigurationShadowNode::getContentOriginOffset( - bool /*includeTransform*/) const { - auto stateData = getStateData(); - return stateData.contentOffset; -} -#endif // ANDROID - -} // namespace facebook::react diff --git a/react-native.config.js b/react-native.config.js index 8fab3480a9..635291d71d 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -17,7 +17,7 @@ module.exports = { 'RNSTabsHostComponentDescriptor', 'RNSSafeAreaViewComponentDescriptor', 'RNSStackScreenComponentDescriptor', - 'RNSStackHeaderConfigurationComponentDescriptor', + 'RNSStackHeaderConfigComponentDescriptor', 'RNSStackHeaderSubviewComponentDescriptor' ], cmakeListsPath: "../android/src/main/jni/CMakeLists.txt" diff --git a/src/components/gamma/stack/header/StackHeaderConfig.tsx b/src/components/gamma/stack/header/StackHeaderConfig.tsx new file mode 100644 index 0000000000..40bd49bc46 --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderConfig.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; +import { StackHeaderConfigProps } from './StackHeaderConfig.types'; +import StackHeaderConfigNativeComponent from '../../../../fabric/gamma/stack/StackHeaderConfigNativeComponent'; + +/** + * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE + */ +function StackHeaderConfig(props: StackHeaderConfigProps) { + const { children, ...filteredProps } = props; + return ( + + {children} + + ); +} + +export default StackHeaderConfig; diff --git a/src/components/gamma/stack/header/StackHeaderConfiguration.types.ts b/src/components/gamma/stack/header/StackHeaderConfig.types.ts similarity index 84% rename from src/components/gamma/stack/header/StackHeaderConfiguration.types.ts rename to src/components/gamma/stack/header/StackHeaderConfig.types.ts index 6aa32f9391..507e76e06e 100644 --- a/src/components/gamma/stack/header/StackHeaderConfiguration.types.ts +++ b/src/components/gamma/stack/header/StackHeaderConfig.types.ts @@ -2,7 +2,7 @@ import { ViewProps } from 'react-native'; export type StackHeaderTypeAndroid = 'small' | 'medium' | 'large'; -export type StackHeaderConfigurationProps = { +export type StackHeaderConfigProps = { children?: ViewProps['children']; type?: StackHeaderTypeAndroid; diff --git a/src/components/gamma/stack/header/StackHeaderConfig.web.tsx b/src/components/gamma/stack/header/StackHeaderConfig.web.tsx new file mode 100644 index 0000000000..c65135ec55 --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderConfig.web.tsx @@ -0,0 +1,5 @@ +import { View } from 'react-native'; + +const StackHeaderConfig = View; + +export default StackHeaderConfig; diff --git a/src/components/gamma/stack/header/StackHeaderConfiguration.tsx b/src/components/gamma/stack/header/StackHeaderConfiguration.tsx deleted file mode 100644 index a3eeb0b9cb..0000000000 --- a/src/components/gamma/stack/header/StackHeaderConfiguration.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import { StyleSheet } from 'react-native'; -import { StackHeaderConfigurationProps } from './StackHeaderConfiguration.types'; -import StackHeaderConfigurationNativeComponent from '../../../../fabric/gamma/stack/StackHeaderConfigurationNativeComponent'; - -/** - * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE - */ -function StackHeaderConfiguration(props: StackHeaderConfigurationProps) { - const { children, ...filteredProps } = props; - return ( - - {children} - - ); -} - -export default StackHeaderConfiguration; diff --git a/src/components/gamma/stack/header/StackHeaderConfiguration.web.tsx b/src/components/gamma/stack/header/StackHeaderConfiguration.web.tsx deleted file mode 100644 index d9db852a32..0000000000 --- a/src/components/gamma/stack/header/StackHeaderConfiguration.web.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { View } from 'react-native'; - -const StackHeaderConfiguration = View; - -export default StackHeaderConfiguration; diff --git a/src/components/gamma/stack/index.ts b/src/components/gamma/stack/index.ts index 6b39a05eba..147cd30814 100644 --- a/src/components/gamma/stack/index.ts +++ b/src/components/gamma/stack/index.ts @@ -1,25 +1,19 @@ import StackHost from './StackHost'; import StackScreen from './StackScreen'; -import StackHeaderConfiguration from './header/StackHeaderConfiguration'; -import StackHeaderSubview from './header/StackHeaderSubview'; +import StackHeaderConfig from './header/StackHeaderConfig'; export * from './StackHost.types'; export * from './StackScreen.types'; -export * from './header/StackHeaderConfiguration.types'; +export * from './header/StackHeaderConfig.types'; export * from './header/StackHeaderSubview.types'; /** * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE */ -const Header = { - Configuration: StackHeaderConfiguration, - Subview: StackHeaderSubview, -}; - const Stack = { Host: StackHost, Screen: StackScreen, - Header, + HeaderConfig: StackHeaderConfig, }; export default Stack; diff --git a/src/fabric/gamma/stack/StackHeaderConfigurationNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderConfigNativeComponent.ts similarity index 77% rename from src/fabric/gamma/stack/StackHeaderConfigurationNativeComponent.ts rename to src/fabric/gamma/stack/StackHeaderConfigNativeComponent.ts index 25bf126d99..95aa652e5d 100644 --- a/src/fabric/gamma/stack/StackHeaderConfigurationNativeComponent.ts +++ b/src/fabric/gamma/stack/StackHeaderConfigNativeComponent.ts @@ -12,9 +12,6 @@ export interface NativeProps extends ViewProps { transparent?: CT.WithDefault; } -export default codegenNativeComponent( - 'RNSStackHeaderConfiguration', - { - interfaceOnly: true, - }, -); +export default codegenNativeComponent('RNSStackHeaderConfig', { + interfaceOnly: true, +}); From ab3d7dd84bd88be713659c10322c1bd6ae5000e5 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 1 Apr 2026 19:04:15 +0200 Subject: [PATCH 35/92] move subviews to prop in HeaderConfig instead of children --- .../gamma/containers/stack/StackContainer.tsx | 73 ++++++++++--------- .../gamma/stack/header/StackHeaderConfig.tsx | 32 +++++++- .../stack/header/StackHeaderConfig.types.ts | 21 +++++- .../stack/header/StackHeaderSubview.types.ts | 6 +- 4 files changed, 89 insertions(+), 43 deletions(-) diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.tsx b/apps/src/shared/gamma/containers/stack/StackContainer.tsx index 9b153bff97..1a202afa42 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.tsx +++ b/apps/src/shared/gamma/containers/stack/StackContainer.tsx @@ -21,7 +21,6 @@ import { } from 'react-native-screens/private'; import { useParentNavigationEffect } from './hooks/useParentNavigationEffect'; import { Text, View } from 'react-native'; -import LongText from '../../../../../src/shared/LongText'; import PressableWithFeedback from '../../../../../src/shared/PressableWithFeedback'; export function StackContainer({ routeConfigs }: StackContainerProps) { @@ -87,41 +86,47 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { - {/* - + type="large" + backgroundSubview={{ + collapseMode: 'parallax', + Component: ( + + + Pressable + + + ), + }} + leadingSubview={{ + Component: ( - Pressable + leading - - - - - leading - - - - - center - - - - - trailing - - */} - + ), + }} + centerSubview={{ + Component: ( + + center + + ), + }} + trailingSubview={{ + Component: ( + + trailing + + ), + }} + /> ); diff --git a/src/components/gamma/stack/header/StackHeaderConfig.tsx b/src/components/gamma/stack/header/StackHeaderConfig.tsx index 40bd49bc46..12dec587d0 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.tsx +++ b/src/components/gamma/stack/header/StackHeaderConfig.tsx @@ -2,18 +2,46 @@ import React from 'react'; import { StyleSheet } from 'react-native'; import { StackHeaderConfigProps } from './StackHeaderConfig.types'; import StackHeaderConfigNativeComponent from '../../../../fabric/gamma/stack/StackHeaderConfigNativeComponent'; +import StackHeaderSubview from './StackHeaderSubview'; /** * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE */ function StackHeaderConfig(props: StackHeaderConfigProps) { - const { children, ...filteredProps } = props; + const { + backgroundSubview, + leadingSubview, + centerSubview, + trailingSubview, + ...filteredProps + } = props; return ( - {children} + {backgroundSubview && ( + + {backgroundSubview.Component} + + )} + {leadingSubview && ( + + {leadingSubview.Component} + + )} + {centerSubview && ( + + {centerSubview.Component} + + )} + {trailingSubview && ( + + {trailingSubview.Component} + + )} ); } diff --git a/src/components/gamma/stack/header/StackHeaderConfig.types.ts b/src/components/gamma/stack/header/StackHeaderConfig.types.ts index 507e76e06e..4ae25c46e0 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.types.ts +++ b/src/components/gamma/stack/header/StackHeaderConfig.types.ts @@ -1,12 +1,27 @@ -import { ViewProps } from 'react-native'; +import { ReactNode } from 'react'; +import { StackHeaderSubviewCollapseModeAndroid } from './StackHeaderSubview.types'; export type StackHeaderTypeAndroid = 'small' | 'medium' | 'large'; -export type StackHeaderConfigProps = { - children?: ViewProps['children']; +export type StackHeaderToolbarSubviewAndroid = { + Component: ReactNode; +}; + +export type StackHeaderBackgroundSubviewCollapseModeAndroid = + StackHeaderSubviewCollapseModeAndroid; +export type StackHeaderBackgroundSubviewAndroid = { + collapseMode?: StackHeaderSubviewCollapseModeAndroid; + Component: ReactNode; +}; + +export type StackHeaderConfigProps = { type?: StackHeaderTypeAndroid; title?: string; hidden?: boolean; transparent?: boolean; + backgroundSubview?: StackHeaderBackgroundSubviewAndroid; + leadingSubview?: StackHeaderToolbarSubviewAndroid; + centerSubview?: StackHeaderToolbarSubviewAndroid; + trailingSubview?: StackHeaderToolbarSubviewAndroid; }; diff --git a/src/components/gamma/stack/header/StackHeaderSubview.types.ts b/src/components/gamma/stack/header/StackHeaderSubview.types.ts index e7e580f101..966956fb66 100644 --- a/src/components/gamma/stack/header/StackHeaderSubview.types.ts +++ b/src/components/gamma/stack/header/StackHeaderSubview.types.ts @@ -6,13 +6,11 @@ export type StackHeaderSubviewTypeAndroid = | 'center' | 'trailing'; -export type StackHeaderSubviewBackgroundCollapseModeAndroid = - | 'off' - | 'parallax'; +export type StackHeaderSubviewCollapseModeAndroid = 'off' | 'parallax'; export type StackHeaderSubviewProps = { children?: ViewProps['children']; type?: StackHeaderSubviewTypeAndroid; - collapseMode?: StackHeaderSubviewBackgroundCollapseModeAndroid; + collapseMode?: StackHeaderSubviewCollapseModeAndroid; }; From 1b64c31fed965b7e8f1949d66f354204d1801c7e Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 1 Apr 2026 19:37:18 +0200 Subject: [PATCH 36/92] build on iOS, refactor shadow tree files --- .../RNSStackHeaderConfigComponentDescriptor.h | 3 +-- .../rnscreens/RNSStackHeaderConfigState.h | 6 +++--- .../RNSStackHeaderSubviewComponentDescriptor.h | 2 -- .../RNSStackHeaderSubviewShadowNode.cpp | 2 ++ .../rnscreens/RNSStackHeaderSubviewShadowNode.h | 2 ++ .../rnscreens/RNSStackHeaderSubviewState.h | 6 +++--- .../RNSStackScreenComponentDescriptor.h | 3 +-- ...Config.tsx => StackHeaderConfig.android.tsx} | 0 .../gamma/stack/header/StackHeaderConfig.d.ts | 17 +++++++++++++++++ .../stack/header/StackHeaderConfig.ios.tsx | 5 +++++ ...bview.tsx => StackHeaderSubview.android.tsx} | 10 +++++++++- .../gamma/stack/header/StackHeaderSubview.d.ts | 17 +++++++++++++++++ .../stack/header/StackHeaderSubview.ios.tsx | 5 +++++ 13 files changed, 65 insertions(+), 13 deletions(-) rename src/components/gamma/stack/header/{StackHeaderConfig.tsx => StackHeaderConfig.android.tsx} (100%) create mode 100644 src/components/gamma/stack/header/StackHeaderConfig.d.ts create mode 100644 src/components/gamma/stack/header/StackHeaderConfig.ios.tsx rename src/components/gamma/stack/header/{StackHeaderSubview.tsx => StackHeaderSubview.android.tsx} (82%) create mode 100644 src/components/gamma/stack/header/StackHeaderSubview.d.ts create mode 100644 src/components/gamma/stack/header/StackHeaderSubview.ios.tsx diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigComponentDescriptor.h index 2a969b2aa2..67c54cba3a 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigComponentDescriptor.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigComponentDescriptor.h @@ -16,10 +16,9 @@ class RNSStackHeaderConfigComponentDescriptor final using ConcreteComponentDescriptor::ConcreteComponentDescriptor; void adopt(ShadowNode &shadowNode) const override { +#ifdef ANDROID react_native_assert( dynamic_cast(&shadowNode)); - -#ifdef ANDROID auto &configShadowNode = static_cast(shadowNode); react_native_assert( diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.h index 7826cdea0b..eca2d7e609 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.h @@ -16,7 +16,7 @@ class JSI_EXPORT RNSStackHeaderConfigState final { public: using Shared = std::shared_ptr; - RNSStackHeaderConfigState() {}; + RNSStackHeaderConfigState() {} #ifdef ANDROID RNSStackHeaderConfigState( @@ -29,7 +29,7 @@ class JSI_EXPORT RNSStackHeaderConfigState final { contentOffset( Point{ (Float)data["contentOffsetX"].getDouble(), - (Float)data["contentOffsetY"].getDouble()}) {}; + (Float)data["contentOffsetY"].getDouble()}) {} Size frameSize{}; Point contentOffset{}; @@ -37,7 +37,7 @@ class JSI_EXPORT RNSStackHeaderConfigState final { folly::dynamic getDynamic() const; MapBuffer getMapBuffer() const { return MapBufferBuilder::EMPTY(); - }; + } #endif // ANDROID }; diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h index bf827a4bb4..4e682f7b16 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h @@ -12,8 +12,6 @@ class RNSStackHeaderSubviewComponentDescriptor final using ConcreteComponentDescriptor::ConcreteComponentDescriptor; void adopt(ShadowNode &shadowNode) const override { - react_native_assert( - dynamic_cast(&shadowNode)); ConcreteComponentDescriptor::adopt(shadowNode); } }; diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp index d9dc5a4e7c..36f924185d 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp @@ -5,6 +5,7 @@ namespace facebook::react { extern const char RNSStackHeaderSubviewComponentName[] = "RNSStackHeaderSubview"; +#ifdef ANDROID Point RNSStackHeaderSubviewShadowNode::getContentOriginOffset( bool /*includeTransform*/) const { auto stateData = getStateData(); @@ -24,5 +25,6 @@ void RNSStackHeaderSubviewShadowNode::applyFrameCorrections() { ensureUnsealed(); layoutMetrics_.frame.origin.x = 0; } +#endif // ANDROID } // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h index 01856256eb..8b5baf9874 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h @@ -21,6 +21,7 @@ class JSI_EXPORT RNSStackHeaderSubviewShadowNode final using ConcreteViewShadowNode::ConcreteViewShadowNode; using StateData = ConcreteViewShadowNode::ConcreteStateData; +#ifdef ANDROID #pragma mark - ShadowNode overrides Point getContentOriginOffset(bool includeTransform) const override; @@ -30,6 +31,7 @@ class JSI_EXPORT RNSStackHeaderSubviewShadowNode final #pragma mark - Custom interface private: void applyFrameCorrections(); +#endif // ANDROID }; } // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.h index f2173e40a6..ccd3a0639e 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.h @@ -16,7 +16,7 @@ class JSI_EXPORT RNSStackHeaderSubviewState final { public: using Shared = std::shared_ptr; - RNSStackHeaderSubviewState() {}; + RNSStackHeaderSubviewState() {} #ifdef ANDROID RNSStackHeaderSubviewState( @@ -25,14 +25,14 @@ class JSI_EXPORT RNSStackHeaderSubviewState final { : contentOffset( Point{ (Float)data["contentOffsetX"].getDouble(), - (Float)data["contentOffsetY"].getDouble()}) {}; + (Float)data["contentOffsetY"].getDouble()}) {} Point contentOffset{}; folly::dynamic getDynamic() const; MapBuffer getMapBuffer() const { return MapBufferBuilder::EMPTY(); - }; + } #endif // ANDROID }; diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenComponentDescriptor.h index e821d0ef81..299e7ba578 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenComponentDescriptor.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenComponentDescriptor.h @@ -16,9 +16,8 @@ class RNSStackScreenComponentDescriptor final using ConcreteComponentDescriptor::ConcreteComponentDescriptor; void adopt(ShadowNode &shadowNode) const override { - react_native_assert(dynamic_cast(&shadowNode)); - #ifdef ANDROID + react_native_assert(dynamic_cast(&shadowNode)); auto &screenShadowNode = static_cast(shadowNode); react_native_assert( diff --git a/src/components/gamma/stack/header/StackHeaderConfig.tsx b/src/components/gamma/stack/header/StackHeaderConfig.android.tsx similarity index 100% rename from src/components/gamma/stack/header/StackHeaderConfig.tsx rename to src/components/gamma/stack/header/StackHeaderConfig.android.tsx diff --git a/src/components/gamma/stack/header/StackHeaderConfig.d.ts b/src/components/gamma/stack/header/StackHeaderConfig.d.ts new file mode 100644 index 0000000000..249ff64b2b --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderConfig.d.ts @@ -0,0 +1,17 @@ +/** + * TS module resolution does not support this RN platform extension pattern out of the box. + * Without a base .tsx file, TS will throw a "Cannot find module" error. + * + * This file satisfies the TS compiler by providing the correct type signatures, + * whereas Metro will handle the proper runtime file resolution. + */ + +import React from 'react'; +import { StackHeaderConfigProps } from './StackHeaderConfig.types'; + +/** + * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE + */ +export default function StackHeaderConfig( + props: StackHeaderConfigProps, +): React.JSX.Element; diff --git a/src/components/gamma/stack/header/StackHeaderConfig.ios.tsx b/src/components/gamma/stack/header/StackHeaderConfig.ios.tsx new file mode 100644 index 0000000000..c65135ec55 --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderConfig.ios.tsx @@ -0,0 +1,5 @@ +import { View } from 'react-native'; + +const StackHeaderConfig = View; + +export default StackHeaderConfig; diff --git a/src/components/gamma/stack/header/StackHeaderSubview.tsx b/src/components/gamma/stack/header/StackHeaderSubview.android.tsx similarity index 82% rename from src/components/gamma/stack/header/StackHeaderSubview.tsx rename to src/components/gamma/stack/header/StackHeaderSubview.android.tsx index a759b474e2..b6525dd34c 100644 --- a/src/components/gamma/stack/header/StackHeaderSubview.tsx +++ b/src/components/gamma/stack/header/StackHeaderSubview.android.tsx @@ -14,7 +14,7 @@ function StackHeaderSubview(props: StackHeaderSubviewProps) { style={ filteredProps.type === 'background' ? StyleSheet.absoluteFill - : { position: 'absolute', start: 0, top: 0 } + : styles.absoluteStartTop } {...filteredProps}> {children} @@ -23,3 +23,11 @@ function StackHeaderSubview(props: StackHeaderSubviewProps) { } export default StackHeaderSubview; + +const styles = StyleSheet.create({ + absoluteStartTop: { + position: 'absolute', + start: 0, + top: 0, + }, +}); diff --git a/src/components/gamma/stack/header/StackHeaderSubview.d.ts b/src/components/gamma/stack/header/StackHeaderSubview.d.ts new file mode 100644 index 0000000000..fb09ed3fa7 --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderSubview.d.ts @@ -0,0 +1,17 @@ +/** + * TS module resolution does not support this RN platform extension pattern out of the box. + * Without a base .tsx file, TS will throw a "Cannot find module" error. + * + * This file satisfies the TS compiler by providing the correct type signatures, + * whereas Metro will handle the proper runtime file resolution. + */ + +import React from 'react'; +import { StackHeaderSubviewProps } from './StackHeaderSubview.types'; + +/** + * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE + */ +export default function StackHeaderSubview( + props: StackHeaderSubviewProps, +): React.JSX.Element; diff --git a/src/components/gamma/stack/header/StackHeaderSubview.ios.tsx b/src/components/gamma/stack/header/StackHeaderSubview.ios.tsx new file mode 100644 index 0000000000..2b4e14c84d --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderSubview.ios.tsx @@ -0,0 +1,5 @@ +import { View } from 'react-native'; + +const StackHeaderSubview = View; + +export default StackHeaderSubview; From cfd09066d82b1d75c82507540be16417aa435d36 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 1 Apr 2026 19:46:24 +0200 Subject: [PATCH 37/92] limit layout requests on header config updates --- .../stack/header/StackHeaderCoordinator.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index f5f97db6e6..39a3d5dea9 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -49,17 +49,25 @@ internal class StackHeaderCoordinator( // render a subview to the leading side of the title. private var managedTitleView: AppCompatTextView? = null + private var shouldRequestLayout = false + internal fun applyHeaderConfig( coordinatorLayout: StackHeaderCoordinatorLayout, config: StackHeaderConfigProviding?, ) { + shouldRequestLayout = false + currentConfig = config if (config != null) { updateHeader(coordinatorLayout, config) } else { removeHeader(coordinatorLayout) } - coordinatorLayout.maybeRequestLayoutContainer() + + if (shouldRequestLayout) { + coordinatorLayout.maybeRequestLayoutContainer() + shouldRequestLayout = false + } } private fun updateHeader( @@ -76,6 +84,7 @@ internal class StackHeaderCoordinator( private fun removeHeader(coordinatorLayout: StackHeaderCoordinatorLayout) { teardown(coordinatorLayout) removeContentBehavior(coordinatorLayout) + shouldRequestLayout = true } // region Rebuild detection @@ -130,6 +139,8 @@ internal class StackHeaderCoordinator( attachedTrailingSubview = config.trailingSubview attachedBackgroundSubview = config.backgroundSubview snapshotSubviewWidths(config) + + shouldRequestLayout = true } private fun teardown(coordinatorLayout: StackHeaderCoordinatorLayout) { @@ -265,6 +276,9 @@ internal class StackHeaderCoordinator( when (appBar) { is StackHeaderAppBarLayout.Small -> { managedTitleView?.text = config.title + + // Changing small title requires layout + shouldRequestLayout = true } is StackHeaderAppBarLayout.Collapsing -> { @@ -308,6 +322,7 @@ internal class StackHeaderCoordinator( updateShadowState(contentTop, dependency) } coordinatorLayout.stackScreenWrapper.layoutParams = params + shouldRequestLayout = true } } @@ -317,6 +332,7 @@ internal class StackHeaderCoordinator( params.behavior = null coordinatorLayout.stackScreenWrapper.layoutParams = params onHeaderHeightChanged(0) + shouldRequestLayout = true } } From 92c163437dda496f9703e398e339baa0fe8c42a9 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 2 Apr 2026 08:49:22 +0200 Subject: [PATCH 38/92] styling, remove export from subview --- src/components/gamma/stack/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/gamma/stack/index.ts b/src/components/gamma/stack/index.ts index 147cd30814..a8384f0b2a 100644 --- a/src/components/gamma/stack/index.ts +++ b/src/components/gamma/stack/index.ts @@ -5,7 +5,6 @@ import StackHeaderConfig from './header/StackHeaderConfig'; export * from './StackHost.types'; export * from './StackScreen.types'; export * from './header/StackHeaderConfig.types'; -export * from './header/StackHeaderSubview.types'; /** * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE From 7bc338f49696ba8ece82e2cb80a8bd69df467464 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 2 Apr 2026 09:57:53 +0200 Subject: [PATCH 39/92] change test to TestStackSubview --- .../gamma/containers/stack/StackContainer.tsx | 50 +-- .../containers/stack/StackContainer.types.tsx | 9 +- .../shared/gamma/containers/stack/reducer.tsx | 9 +- .../containers/stack/utils/safe-stringify.ts | 23 ++ .../single-feature-tests/stack-v5/index.ts | 4 +- .../stack-v5/test-stack-header-modes.tsx | 63 ---- .../stack-v5/test-stack-subviews.tsx | 316 ++++++++++++++++++ src/experimental/types.ts | 1 + 8 files changed, 357 insertions(+), 118 deletions(-) create mode 100644 apps/src/shared/gamma/containers/stack/utils/safe-stringify.ts delete mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx create mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.tsx b/apps/src/shared/gamma/containers/stack/StackContainer.tsx index 1a202afa42..1df204935c 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.tsx +++ b/apps/src/shared/gamma/containers/stack/StackContainer.tsx @@ -20,8 +20,6 @@ import { useRenderDebugInfo, } from 'react-native-screens/private'; import { useParentNavigationEffect } from './hooks/useParentNavigationEffect'; -import { Text, View } from 'react-native'; -import PressableWithFeedback from '../../../../../src/shared/PressableWithFeedback'; export function StackContainer({ routeConfigs }: StackContainerProps) { useSanitizeRouteConfigs(routeConfigs); @@ -63,7 +61,7 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { return ( {stackNavState.stack.map( - ({ Component, options, activityMode, routeKey }) => { + ({ Component, options: { headerConfig, ...options }, activityMode, routeKey }) => { const stackNavigationContext: StackNavigationContextPayload = { routeKey, routeOptions: { ...options }, @@ -84,49 +82,9 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { onNativeDismiss={onScreenNativelyDismissed}> - - - Pressable - - - ), - }} - leadingSubview={{ - Component: ( - - leading - - ), - }} - centerSubview={{ - Component: ( - - center - - ), - }} - trailingSubview={{ - Component: ( - - trailing - - ), - }} - /> + {headerConfig !== undefined && ( + + )} ); diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.types.tsx b/apps/src/shared/gamma/containers/stack/StackContainer.types.tsx index 42df84fd3a..3fadad151d 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.types.tsx +++ b/apps/src/shared/gamma/containers/stack/StackContainer.types.tsx @@ -1,12 +1,17 @@ import React from 'react'; -import { StackScreenProps } from 'react-native-screens/experimental'; +import { + StackScreenProps, + StackHeaderConfigProps, +} from 'react-native-screens/experimental'; /// Route definition export type StackRouteOptions = Omit< StackScreenProps, 'children' | 'activityMode' | 'screenKey' ->; +> & { + headerConfig?: StackHeaderConfigProps; +}; /** * Blueprint for a route. diff --git a/apps/src/shared/gamma/containers/stack/reducer.tsx b/apps/src/shared/gamma/containers/stack/reducer.tsx index a7a4c0835e..de62201773 100644 --- a/apps/src/shared/gamma/containers/stack/reducer.tsx +++ b/apps/src/shared/gamma/containers/stack/reducer.tsx @@ -16,6 +16,7 @@ import type { StackState, } from './StackContainer.types'; import { generateID } from './utils/id-generator'; +import { safeStringify } from './utils/safe-stringify'; const NOT_FOUND_INDEX = -1; @@ -60,15 +61,13 @@ export function navigationStateReducerWithLogging( state: StackNavigationState, action: NavigationAction, ): StackNavigationState { - console.debug(`[Stack] Handling action: ${JSON.stringify(action)}`); - console.debug(`[Stack] BEFORE state: ${JSON.stringify(state, undefined, 2)}`); + console.debug(`[Stack] Handling action: ${safeStringify(action)}`); + console.debug(`[Stack] BEFORE state: ${safeStringify(state, 2)}`); const newState = navigationStateReducer(state, action); if (state === newState) { console.debug('[Stack] AFTER state: unchanged'); } else { - console.debug( - `[Stack] AFTER state: ${JSON.stringify(newState, undefined, 2)}`, - ); + console.debug(`[Stack] AFTER state: ${safeStringify(newState, 2)}`); } return newState; } diff --git a/apps/src/shared/gamma/containers/stack/utils/safe-stringify.ts b/apps/src/shared/gamma/containers/stack/utils/safe-stringify.ts new file mode 100644 index 0000000000..72a70181d2 --- /dev/null +++ b/apps/src/shared/gamma/containers/stack/utils/safe-stringify.ts @@ -0,0 +1,23 @@ +export function safeStringify(value: unknown, space?: number): string { + const seen = new WeakSet(); + return JSON.stringify( + value, + (_key, val) => { + if (typeof val === 'object' && val !== null) { + if (seen.has(val)) { + return '[Circular]'; + } + seen.add(val); + } + if (typeof val === 'function') { + return '[Function]'; + } + // React elements + if (val != null && typeof val === 'object' && '$$typeof' in val) { + return '[ReactElement]'; + } + return val; + }, + space, + ); +} diff --git a/apps/src/tests/single-feature-tests/stack-v5/index.ts b/apps/src/tests/single-feature-tests/stack-v5/index.ts index fa9878cb16..03fcb4dde7 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/index.ts +++ b/apps/src/tests/single-feature-tests/stack-v5/index.ts @@ -2,13 +2,13 @@ import type { ScenarioGroup } from '../../shared/helpers'; import PreventNativeDismissSingleStack from './prevent-native-dismiss-single-stack'; import PreventNativeDismissNestedStack from './prevent-native-dismiss-nested-stack'; import AnimationAndroid from './test-animation-android'; -import TestStackHeaderModes from './test-stack-header-modes'; +import TestStackSubviews from './test-stack-subviews'; const scenarios = { PreventNativeDismissSingleStack, PreventNativeDismissNestedStack, AnimationAndroid, - TestStackHeaderModes, + TestStackSubviews, }; const StackScenarioGroup: ScenarioGroup = { diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx deleted file mode 100644 index 06a6f724da..0000000000 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import { Scenario } from '../../shared/helpers'; -import { StackContainer } from '../../../shared/gamma/containers/stack'; -import { ScrollView, Text, View } from 'react-native'; -import LongText from '../../../../src/shared/LongText'; -import { StackNavigationButtons } from '../../shared/components/stack-v5/StackNavigationButtons'; -import Colors from '../../../../src/shared/styling/Colors'; -import PressableWithFeedback from '../../../../src/shared/PressableWithFeedback'; - -const SCENARIO: Scenario = { - name: 'Stack Header Modes', - key: 'test-stack-header-modes', - details: '[WIP] Tests different header modes.', - platforms: ['android'], - AppComponent: App, -}; - -export default SCENARIO; - -export function App() { - return ; -} - -function StackSetup() { - return ( - Screen(true), - options: {}, - }, - { - name: 'A', - Component: () => Screen(false), - options: {}, - }, - ]} - /> - ); -} - -function Screen(isHome: boolean) { - return ( - - - - - - Pressable - - - - - ); -} diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx new file mode 100644 index 0000000000..1a0e7e5a0b --- /dev/null +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx @@ -0,0 +1,316 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Image, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { Scenario } from '../../shared/helpers'; +import { + StackContainer, + useStackNavigationContext, +} from '../../../shared/gamma/containers/stack'; +import { SettingsPicker, SettingsSwitch } from '../../../shared'; +import PressableWithFeedback from '../../../../src/shared/PressableWithFeedback'; +import Colors from '../../../../src/shared/styling/Colors'; +import LongText from '../../../../src/shared/LongText'; +import type { + StackHeaderConfigProps, + StackHeaderTypeAndroid, + StackHeaderBackgroundSubviewCollapseModeAndroid, +} from 'react-native-screens/experimental'; +import { NavigationContainer } from '@react-navigation/native'; + +const SCENARIO: Scenario = { + name: 'Stack Subviews', + key: 'test-stack-subviews', + details: 'Tests header config and subview customization.', + platforms: ['android'], + AppComponent: App, +}; + +export default SCENARIO; + +const SHORT_TITLE = 'Hello'; +const LONG_TITLE = + 'A Very Long Title That Should Ellipsize When There Is Not Enough Space Available'; + +type SubviewSize = 'none' | 'sm' | 'md' | 'lg'; +type HitSlopValue = '0' | '10' | '30'; +type PressRetentionValue = '0' | '20' | '50'; +type TitleOption = 'short' | 'long'; + +interface Config { + enabled: boolean; + type: StackHeaderTypeAndroid; + transparent: boolean; + hidden: boolean; + title: TitleOption; + leadingSize: SubviewSize; + centerSize: SubviewSize; + trailingSize: SubviewSize; + backgroundEnabled: boolean; + backgroundCollapseMode: StackHeaderBackgroundSubviewCollapseModeAndroid; + hitSlop: HitSlopValue; + pressRetentionOffset: PressRetentionValue; +} + +const DEFAULT_CONFIG: Config = { + enabled: true, + type: 'large', + transparent: false, + hidden: false, + title: 'short', + leadingSize: 'none', + centerSize: 'none', + trailingSize: 'none', + backgroundEnabled: false, + backgroundCollapseMode: 'parallax', + hitSlop: '0', + pressRetentionOffset: '0', +}; + +const SUBVIEW_SIZES: SubviewSize[] = ['none', 'sm', 'md', 'lg']; +const HEADER_TYPES: StackHeaderTypeAndroid[] = ['small', 'medium', 'large']; +const COLLAPSE_MODES: StackHeaderBackgroundSubviewCollapseModeAndroid[] = [ + 'off', + 'parallax', +]; +const HIT_SLOP_VALUES: HitSlopValue[] = ['0', '10', '30']; +const PRESS_RETENTION_VALUES: PressRetentionValue[] = ['0', '20', '50']; +const TITLE_OPTIONS: TitleOption[] = ['short', 'long']; + +function getSubviewDimensions(size: SubviewSize): { + width: number; + height: number; +} { + switch (size) { + case 'sm': + return { width: 24, height: 24 }; + case 'md': + return { width: 40, height: 40 }; + case 'lg': + return { width: 80, height: 40 }; + default: + return { width: 0, height: 0 }; + } +} + +function buildHeaderConfig(config: Config): StackHeaderConfigProps | undefined { + if (!config.enabled) { + return undefined; + } + + const hitSlop = Number(config.hitSlop); + const pressRetentionOffset = Number(config.pressRetentionOffset); + + const makeToolbarSubview = (size: SubviewSize, label: string) => { + if (size === 'none') { + return undefined; + } + const dims = getSubviewDimensions(size); + return { + Component: ( + + + {label} + + + ), + }; + }; + + const backgroundSubview = config.backgroundEnabled + ? { + collapseMode: config.backgroundCollapseMode, + Component: ( + + + + + BG Pressable + + + + ), + } + : undefined; + + return { + type: config.type, + title: config.title === 'short' ? SHORT_TITLE : LONG_TITLE, + hidden: config.hidden, + transparent: config.transparent, + backgroundSubview, + leadingSubview: makeToolbarSubview(config.leadingSize, 'L'), + centerSubview: makeToolbarSubview(config.centerSize, 'C'), + trailingSubview: makeToolbarSubview(config.trailingSize, 'T'), + }; +} + +export function App() { + // TODO: NavigationContainer is used only in order to make SettingsSwitch/Picker work. + // Those components shouldn't rely on react-navigation in the future. + return ( + + + + ); +} + +function ConfigScreen() { + const navigation = useStackNavigationContext(); + const [config, setConfig] = useState(DEFAULT_CONFIG); + + const updateConfig = useCallback( + (key: K, value: Config[K]) => { + setConfig(prev => ({ ...prev, [key]: value })); + }, + [], + ); + + const { setRouteOptions, routeKey } = navigation; + const headerConfig = useMemo(() => buildHeaderConfig(config), [config]); + + useEffect(() => { + setRouteOptions(routeKey, { + headerConfig, + }); + }, [headerConfig, setRouteOptions, routeKey]); + + return ( + + General + updateConfig('enabled', v)} + /> + + label="type" + value={config.type} + onValueChange={v => updateConfig('type', v)} + items={HEADER_TYPES} + /> + updateConfig('transparent', v)} + /> + updateConfig('hidden', v)} + /> + + label="title" + value={config.title} + onValueChange={v => updateConfig('title', v)} + items={TITLE_OPTIONS} + /> + + Toolbar Subviews + + label="leading" + value={config.leadingSize} + onValueChange={v => updateConfig('leadingSize', v)} + items={SUBVIEW_SIZES} + /> + + label="center" + value={config.centerSize} + onValueChange={v => updateConfig('centerSize', v)} + items={SUBVIEW_SIZES} + /> + + label="trailing" + value={config.trailingSize} + onValueChange={v => updateConfig('trailingSize', v)} + items={SUBVIEW_SIZES} + /> + + Background Subview + updateConfig('backgroundEnabled', v)} + /> + + label="collapseMode" + value={config.backgroundCollapseMode} + onValueChange={v => updateConfig('backgroundCollapseMode', v)} + items={COLLAPSE_MODES} + /> + + Pressable Settings + + label="hitSlop" + value={config.hitSlop} + onValueChange={v => updateConfig('hitSlop', v)} + items={HIT_SLOP_VALUES} + /> + + label="pressRetentionOffset" + value={config.pressRetentionOffset} + onValueChange={v => updateConfig('pressRetentionOffset', v)} + items={PRESS_RETENTION_VALUES} + /> + + ScrollView content + + + ); +} + +const styles = StyleSheet.create({ + scroll: { + backgroundColor: Colors.cardBackground, + }, + content: { + padding: 16, + gap: 6, + }, + heading: { + fontSize: 20, + fontWeight: 'bold', + marginTop: 12, + marginBottom: 4, + }, + subviewLabel: { + fontSize: 10, + textAlign: 'center', + }, + backgroundContainer: { + flex: 1, + }, + backgroundImage: { + ...StyleSheet.absoluteFill, + width: '100%', + height: '100%', + resizeMode: 'cover', + }, + backgroundPressable: { + position: 'absolute', + bottom: 32, + alignSelf: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + }, + backgroundPressableText: { + color: 'white', + fontWeight: 'bold', + }, +}); diff --git a/src/experimental/types.ts b/src/experimental/types.ts index 093d00641c..6bc9770a42 100644 --- a/src/experimental/types.ts +++ b/src/experimental/types.ts @@ -9,3 +9,4 @@ export * from '../components/gamma/split/SplitScreen.types'; export * from '../components/gamma/stack/StackScreen.types'; export * from '../components/safe-area/SafeAreaView.types'; export type * from '../components/gamma/scroll-view-marker/ScrollViewMarker.types'; +export * from '../components/gamma/stack/header/StackHeaderConfig.types'; From 80199948b398f0d0e14236b2a12a75633cd5e9d9 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 2 Apr 2026 12:05:42 +0200 Subject: [PATCH 40/92] modify test to force container unmount on test change --- .../single-feature-tests/stack-v5/test-stack-subviews.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx index 1a0e7e5a0b..f31254f552 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx @@ -151,6 +151,10 @@ function buildHeaderConfig(config: Config): StackHeaderConfigProps | undefined { } export function App() { + return ; +} + +function StackSetup() { // TODO: NavigationContainer is used only in order to make SettingsSwitch/Picker work. // Those components shouldn't rely on react-navigation in the future. return ( From 123f660e39a7e78ac7be8dd5056a2c10c2f7ab3b Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 2 Apr 2026 12:06:06 +0200 Subject: [PATCH 41/92] fix dynamic changes to collapseMode for backgroundSubview --- .../stack/header/StackHeaderCoordinator.kt | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 39a3d5dea9..7b9bff2d0a 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -7,6 +7,7 @@ import android.view.Gravity import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.FrameLayout import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.Toolbar @@ -18,6 +19,7 @@ import com.swmansion.rnscreens.ext.detachFromCurrentParent import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigProviding import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderType import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewCollapseMode import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewProviding internal class StackHeaderCoordinator( @@ -44,6 +46,7 @@ internal class StackHeaderCoordinator( // so we must rebuild the hierarchy when toolbar subview widths change. private var lastLeadingSubviewWidth: Int? = null private var lastTrailingSubviewWidth: Int? = null + private var lastBackgroundSubviewCollapseMode: StackHeaderSubviewCollapseMode? = null // For small header, we need to use custom title view in order to // render a subview to the leading side of the title. @@ -100,14 +103,16 @@ internal class StackHeaderCoordinator( if (appBarLayout is StackHeaderAppBarLayout.Collapsing) { if (config.leadingSubview?.view?.width != lastLeadingSubviewWidth) return true if (config.trailingSubview?.view?.width != lastTrailingSubviewWidth) return true + if (config.backgroundSubview?.collapseMode != lastBackgroundSubviewCollapseMode) return true } return false } - private fun snapshotSubviewWidths(config: StackHeaderConfigProviding) { + private fun snapshotSubviewProperties(config: StackHeaderConfigProviding) { lastLeadingSubviewWidth = config.leadingSubview?.view?.width lastTrailingSubviewWidth = config.trailingSubview?.view?.width + lastBackgroundSubviewCollapseMode = config.backgroundSubview?.collapseMode } // endregion @@ -138,7 +143,7 @@ internal class StackHeaderCoordinator( attachedCenterSubview = config.centerSubview attachedTrailingSubview = config.trailingSubview attachedBackgroundSubview = config.backgroundSubview - snapshotSubviewWidths(config) + snapshotSubviewProperties(config) shouldRequestLayout = true } @@ -163,7 +168,11 @@ internal class StackHeaderCoordinator( attachedTrailingSubview?.let { appBar.toolbar.removeView(it.view) } if (appBar is StackHeaderAppBarLayout.Collapsing) { - attachedBackgroundSubview?.let { appBar.collapsingToolbarLayout.removeView(it.view) } + attachedBackgroundSubview?.let { + val wrapper = it.view.parent as? FrameLayout + wrapper?.removeView(it.view) + appBar.collapsingToolbarLayout.removeView(wrapper) + } } } @@ -231,13 +240,25 @@ internal class StackHeaderCoordinator( backgroundSubview.view.detachFromCurrentParent() - // Needed to extend the background under the status bar - backgroundSubview.view.fitsSystemWindows = true + // Wrap in a FrameLayout so that CollapsingToolbarLayout's ViewOffsetHelper + // attaches to the disposable wrapper, not the reused React view. This avoids + // stale parallax offsets persisting across collapse mode rebuilds therefore allowing + // runtime changes to this property. + val wrapper = + FrameLayout(appBar.context).apply { + fitsSystemWindows = true + } + wrapper.addView( + backgroundSubview.view, + FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT), + ) appBar.collapsingToolbarLayout.addView( - backgroundSubview.view, + wrapper, 0, - CollapsingToolbarLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT), + CollapsingToolbarLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT).apply { + collapseMode = backgroundSubview.collapseMode.toNativeCollapseMode() + }, ) } @@ -290,7 +311,8 @@ internal class StackHeaderCoordinator( private fun applyBackgroundCollapseMode(config: StackHeaderConfigProviding) { val backgroundSubview = config.backgroundSubview ?: return - val params = backgroundSubview.view.layoutParams as? CollapsingToolbarLayout.LayoutParams ?: return + val wrapper = backgroundSubview.view.parent as? FrameLayout ?: return + val params = wrapper.layoutParams as? CollapsingToolbarLayout.LayoutParams ?: return val desired = backgroundSubview.collapseMode.toNativeCollapseMode() if (params.collapseMode != desired) { params.collapseMode = desired From 175b32f3a36a210b632ed9683c04bcbefe44a534 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 2 Apr 2026 12:39:47 +0200 Subject: [PATCH 42/92] force rebuild on transparent header change --- .../stack/header/StackHeaderCoordinator.kt | 86 ++++++++++--------- 1 file changed, 45 insertions(+), 41 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 7b9bff2d0a..11f414373d 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -33,17 +33,17 @@ internal class StackHeaderCoordinator( ) private var appBarLayout: StackHeaderAppBarLayout? = null - private var currentHeaderTypeOrNull: StackHeaderType? = null private var currentConfig: StackHeaderConfigProviding? = null + // Cached values used by requiresRebuild() to detect when the header + // hierarchy needs to be torn down and recreated. + private var lastHeaderType: StackHeaderType? = null + private var lastHidden: Boolean = false + private var lastTransparent: Boolean = false private var attachedLeadingSubview: StackHeaderSubviewProviding? = null private var attachedCenterSubview: StackHeaderSubviewProviding? = null private var attachedTrailingSubview: StackHeaderSubviewProviding? = null private var attachedBackgroundSubview: StackHeaderSubviewProviding? = null - - // Width snapshots for collapsing header rebuild detection. - // CollapsingToolbarLayout can't resize custom views at runtime, - // so we must rebuild the hierarchy when toolbar subview widths change. private var lastLeadingSubviewWidth: Int? = null private var lastTrailingSubviewWidth: Int? = null private var lastBackgroundSubviewCollapseMode: StackHeaderSubviewCollapseMode? = null @@ -81,7 +81,6 @@ internal class StackHeaderCoordinator( rebuild(coordinatorLayout, config) } applyProps(config) - applyContentBehavior(coordinatorLayout, config) } private fun removeHeader(coordinatorLayout: StackHeaderCoordinatorLayout) { @@ -93,8 +92,9 @@ internal class StackHeaderCoordinator( // region Rebuild detection private fun requiresRebuild(config: StackHeaderConfigProviding): Boolean { - val desiredTypeOrNull = if (config.hidden) null else config.type - if (desiredTypeOrNull != currentHeaderTypeOrNull) return true + if (config.type != lastHeaderType) return true + if (config.hidden != lastHidden) return true + if (config.transparent != lastTransparent) return true if (config.leadingSubview !== attachedLeadingSubview) return true if (config.centerSubview !== attachedCenterSubview) return true if (config.trailingSubview !== attachedTrailingSubview) return true @@ -109,12 +109,6 @@ internal class StackHeaderCoordinator( return false } - private fun snapshotSubviewProperties(config: StackHeaderConfigProviding) { - lastLeadingSubviewWidth = config.leadingSubview?.view?.width - lastTrailingSubviewWidth = config.trailingSubview?.view?.width - lastBackgroundSubviewCollapseMode = config.backgroundSubview?.collapseMode - } - // endregion // region Full rebuild @@ -125,26 +119,26 @@ internal class StackHeaderCoordinator( ) { teardown(coordinatorLayout) - val desiredTypeOrNull = if (config.hidden) null else config.type - - if (desiredTypeOrNull != null) { - val appBar = StackHeaderAppBarLayout.create(wrappedContext, desiredTypeOrNull) + if (!config.hidden) { + val appBar = StackHeaderAppBarLayout.create(wrappedContext, config.type) appBarLayout = appBar - coordinatorLayout.addView(appBar, 0) - appBar.requestApplyInsets() - maybeApplyRtlCollapsingToolbarLayoutWorkaround(coordinatorLayout, config, appBar) + if (config.transparent) { + removeContentBehavior(coordinatorLayout) + coordinatorLayout.addView(appBar) + } else { + coordinatorLayout.addView(appBar, 0) + setContentBehavior(coordinatorLayout) + } + appBar.requestApplyInsets() + maybeApplyRtlCollapsingToolbarLayoutWorkaround(coordinatorLayout, config, appBar) populateAppBar(appBar, config) + } else { + removeContentBehavior(coordinatorLayout) } - currentHeaderTypeOrNull = desiredTypeOrNull - attachedLeadingSubview = config.leadingSubview - attachedCenterSubview = config.centerSubview - attachedTrailingSubview = config.trailingSubview - attachedBackgroundSubview = config.backgroundSubview - snapshotSubviewProperties(config) - + cacheRebuildTriggers(config) shouldRequestLayout = true } @@ -153,11 +147,33 @@ internal class StackHeaderCoordinator( appBarLayout?.let { coordinatorLayout.removeView(it) } appBarLayout = null managedTitleView = null - currentHeaderTypeOrNull = null + clearCachedRebuildTriggers() + } + + private fun cacheRebuildTriggers(config: StackHeaderConfigProviding) { + lastHeaderType = config.type + lastHidden = config.hidden + lastTransparent = config.transparent + attachedLeadingSubview = config.leadingSubview + attachedCenterSubview = config.centerSubview + attachedTrailingSubview = config.trailingSubview + attachedBackgroundSubview = config.backgroundSubview + lastLeadingSubviewWidth = config.leadingSubview?.view?.width + lastTrailingSubviewWidth = config.trailingSubview?.view?.width + lastBackgroundSubviewCollapseMode = config.backgroundSubview?.collapseMode + } + + private fun clearCachedRebuildTriggers() { + lastHeaderType = null + lastHidden = false + lastTransparent = false attachedLeadingSubview = null attachedCenterSubview = null attachedTrailingSubview = null attachedBackgroundSubview = null + lastLeadingSubviewWidth = null + lastTrailingSubviewWidth = null + lastBackgroundSubviewCollapseMode = null } private fun detachSubviews() { @@ -323,18 +339,6 @@ internal class StackHeaderCoordinator( // region Content behavior - private fun applyContentBehavior( - coordinatorLayout: StackHeaderCoordinatorLayout, - config: StackHeaderConfigProviding, - ) { - val needsBehavior = appBarLayout != null && !config.transparent && !config.hidden - if (needsBehavior) { - setContentBehavior(coordinatorLayout) - } else { - removeContentBehavior(coordinatorLayout) - } - } - private fun setContentBehavior(coordinatorLayout: StackHeaderCoordinatorLayout) { val params = coordinatorLayout.stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams if (params.behavior == null) { From 26c08f4ad582400c1db4990d6ce2f829264e2a7d Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 2 Apr 2026 13:46:38 +0200 Subject: [PATCH 43/92] fix subview positioning so that Yoga won't override it --- .../stack/header/StackHeaderCoordinator.kt | 61 +++++++++++++------ 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 11f414373d..59e31ce71f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -179,21 +179,49 @@ internal class StackHeaderCoordinator( private fun detachSubviews() { val appBar = appBarLayout ?: return - attachedLeadingSubview?.let { appBar.toolbar.removeView(it.view) } - attachedCenterSubview?.let { appBar.toolbar.removeView(it.view) } - attachedTrailingSubview?.let { appBar.toolbar.removeView(it.view) } + attachedLeadingSubview?.let { unwrapAndRemoveFrom(it, appBar.toolbar) } + attachedCenterSubview?.let { unwrapAndRemoveFrom(it, appBar.toolbar) } + attachedTrailingSubview?.let { unwrapAndRemoveFrom(it, appBar.toolbar) } if (appBar is StackHeaderAppBarLayout.Collapsing) { attachedBackgroundSubview?.let { - val wrapper = it.view.parent as? FrameLayout - wrapper?.removeView(it.view) - appBar.collapsingToolbarLayout.removeView(wrapper) + unwrapAndRemoveFrom(it, appBar.collapsingToolbarLayout) } } } // endregion + // region Subview wrapping + // + // All subviews are wrapped in a FrameLayout before being added to the + // toolbar or collapsing toolbar layout. This ensures the React view has + // a relative offset of (0,0) within its native parent, matching what + // Yoga expects (it always thinks views are at origin). + + private fun wrapSubview( + subview: StackHeaderSubviewProviding, + context: Context, + wrapperWidth: Int = WRAP_CONTENT, + wrapperHeight: Int = WRAP_CONTENT, + ): FrameLayout { + subview.view.detachFromCurrentParent() + return FrameLayout(context).apply { + addView(subview.view, FrameLayout.LayoutParams(wrapperWidth, wrapperHeight)) + } + } + + private fun unwrapAndRemoveFrom( + subview: StackHeaderSubviewProviding, + parent: android.view.ViewGroup, + ) { + val wrapper = subview.view.parent as? FrameLayout ?: return + wrapper.removeView(subview.view) + parent.removeView(wrapper) + } + + // endregion + // region App bar population private fun populateAppBar( @@ -205,13 +233,13 @@ internal class StackHeaderCoordinator( // Toolbar measures children in insertion order. Leading and trailing go first so the // title/center gets the remaining space. config.leadingSubview?.let { - it.view.detachFromCurrentParent() - toolbar.addView(it.view, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.START)) + val wrapper = wrapSubview(it, toolbar.context) + toolbar.addView(wrapper, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.START)) } config.trailingSubview?.let { - it.view.detachFromCurrentParent() - toolbar.addView(it.view, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.END)) + val wrapper = wrapSubview(it, toolbar.context) + toolbar.addView(wrapper, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.END)) } populateTitleOrCenter(appBar, toolbar, config) @@ -229,8 +257,8 @@ internal class StackHeaderCoordinator( toolbar.removeView(managedTitleView) managedTitleView = null - centerSubview.view.detachFromCurrentParent() - toolbar.addView(centerSubview.view, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.CENTER_HORIZONTAL)) + val wrapper = wrapSubview(centerSubview, toolbar.context) + toolbar.addView(wrapper, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.CENTER_HORIZONTAL)) } else { Log.e(TAG, "[RNScreens] Center subview is supported only for small header type.") } @@ -254,20 +282,15 @@ internal class StackHeaderCoordinator( return } - backgroundSubview.view.detachFromCurrentParent() - // Wrap in a FrameLayout so that CollapsingToolbarLayout's ViewOffsetHelper // attaches to the disposable wrapper, not the reused React view. This avoids // stale parallax offsets persisting across collapse mode rebuilds therefore allowing // runtime changes to this property. val wrapper = - FrameLayout(appBar.context).apply { + wrapSubview(backgroundSubview, appBar.context, MATCH_PARENT, MATCH_PARENT).apply { + // We're setting `fitsSystemWindows` so that the background renders behind status bar (edge-to-edge). fitsSystemWindows = true } - wrapper.addView( - backgroundSubview.view, - FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT), - ) appBar.collapsingToolbarLayout.addView( wrapper, From 7c16ee7f06acd72b66892393167a440b116b3c36 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 2 Apr 2026 15:09:54 +0200 Subject: [PATCH 44/92] handle subview height dynamic updates --- .../stack/header/StackHeaderCoordinator.kt | 24 ++++++++++++------- .../header/subview/StackHeaderSubview.kt | 8 +++---- .../stack-v5/test-stack-subviews.tsx | 2 +- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 59e31ce71f..9d88261d9b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -44,8 +44,9 @@ internal class StackHeaderCoordinator( private var attachedCenterSubview: StackHeaderSubviewProviding? = null private var attachedTrailingSubview: StackHeaderSubviewProviding? = null private var attachedBackgroundSubview: StackHeaderSubviewProviding? = null - private var lastLeadingSubviewWidth: Int? = null - private var lastTrailingSubviewWidth: Int? = null + private var lastLeadingSubviewSize: Pair? = null + private var lastCenterSubviewSize: Pair? = null + private var lastTrailingSubviewSize: Pair? = null private var lastBackgroundSubviewCollapseMode: StackHeaderSubviewCollapseMode? = null // For small header, we need to use custom title view in order to @@ -100,9 +101,11 @@ internal class StackHeaderCoordinator( if (config.trailingSubview !== attachedTrailingSubview) return true if (config.backgroundSubview !== attachedBackgroundSubview) return true + if (config.leadingSubview?.viewSize != lastLeadingSubviewSize) return true + if (config.centerSubview?.viewSize != lastCenterSubviewSize) return true + if (config.trailingSubview?.viewSize != lastTrailingSubviewSize) return true + if (appBarLayout is StackHeaderAppBarLayout.Collapsing) { - if (config.leadingSubview?.view?.width != lastLeadingSubviewWidth) return true - if (config.trailingSubview?.view?.width != lastTrailingSubviewWidth) return true if (config.backgroundSubview?.collapseMode != lastBackgroundSubviewCollapseMode) return true } @@ -158,8 +161,9 @@ internal class StackHeaderCoordinator( attachedCenterSubview = config.centerSubview attachedTrailingSubview = config.trailingSubview attachedBackgroundSubview = config.backgroundSubview - lastLeadingSubviewWidth = config.leadingSubview?.view?.width - lastTrailingSubviewWidth = config.trailingSubview?.view?.width + lastLeadingSubviewSize = config.leadingSubview?.viewSize + lastCenterSubviewSize = config.centerSubview?.viewSize + lastTrailingSubviewSize = config.trailingSubview?.viewSize lastBackgroundSubviewCollapseMode = config.backgroundSubview?.collapseMode } @@ -171,8 +175,9 @@ internal class StackHeaderCoordinator( attachedCenterSubview = null attachedTrailingSubview = null attachedBackgroundSubview = null - lastLeadingSubviewWidth = null - lastTrailingSubviewWidth = null + lastLeadingSubviewSize = null + lastCenterSubviewSize = null + lastTrailingSubviewSize = null lastBackgroundSubviewCollapseMode = null } @@ -490,3 +495,6 @@ internal class StackHeaderCoordinator( private const val TAG = "StackHeaderCoordinator" } } + +private val StackHeaderSubviewProviding.viewSize: Pair + get() = view.width to view.height diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt index 483077f4f1..de95070764 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -39,7 +39,7 @@ class StackHeaderSubview( internal var onStackHeaderSubviewChangeListener: WeakReference? = null - private var lastNotifiedWidth: Int = 0 + private var lastNotifiedSize: Pair? = null override fun onLayout( changed: Boolean, @@ -49,9 +49,9 @@ class StackHeaderSubview( bottom: Int, ) { super.onLayout(changed, left, top, right, bottom) - val newWidth = right - left - if (newWidth != lastNotifiedWidth) { - lastNotifiedWidth = newWidth + val newSize = (right - left) to (bottom - top) + if (lastNotifiedSize != newSize) { + lastNotifiedSize = newSize onStackHeaderSubviewChangeListener?.get()?.onStackHeaderSubviewChange() } } diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx index f31254f552..186d7ed678 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx @@ -83,7 +83,7 @@ function getSubviewDimensions(size: SubviewSize): { case 'sm': return { width: 24, height: 24 }; case 'md': - return { width: 40, height: 40 }; + return { width: 24, height: 40 }; case 'lg': return { width: 80, height: 40 }; default: From 57d2c3e97e70652435e77fe23d13604a413c185f Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Tue, 7 Apr 2026 14:26:20 +0200 Subject: [PATCH 45/92] remove NavigationContainer wrapper from SFT --- .../stack-v5/test-stack-subviews.tsx | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx index 186d7ed678..ae1194b064 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx @@ -14,7 +14,6 @@ import type { StackHeaderTypeAndroid, StackHeaderBackgroundSubviewCollapseModeAndroid, } from 'react-native-screens/experimental'; -import { NavigationContainer } from '@react-navigation/native'; const SCENARIO: Scenario = { name: 'Stack Subviews', @@ -155,20 +154,16 @@ export function App() { } function StackSetup() { - // TODO: NavigationContainer is used only in order to make SettingsSwitch/Picker work. - // Those components shouldn't rely on react-navigation in the future. return ( - - - + ); } From 1d5647e8000cc543cebb7353596841e593c33c0d Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 11 Mar 2026 13:39:49 +0100 Subject: [PATCH 46/92] make StackContainer a FrameLayout It doesn't use any CoordinatorLayout features. --- .../swmansion/rnscreens/gamma/stack/host/StackContainer.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt index 8d64bc9681..3f1b1de8d6 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt @@ -3,7 +3,7 @@ package com.swmansion.rnscreens.gamma.stack.host import android.annotation.SuppressLint import android.content.Context import android.util.Log -import androidx.coordinatorlayout.widget.CoordinatorLayout +import android.widget.FrameLayout import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import com.swmansion.rnscreens.ext.isMeasured @@ -18,7 +18,7 @@ import java.lang.ref.WeakReference internal class StackContainer( context: Context, private val delegate: WeakReference, -) : CoordinatorLayout(context), +) : FrameLayout(context), FragmentManager.OnBackStackChangedListener { private var fragmentManager: FragmentManager? = null From 94386c58f5674fc1e2bf4f6feac5263404eb937c Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 11 Mar 2026 14:33:46 +0100 Subject: [PATCH 47/92] add skeleton classes --- .../screen/header/StackScreenAppBarLayout.kt | 96 +++++++++++++++++++ .../header/StackScreenCoordinatorLayout.kt | 30 ++++++ .../header/StackScreenHeaderCoordinator.kt | 69 +++++++++++++ ...StackScreenHeaderConfigurationProviding.kt | 6 ++ .../configuration/StackScreenHeaderType.kt | 7 ++ .../rnscreens/utils/DimensionUtils.kt | 18 ++++ 6 files changed, 226 insertions(+) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/utils/DimensionUtils.kt diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt new file mode 100644 index 0000000000..29b9c563fc --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt @@ -0,0 +1,96 @@ +package com.swmansion.rnscreens.gamma.stack.screen.header + +import android.annotation.SuppressLint +import android.content.Context +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import com.google.android.material.R +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.appbar.CollapsingToolbarLayout +import com.google.android.material.appbar.MaterialToolbar +import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType +import com.swmansion.rnscreens.utils.resolveDimensionAttr + +internal sealed class StackScreenAppBarLayout( + context: Context, +) : AppBarLayout(context) { + abstract val toolbar: MaterialToolbar + + internal class Small( + context: Context, + ) : StackScreenAppBarLayout(context) { + override val toolbar = + MaterialToolbar(context).apply { + elevation = 0f + layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) + } + + init { + addView(toolbar) + } + } + + @SuppressLint("ViewConstructor") + internal class Collapsing( + context: Context, + val type: StackScreenHeaderType, + ) : StackScreenAppBarLayout(context) { + init { + require( + type == StackScreenHeaderType.MEDIUM || + type == StackScreenHeaderType.LARGE, + ) { + "[RNScreens] Collapsing StackScreenAppBarLayout must be MEDIUM or LARGE type." + } + } + + override val toolbar = + MaterialToolbar(context).apply { + elevation = 0f + layoutParams = + CollapsingToolbarLayout + .LayoutParams( + MATCH_PARENT, + resolveDimensionAttr(context, android.R.attr.actionBarSize), + ).apply { + collapseMode = CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PIN + } + } + + val collapsingToolbarLayout: CollapsingToolbarLayout = + run { + val (styleAttr, sizeAttr) = + when (type) { + StackScreenHeaderType.MEDIUM -> + Pair(R.attr.collapsingToolbarLayoutMediumStyle, R.attr.collapsingToolbarLayoutMediumSize) + StackScreenHeaderType.LARGE -> + Pair(R.attr.collapsingToolbarLayoutLargeStyle, R.attr.collapsingToolbarLayoutLargeSize) + else -> error("[RNScreens] Invalid header mode.") + } + CollapsingToolbarLayout(context, null, styleAttr).apply { + fitsSystemWindows = false + layoutParams = + LayoutParams( + MATCH_PARENT, + resolveDimensionAttr(context, sizeAttr), + ) + addView(toolbar) + } + } + + init { + addView(collapsingToolbarLayout) + } + } + + companion object { + fun create( + context: Context, + type: StackScreenHeaderType, + ): StackScreenAppBarLayout = + when (type) { + StackScreenHeaderType.SMALL -> Small(context) + StackScreenHeaderType.MEDIUM, StackScreenHeaderType.LARGE -> Collapsing(context, type) + } + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt new file mode 100644 index 0000000000..3a06cc5426 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -0,0 +1,30 @@ +package com.swmansion.rnscreens.gamma.stack.screen.header + +import android.annotation.SuppressLint +import android.content.Context +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.appbar.AppBarLayout +import com.swmansion.rnscreens.gamma.stack.screen.StackScreen +import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding + +@SuppressLint("ViewConstructor") +internal class StackScreenCoordinatorLayout( + context: Context, + stackScreen: StackScreen, +) : CoordinatorLayout(context) { + private val headerCoordinator = StackScreenHeaderCoordinator(context) + + init { + addView( + stackScreen, + LayoutParams(MATCH_PARENT, MATCH_PARENT).apply { + // TODO: when adding possibility to hide the header, this needs to be moved to coordinator + behavior = AppBarLayout.ScrollingViewBehavior() + }, + ) + } + + internal fun applyHeaderConfiguration(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) = + headerCoordinator.applyHeaderConfiguration(this, headerConfigurationProviding) +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt new file mode 100644 index 0000000000..b98de34278 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt @@ -0,0 +1,69 @@ +package com.swmansion.rnscreens.gamma.stack.screen.header + +import android.content.Context +import androidx.appcompat.view.ContextThemeWrapper +import com.google.android.material.R +import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding +import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType + +internal class StackScreenHeaderCoordinator( + context: Context, +) { + private var appBarLayout: StackScreenAppBarLayout? = null + private var currentHeaderType: StackScreenHeaderType? = null + + private val wrappedContext = + ContextThemeWrapper( + context, + R.style.Theme_Material3_DayNight_NoActionBar, + ) + + internal fun applyHeaderConfiguration( + coordinatorLayout: StackScreenCoordinatorLayout, + headerConfigurationProviding: StackScreenHeaderConfigurationProviding, + ) { + // TODO: handle hiding the header + if (appBarLayout == null || currentHeaderType == null || currentHeaderType != headerConfigurationProviding.headerType) { + rebuild(coordinatorLayout, headerConfigurationProviding) + } else { + update(headerConfigurationProviding) + } + } + + private fun rebuild( + coordinatorLayout: StackScreenCoordinatorLayout, + headerConfigurationProviding: StackScreenHeaderConfigurationProviding, + ) { + teardown(coordinatorLayout) + appBarLayout = StackScreenAppBarLayout.create(wrappedContext, headerConfigurationProviding.headerType) + coordinatorLayout.addView(appBarLayout, 0) + update(headerConfigurationProviding) + } + + private fun teardown(coordinatorLayout: StackScreenCoordinatorLayout) { + coordinatorLayout.removeView(appBarLayout) + appBarLayout = null + currentHeaderType = null + } + + private fun update(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) { + appBarLayout?.let { + applyTitle(it, headerConfigurationProviding.title) + } + } + + private fun applyTitle( + appBarLayout: StackScreenAppBarLayout, + title: String, + ) { + when (appBarLayout) { + is StackScreenAppBarLayout.Small -> { + appBarLayout.toolbar.title = title + } + is StackScreenAppBarLayout.Collapsing -> { + appBarLayout.toolbar.title = null + appBarLayout.collapsingToolbarLayout.title = title + } + } + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt new file mode 100644 index 0000000000..900107d009 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt @@ -0,0 +1,6 @@ +package com.swmansion.rnscreens.gamma.stack.screen.header.configuration + +internal interface StackScreenHeaderConfigurationProviding { + val headerType: StackScreenHeaderType + val title: String +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt new file mode 100644 index 0000000000..fbaa11873f --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt @@ -0,0 +1,7 @@ +package com.swmansion.rnscreens.gamma.stack.screen.header.configuration + +internal enum class StackScreenHeaderType { + SMALL, + MEDIUM, + LARGE, +} diff --git a/android/src/main/java/com/swmansion/rnscreens/utils/DimensionUtils.kt b/android/src/main/java/com/swmansion/rnscreens/utils/DimensionUtils.kt new file mode 100644 index 0000000000..7261578486 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/utils/DimensionUtils.kt @@ -0,0 +1,18 @@ +package com.swmansion.rnscreens.utils + +import android.content.Context +import android.util.TypedValue + +internal fun resolveDimensionAttr( + context: Context, + attrId: Int, +): Int { + val typedValue = TypedValue() + require(context.theme.resolveAttribute(attrId, typedValue, true)) { + "[RNScreens] Unable to resolve Material theme dimension." + } + return TypedValue.complexToDimensionPixelSize( + typedValue.data, + context.resources.displayMetrics, + ) +} From 5f36f0ba3afc1db285d117125389e99a9642243f Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 11 Mar 2026 14:46:05 +0100 Subject: [PATCH 48/92] pass Context from host I'm not really sure which context should we use. Might want to revisit it later. --- .../swmansion/rnscreens/gamma/stack/host/StackContainer.kt | 4 ++-- .../rnscreens/gamma/stack/screen/StackScreenFragment.kt | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt index 3f1b1de8d6..0581800f21 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt @@ -16,7 +16,7 @@ import java.lang.ref.WeakReference @SuppressLint("ViewConstructor") // Only we construct this view, it is never inflated. internal class StackContainer( - context: Context, + private val context: Context, private val delegate: WeakReference, ) : FrameLayout(context), FragmentManager.OnBackStackChangedListener { @@ -194,7 +194,7 @@ internal class StackContainer( } private fun createFragmentForScreen(screen: StackScreen): StackScreenFragment = - StackScreenFragment(screen).also { + StackScreenFragment(context, screen).also { Log.d(TAG, "Created Fragment $it for screen ${screen.screenKey}") } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt index 87160eca31..4d4d661ee3 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt @@ -1,5 +1,6 @@ package com.swmansion.rnscreens.gamma.stack.screen +import android.content.Context import android.os.Bundle import android.view.Gravity import android.view.LayoutInflater @@ -7,8 +8,10 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.transition.Slide +import com.swmansion.rnscreens.gamma.stack.screen.header.StackScreenCoordinatorLayout internal class StackScreenFragment( + private val context: Context, internal val stackScreen: StackScreen, ) : Fragment() { private var screenLifecycleEventEmitter: StackScreenAppearanceEventsEmitter? = null @@ -43,7 +46,7 @@ internal class StackScreenFragment( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View = stackScreen + ): View = StackScreenCoordinatorLayout(context, stackScreen) override fun onViewCreated( view: View, From 27049503e152b0478c712e04ad6bad0b57b3c8af Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 11 Mar 2026 15:00:25 +0100 Subject: [PATCH 49/92] debug setup --- .../stack/screen/header/StackScreenCoordinatorLayout.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index 3a06cc5426..d5da1364c3 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -7,6 +7,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.appbar.AppBarLayout import com.swmansion.rnscreens.gamma.stack.screen.StackScreen import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding +import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType @SuppressLint("ViewConstructor") internal class StackScreenCoordinatorLayout( @@ -23,6 +24,14 @@ internal class StackScreenCoordinatorLayout( behavior = AppBarLayout.ScrollingViewBehavior() }, ) + + // TODO: debug-only + applyHeaderConfiguration( + object : StackScreenHeaderConfigurationProviding { + override val headerType = StackScreenHeaderType.SMALL + override val title = "Hello, World!" + }, + ) } internal fun applyHeaderConfiguration(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) = From faeae536a1c44c6d4d6992f8a880db7f9e91f6e1 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 11 Mar 2026 15:40:29 +0100 Subject: [PATCH 50/92] small header PoC TODO: handle layout of CoordinatorLayout, now it's added in a random place. --- .../rnscreens/gamma/stack/host/StackContainer.kt | 14 ++++++++++++++ .../rnscreens/gamma/stack/screen/StackScreen.kt | 6 ------ .../stack/screen/header/StackScreenAppBarLayout.kt | 9 +++++++++ .../screen/header/StackScreenCoordinatorLayout.kt | 4 ++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt index 0581800f21..193d9bf12f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt @@ -211,6 +211,11 @@ internal class StackContainer( check(fragmentManager.primaryNavigationFragment === fragments.last()) { "[RNScreens] Top fragment different from primary navigation fragment" } + + // TODO: debug only but this is necessary to layout CoordinatorLayout + post { + forceSubtreeMeasureAndLayoutPass() + } } /** @@ -251,6 +256,15 @@ internal class StackContainer( } } + private fun forceSubtreeMeasureAndLayoutPass() { + measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), + ) + + layout(left, top, right, bottom) + } + companion object { const val TAG = "StackContainer" } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt index 3290846a8a..dfaf3743ae 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt @@ -21,12 +21,6 @@ class StackScreen( ATTACHED, } - init { - // Needed when Transition API is in use to ensure that shadows do not disappear, - // views do not jump around the screen and whole sub-tree is animated as a whole. - isTransitionGroup = true - } - internal var isPreventNativeDismissEnabled: Boolean by Delegates.observable(false) { _, oldValue, newValue -> if (oldValue != newValue) { preventNativeDismissChangeObserver?.preventNativeDismissChanged(newValue) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt index 29b9c563fc..9591434f24 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.R import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.CollapsingToolbarLayout @@ -16,6 +17,14 @@ internal sealed class StackScreenAppBarLayout( ) : AppBarLayout(context) { abstract val toolbar: MaterialToolbar + init { + layoutParams = CoordinatorLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + isLiftOnScroll = true + // TODO: this won't work with nested header but there were some problems with lift on scroll + // without it when I was researching this. + fitsSystemWindows = true + } + internal class Small( context: Context, ) : StackScreenAppBarLayout(context) { diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index d5da1364c3..80ef6c3de8 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -17,6 +17,10 @@ internal class StackScreenCoordinatorLayout( private val headerCoordinator = StackScreenHeaderCoordinator(context) init { + // Needed when Transition API is in use to ensure that shadows do not disappear, + // views do not jump around the screen and whole sub-tree is animated as a whole. + isTransitionGroup = true + addView( stackScreen, LayoutParams(MATCH_PARENT, MATCH_PARENT).apply { From 41b0635b781522a9e1f11e130d73b94efcd6ce03 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 11 Mar 2026 16:00:34 +0100 Subject: [PATCH 51/92] add temporary SFT --- .../screen/header/StackScreenAppBarLayout.kt | 14 ++++- .../header/StackScreenCoordinatorLayout.kt | 2 +- .../single-feature-tests/stack-v5/index.ts | 2 + .../stack-v5/test-stack-header-modes.tsx | 51 +++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt index 9591434f24..f9e1733b25 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt @@ -7,6 +7,9 @@ import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.R import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED +import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL +import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP import com.google.android.material.appbar.CollapsingToolbarLayout import com.google.android.material.appbar.MaterialToolbar import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType @@ -31,7 +34,11 @@ internal sealed class StackScreenAppBarLayout( override val toolbar = MaterialToolbar(context).apply { elevation = 0f - layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) + layoutParams = + LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + // TODO: debug only for small header, must be moved to configuration + scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_SNAP + } } init { @@ -82,7 +89,10 @@ internal sealed class StackScreenAppBarLayout( LayoutParams( MATCH_PARENT, resolveDimensionAttr(context, sizeAttr), - ) + ).apply { + // TODO: debug only for medium/large header, must be moved to configuration + scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED or SCROLL_FLAG_SNAP + } addView(toolbar) } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index 80ef6c3de8..d55116bf95 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -32,7 +32,7 @@ internal class StackScreenCoordinatorLayout( // TODO: debug-only applyHeaderConfiguration( object : StackScreenHeaderConfigurationProviding { - override val headerType = StackScreenHeaderType.SMALL + override val headerType = StackScreenHeaderType.LARGE override val title = "Hello, World!" }, ) diff --git a/apps/src/tests/single-feature-tests/stack-v5/index.ts b/apps/src/tests/single-feature-tests/stack-v5/index.ts index 3d5bff680a..ee5d1ff1fc 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/index.ts +++ b/apps/src/tests/single-feature-tests/stack-v5/index.ts @@ -2,11 +2,13 @@ import type { ScenarioGroup } from '@apps/tests/shared/helpers'; import PreventNativeDismissSingleStack from './prevent-native-dismiss-single-stack'; import PreventNativeDismissNestedStack from './prevent-native-dismiss-nested-stack'; import AnimationAndroid from './test-animation-android'; +import TestStackHeaderModes from './test-stack-header-modes'; const scenarios = { PreventNativeDismissSingleStack, PreventNativeDismissNestedStack, AnimationAndroid, + TestStackHeaderModes, }; const StackScenarioGroup: ScenarioGroup = { diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx new file mode 100644 index 0000000000..a83063b29a --- /dev/null +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Scenario } from '../../shared/helpers'; +import { StackContainer } from '../../../shared/gamma/containers/stack'; +import { ScrollView } from 'react-native'; +import LongText from '../../../../src/shared/LongText'; +import { StackNavigationButtons } from '../../shared/components/stack-v5/StackNavigationButtons'; +import Colors from '../../../../src/shared/styling/Colors'; + +const SCENARIO: Scenario = { + name: 'Stack Header Modes', + key: 'test-stack-header-modes', + details: '[WIP] Tests different header modes.', + platforms: ['android'], + AppComponent: App, +}; + +export default SCENARIO; + +export function App() { + return ; +} + +function StackSetup() { + return ( + Screen(true), + options: {}, + }, + { + name: 'A', + Component: () => Screen(false), + options: {}, + }, + ]} + /> + ); +} + +function Screen(isHome: boolean) { + return ( + + + + + ); +} From 5e9bc7103192139cd26348961a512834dbb8b2b4 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 14:18:02 +0100 Subject: [PATCH 52/92] add ability to hide the header, refactor requesting layout --- .../gamma/stack/host/StackContainer.kt | 7 +--- .../header/StackScreenCoordinatorLayout.kt | 38 ++++++++++++++++--- .../header/StackScreenHeaderCoordinator.kt | 24 +++++++++++- ...StackScreenHeaderConfigurationProviding.kt | 1 + 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt index 193d9bf12f..79e2f0e5b6 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt @@ -211,11 +211,6 @@ internal class StackContainer( check(fragmentManager.primaryNavigationFragment === fragments.last()) { "[RNScreens] Top fragment different from primary navigation fragment" } - - // TODO: debug only but this is necessary to layout CoordinatorLayout - post { - forceSubtreeMeasureAndLayoutPass() - } } /** @@ -256,7 +251,7 @@ internal class StackContainer( } } - private fun forceSubtreeMeasureAndLayoutPass() { + internal fun forceSubtreeMeasureAndLayoutPass() { measure( MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index d55116bf95..352cc15c95 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -4,7 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.view.ViewGroup.LayoutParams.MATCH_PARENT import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.appbar.AppBarLayout +import com.swmansion.rnscreens.gamma.stack.host.StackContainer import com.swmansion.rnscreens.gamma.stack.screen.StackScreen import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType @@ -12,7 +12,7 @@ import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScre @SuppressLint("ViewConstructor") internal class StackScreenCoordinatorLayout( context: Context, - stackScreen: StackScreen, + internal val stackScreen: StackScreen, ) : CoordinatorLayout(context) { private val headerCoordinator = StackScreenHeaderCoordinator(context) @@ -23,10 +23,7 @@ internal class StackScreenCoordinatorLayout( addView( stackScreen, - LayoutParams(MATCH_PARENT, MATCH_PARENT).apply { - // TODO: when adding possibility to hide the header, this needs to be moved to coordinator - behavior = AppBarLayout.ScrollingViewBehavior() - }, + LayoutParams(MATCH_PARENT, MATCH_PARENT), ) // TODO: debug-only @@ -34,8 +31,37 @@ internal class StackScreenCoordinatorLayout( object : StackScreenHeaderConfigurationProviding { override val headerType = StackScreenHeaderType.LARGE override val title = "Hello, World!" + override val isHidden = false }, ) + + postDelayed({ + applyHeaderConfiguration( + object : StackScreenHeaderConfigurationProviding { + override val headerType = StackScreenHeaderType.LARGE + override val title = "Hello, World!" + override val isHidden = true + }, + ) + + postDelayed({ + applyHeaderConfiguration( + object : StackScreenHeaderConfigurationProviding { + override val headerType = StackScreenHeaderType.LARGE + override val title = "Hello, World!" + override val isHidden = false + }, + ) + }, 3000) + }, 3000) + } + + private fun stackContainerOrNull(): StackContainer? = this.parent as StackContainer? + + internal fun maybeRequestLayoutContainer() { + post { + stackContainerOrNull()?.forceSubtreeMeasureAndLayoutPass() + } } internal fun applyHeaderConfiguration(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) = diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt index b98de34278..b43b8f8105 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt @@ -2,7 +2,9 @@ package com.swmansion.rnscreens.gamma.stack.screen.header import android.content.Context import androidx.appcompat.view.ContextThemeWrapper +import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.R +import com.google.android.material.appbar.AppBarLayout import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType @@ -23,11 +25,15 @@ internal class StackScreenHeaderCoordinator( headerConfigurationProviding: StackScreenHeaderConfigurationProviding, ) { // TODO: handle hiding the header - if (appBarLayout == null || currentHeaderType == null || currentHeaderType != headerConfigurationProviding.headerType) { + if (headerConfigurationProviding.isHidden) { + teardown(coordinatorLayout) + } else if (appBarLayout == null || currentHeaderType == null || currentHeaderType != headerConfigurationProviding.headerType) { rebuild(coordinatorLayout, headerConfigurationProviding) } else { update(headerConfigurationProviding) } + + coordinatorLayout.maybeRequestLayoutContainer() } private fun rebuild( @@ -38,12 +44,14 @@ internal class StackScreenHeaderCoordinator( appBarLayout = StackScreenAppBarLayout.create(wrappedContext, headerConfigurationProviding.headerType) coordinatorLayout.addView(appBarLayout, 0) update(headerConfigurationProviding) + updateContentBehavior(coordinatorLayout, false) } private fun teardown(coordinatorLayout: StackScreenCoordinatorLayout) { coordinatorLayout.removeView(appBarLayout) appBarLayout = null currentHeaderType = null + updateContentBehavior(coordinatorLayout, true) } private fun update(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) { @@ -66,4 +74,18 @@ internal class StackScreenHeaderCoordinator( } } } + + private fun updateContentBehavior( + coordinatorLayout: StackScreenCoordinatorLayout, + isHidden: Boolean, + ) { + val stackScreen = coordinatorLayout.stackScreen + val params = stackScreen.layoutParams as CoordinatorLayout.LayoutParams + val needsBehavior = !isHidden && appBarLayout != null + val hasBehavior = params.behavior != null + if (needsBehavior != hasBehavior) { + params.behavior = if (needsBehavior) AppBarLayout.ScrollingViewBehavior() else null + stackScreen.layoutParams = params + } + } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt index 900107d009..249e38b6a2 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt @@ -3,4 +3,5 @@ package com.swmansion.rnscreens.gamma.stack.screen.header.configuration internal interface StackScreenHeaderConfigurationProviding { val headerType: StackScreenHeaderType val title: String + val isHidden: Boolean } From edf241a7ac8c5da9650d2c8fa4412c9b2dec76ec Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 14:49:37 +0100 Subject: [PATCH 53/92] refactor HeaderCoordinator, handle layout for transparent header I did not apply any changes to color - header won't be transparent but will have correct layout for transparent header. --- .../screen/header/StackScreenAppBarLayout.kt | 3 ++ .../header/StackScreenCoordinatorLayout.kt | 43 ++++++++------- .../header/StackScreenHeaderCoordinator.kt | 54 +++++++++---------- ...StackScreenHeaderConfigurationProviding.kt | 1 + 4 files changed, 52 insertions(+), 49 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt index f9e1733b25..da165c9372 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt @@ -23,6 +23,7 @@ internal sealed class StackScreenAppBarLayout( init { layoutParams = CoordinatorLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) isLiftOnScroll = true + // TODO: this won't work with nested header but there were some problems with lift on scroll // without it when I was researching this. fitsSystemWindows = true @@ -37,6 +38,7 @@ internal sealed class StackScreenAppBarLayout( layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { // TODO: debug only for small header, must be moved to configuration +// scrollFlags = SCROLL_FLAG_NO_SCROLL scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_SNAP } } @@ -92,6 +94,7 @@ internal sealed class StackScreenAppBarLayout( ).apply { // TODO: debug only for medium/large header, must be moved to configuration scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED or SCROLL_FLAG_SNAP +// scrollFlags = SCROLL_FLAG_NO_SCROLL } addView(toolbar) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index 352cc15c95..2e0af8407d 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -29,31 +29,34 @@ internal class StackScreenCoordinatorLayout( // TODO: debug-only applyHeaderConfiguration( object : StackScreenHeaderConfigurationProviding { - override val headerType = StackScreenHeaderType.LARGE + override val headerType = StackScreenHeaderType.SMALL override val title = "Hello, World!" override val isHidden = false + override val isTransparent = true }, ) - postDelayed({ - applyHeaderConfiguration( - object : StackScreenHeaderConfigurationProviding { - override val headerType = StackScreenHeaderType.LARGE - override val title = "Hello, World!" - override val isHidden = true - }, - ) - - postDelayed({ - applyHeaderConfiguration( - object : StackScreenHeaderConfigurationProviding { - override val headerType = StackScreenHeaderType.LARGE - override val title = "Hello, World!" - override val isHidden = false - }, - ) - }, 3000) - }, 3000) +// postDelayed({ +// applyHeaderConfiguration( +// object : StackScreenHeaderConfigurationProviding { +// override val headerType = StackScreenHeaderType.LARGE +// override val title = "Hello, World!" +// override val isHidden = true +// override val isTransparent = false +// }, +// ) +// +// postDelayed({ +// applyHeaderConfiguration( +// object : StackScreenHeaderConfigurationProviding { +// override val headerType = StackScreenHeaderType.LARGE +// override val title = "Hello, World!" +// override val isHidden = false +// override val isTransparent = false +// }, +// ) +// }, 3000) +// }, 3000) } private fun stackContainerOrNull(): StackContainer? = this.parent as StackContainer? diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt index b43b8f8105..eef3c8c324 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt @@ -22,41 +22,37 @@ internal class StackScreenHeaderCoordinator( internal fun applyHeaderConfiguration( coordinatorLayout: StackScreenCoordinatorLayout, - headerConfigurationProviding: StackScreenHeaderConfigurationProviding, + config: StackScreenHeaderConfigurationProviding, ) { - // TODO: handle hiding the header - if (headerConfigurationProviding.isHidden) { - teardown(coordinatorLayout) - } else if (appBarLayout == null || currentHeaderType == null || currentHeaderType != headerConfigurationProviding.headerType) { - rebuild(coordinatorLayout, headerConfigurationProviding) - } else { - update(headerConfigurationProviding) - } - + applyStructure(coordinatorLayout, config) + applyAppBarConfiguration(config) + applyContentBehavior(coordinatorLayout, config) coordinatorLayout.maybeRequestLayoutContainer() } - private fun rebuild( + private fun applyStructure( coordinatorLayout: StackScreenCoordinatorLayout, - headerConfigurationProviding: StackScreenHeaderConfigurationProviding, + config: StackScreenHeaderConfigurationProviding, ) { - teardown(coordinatorLayout) - appBarLayout = StackScreenAppBarLayout.create(wrappedContext, headerConfigurationProviding.headerType) - coordinatorLayout.addView(appBarLayout, 0) - update(headerConfigurationProviding) - updateContentBehavior(coordinatorLayout, false) - } + val desiredType = if (config.isHidden) null else config.headerType + + if (desiredType == currentHeaderType) return + + appBarLayout?.let { coordinatorLayout.removeView(it) } + appBarLayout = + desiredType?.let { + StackScreenAppBarLayout.create(wrappedContext, it).also { appBar -> + coordinatorLayout.addView(appBar, 0) + } + } - private fun teardown(coordinatorLayout: StackScreenCoordinatorLayout) { - coordinatorLayout.removeView(appBarLayout) - appBarLayout = null - currentHeaderType = null - updateContentBehavior(coordinatorLayout, true) + currentHeaderType = desiredType } - private fun update(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) { - appBarLayout?.let { - applyTitle(it, headerConfigurationProviding.title) + private fun applyAppBarConfiguration(config: StackScreenHeaderConfigurationProviding) { + appBarLayout?.let { appBar -> + applyTitle(appBar, config.title) + // ... } } @@ -75,13 +71,13 @@ internal class StackScreenHeaderCoordinator( } } - private fun updateContentBehavior( + private fun applyContentBehavior( coordinatorLayout: StackScreenCoordinatorLayout, - isHidden: Boolean, + config: StackScreenHeaderConfigurationProviding, ) { val stackScreen = coordinatorLayout.stackScreen val params = stackScreen.layoutParams as CoordinatorLayout.LayoutParams - val needsBehavior = !isHidden && appBarLayout != null + val needsBehavior = appBarLayout != null && !config.isTransparent && !config.isHidden val hasBehavior = params.behavior != null if (needsBehavior != hasBehavior) { params.behavior = if (needsBehavior) AppBarLayout.ScrollingViewBehavior() else null diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt index 249e38b6a2..32c2a1c73c 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt @@ -4,4 +4,5 @@ internal interface StackScreenHeaderConfigurationProviding { val headerType: StackScreenHeaderType val title: String val isHidden: Boolean + val isTransparent: Boolean } From 12f610e37d26c64400b4a661ce33bf1c8e1141a2 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 16:46:22 +0100 Subject: [PATCH 54/92] add custom shadow node --- .../gamma/stack/screen/StackScreen.kt | 8 ++- .../screen/StackScreenShadowStateProxy.kt | 57 +++++++++++++++++++ .../stack/screen/StackScreenViewManager.kt | 13 +++++ .../header/StackScreenCoordinatorLayout.kt | 4 +- android/src/main/jni/rnscreens.h | 1 + .../RNSStackScreenComponentDescriptor.h | 43 ++++++++++++++ .../rnscreens/RNSStackScreenShadowNode.cpp | 15 +++++ .../rnscreens/RNSStackScreenShadowNode.h | 30 ++++++++++ .../rnscreens/RNSStackScreenState.cpp | 13 +++++ .../rnscreens/RNSStackScreenState.h | 42 ++++++++++++++ react-native.config.js | 3 +- .../gamma/stack/StackScreenNativeComponent.ts | 4 +- 12 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackScreenComponentDescriptor.h create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.cpp create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.h create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.cpp create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt index dfaf3743ae..2dda466712 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt @@ -50,6 +50,10 @@ class StackScreen( field = value } + private val shadowStateProxy = StackScreenShadowStateProxy() + + var stateWrapper by shadowStateProxy::stateWrapper + internal lateinit var eventEmitter: StackScreenEventEmitter /** @@ -83,7 +87,9 @@ class StackScreen( t: Int, r: Int, b: Int, - ) = Unit + ) { + shadowStateProxy.updateStateIfNeeded(l, t, r - l, b - t) + } override fun getAssociatedFragment(): Fragment? = this.findFragmentOrNull()?.also { diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt new file mode 100644 index 0000000000..5ce2ddd584 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt @@ -0,0 +1,57 @@ +package com.swmansion.rnscreens.gamma.stack.screen + +import com.facebook.react.bridge.WritableMap +import com.facebook.react.bridge.WritableNativeMap +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.StateWrapper +import kotlin.math.abs + +internal class StackScreenShadowStateProxy { + internal var stateWrapper: StateWrapper? = null + + private var lastXInDp: Float = 0f + private var lastYInDp: Float = 0f + private var lastWidthInDp: Float = 0f + private var lastHeightInDp: Float = 0f + + fun updateStateIfNeeded( + x: Int, + y: Int, + width: Int, + height: Int, + ) { + val xInDp: Float = PixelUtil.toDIPFromPixel(x.toFloat()) + val yInDp: Float = PixelUtil.toDIPFromPixel(y.toFloat()) + val widthInDp: Float = PixelUtil.toDIPFromPixel(width.toFloat()) + val heightInDp: Float = PixelUtil.toDIPFromPixel(height.toFloat()) + + // Check incoming state values. If they're already the correct value, return early to prevent + // infinite UpdateState/SetState loop. + if ( + abs(lastXInDp - xInDp) < DELTA && + abs(lastYInDp - yInDp) < DELTA && + abs(lastWidthInDp - widthInDp) < DELTA && + abs(lastHeightInDp - heightInDp) < DELTA + ) { + return + } + + lastXInDp = xInDp + lastYInDp = yInDp + lastWidthInDp = widthInDp + lastHeightInDp = heightInDp + + val map: WritableMap = + WritableNativeMap().apply { + putDouble("frameWidth", widthInDp.toDouble()) + putDouble("frameHeight", heightInDp.toDouble()) + putDouble("contentOffsetX", xInDp.toDouble()) + putDouble("contentOffsetY", yInDp.toDouble()) + } + stateWrapper?.updateState(map) + } + + companion object { + private const val DELTA = 0.9f + } +} \ No newline at end of file diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt index 44d75157de..af1f81942e 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt @@ -2,11 +2,15 @@ package com.swmansion.rnscreens.gamma.stack.screen import com.facebook.react.bridge.JSApplicationIllegalArgumentException import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.viewmanagers.RNSStackScreenManagerDelegate import com.facebook.react.viewmanagers.RNSStackScreenManagerInterface +import com.swmansion.rnscreens.BuildConfig +import com.swmansion.rnscreens.Screen import com.swmansion.rnscreens.gamma.helpers.makeEventRegistrationInfo import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidAppearEvent import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidDisappearEvent @@ -45,6 +49,15 @@ class StackScreenViewManager : makeEventRegistrationInfo(StackScreenNativeDismissPreventedEvent), ) + override fun updateState( + view: StackScreen, + props: ReactStylesDiffMap?, + stateWrapper: StateWrapper?, + ): Any? { + view.stateWrapper = stateWrapper + return super.updateState(view, props, stateWrapper) + } + override fun setActivityMode( view: StackScreen, value: String?, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index 2e0af8407d..aea71b4ba1 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -29,10 +29,10 @@ internal class StackScreenCoordinatorLayout( // TODO: debug-only applyHeaderConfiguration( object : StackScreenHeaderConfigurationProviding { - override val headerType = StackScreenHeaderType.SMALL + override val headerType = StackScreenHeaderType.LARGE override val title = "Hello, World!" override val isHidden = false - override val isTransparent = true + override val isTransparent = false }, ) diff --git a/android/src/main/jni/rnscreens.h b/android/src/main/jni/rnscreens.h index ab83c59fca..a3d5690d39 100644 --- a/android/src/main/jni/rnscreens.h +++ b/android/src/main/jni/rnscreens.h @@ -23,6 +23,7 @@ #include #include #include +#include namespace facebook { namespace react { diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenComponentDescriptor.h new file mode 100644 index 0000000000..e821d0ef81 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenComponentDescriptor.h @@ -0,0 +1,43 @@ +#pragma once + +#ifdef ANDROID +#include +#endif // ANDROID + +#include +#include +#include "RNSStackScreenShadowNode.h" + +namespace facebook::react { + +class RNSStackScreenComponentDescriptor final + : public ConcreteComponentDescriptor { + public: + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + + void adopt(ShadowNode &shadowNode) const override { + react_native_assert(dynamic_cast(&shadowNode)); + +#ifdef ANDROID + auto &screenShadowNode = + static_cast(shadowNode); + react_native_assert( + dynamic_cast(&screenShadowNode)); + auto &layoutableShadowNode = + static_cast(screenShadowNode); + + auto state = + std::static_pointer_cast( + shadowNode.getState()); + auto stateData = state->getData(); + + if (stateData.frameSize.width != 0 && stateData.frameSize.height != 0) { + layoutableShadowNode.setSize( + Size{stateData.frameSize.width, stateData.frameSize.height}); + } +#endif // ANDROID + ConcreteComponentDescriptor::adopt(shadowNode); + } +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.cpp new file mode 100644 index 0000000000..bf9c34141a --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.cpp @@ -0,0 +1,15 @@ +#include "RNSStackScreenShadowNode.h" + +namespace facebook::react { + +extern const char RNSStackScreenComponentName[] = "RNSStackScreen"; + +#ifdef ANDROID +Point RNSStackScreenShadowNode::getContentOriginOffset( + bool /*includeTransform*/) const { + auto stateData = getStateData(); + return stateData.contentOffset; +} +#endif // ANDROID + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.h new file mode 100644 index 0000000000..8e64c48ea3 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenShadowNode.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include +#include +#include "RNSStackScreenState.h" + +namespace facebook::react { + +JSI_EXPORT extern const char RNSStackScreenComponentName[]; + +class JSI_EXPORT RNSStackScreenShadowNode final + : public ConcreteViewShadowNode< + RNSStackScreenComponentName, + RNSStackScreenProps, + RNSStackScreenEventEmitter, + RNSStackScreenState> { + public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + using StateData = ConcreteViewShadowNode::ConcreteStateData; + +#pragma mark - ShadowNode overrides + +#ifdef ANDROID + Point getContentOriginOffset(bool includeTransform) const override; +#endif // ANDROID +}; + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.cpp new file mode 100644 index 0000000000..6ddf9b29c6 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.cpp @@ -0,0 +1,13 @@ +#include "RNSStackScreenState.h" + +namespace facebook::react { + +#ifdef ANDROID +folly::dynamic RNSStackScreenState::getDynamic() const { + return folly::dynamic::object("frameWidth", frameSize.width)( + "frameHeight", frameSize.height)("contentOffsetX", contentOffset.x)( + "contentOffsetY", contentOffset.y); +} +#endif // ANDROID + +} // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h new file mode 100644 index 0000000000..59ee1a4ad2 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h @@ -0,0 +1,42 @@ +#pragma once + +#ifdef ANDROID +#include +#include +#include +#include +#include +#endif // ANDROID + +namespace facebook::react { + +class JSI_EXPORT RNSStackScreenState final { + public: + using Shared = std::shared_ptr; + + RNSStackScreenState() {}; + +#ifdef ANDROID + RNSStackScreenState( + RNSStackScreenState const &previousState, + folly::dynamic data) + : frameSize( + Size{ + (Float)data["frameWidth"].getDouble(), + (Float)data["frameHeight"].getDouble()}), + contentOffset( + Point{ + (Float)data["contentOffsetX"].getDouble(), + (Float)data["contentOffsetY"].getDouble()}) {}; + + Size frameSize{}; + Point contentOffset; + + folly::dynamic getDynamic() const; + MapBuffer getMapBuffer() const { + return MapBufferBuilder::EMPTY(); + }; +#endif // ANDROID +}; + +} // namespace facebook::react diff --git a/react-native.config.js b/react-native.config.js index 7815576b13..6bf41244e4 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -15,7 +15,8 @@ module.exports = { "RNSScreenContentWrapperComponentDescriptor", 'RNSModalScreenComponentDescriptor', 'RNSTabsHostComponentDescriptor', - 'RNSSafeAreaViewComponentDescriptor' + 'RNSSafeAreaViewComponentDescriptor', + 'RNSStackScreenComponentDescriptor' ], cmakeListsPath: "../android/src/main/jni/CMakeLists.txt" }, diff --git a/src/fabric/gamma/stack/StackScreenNativeComponent.ts b/src/fabric/gamma/stack/StackScreenNativeComponent.ts index 64e87f3c81..06cd3ec369 100644 --- a/src/fabric/gamma/stack/StackScreenNativeComponent.ts +++ b/src/fabric/gamma/stack/StackScreenNativeComponent.ts @@ -35,4 +35,6 @@ export interface NativeProps extends ViewProps { preventNativeDismiss?: CT.WithDefault; } -export default codegenNativeComponent('RNSStackScreen', {}); +export default codegenNativeComponent('RNSStackScreen', { + interfaceOnly: true, +}); From e6830b8d6525a9f1d25ea2cfd5e9a4cc53efe763 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 16:56:02 +0100 Subject: [PATCH 55/92] add wrapper to match Yoga layout --- .../stack/screen/header/StackScreenCoordinatorLayout.kt | 5 ++++- .../stack/screen/header/StackScreenHeaderCoordinator.kt | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index aea71b4ba1..ad875ffbd9 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -3,6 +3,7 @@ package com.swmansion.rnscreens.gamma.stack.screen.header import android.annotation.SuppressLint import android.content.Context import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.FrameLayout import androidx.coordinatorlayout.widget.CoordinatorLayout import com.swmansion.rnscreens.gamma.stack.host.StackContainer import com.swmansion.rnscreens.gamma.stack.screen.StackScreen @@ -15,14 +16,16 @@ internal class StackScreenCoordinatorLayout( internal val stackScreen: StackScreen, ) : CoordinatorLayout(context) { private val headerCoordinator = StackScreenHeaderCoordinator(context) + internal var stackScreenWrapper: FrameLayout init { // Needed when Transition API is in use to ensure that shadows do not disappear, // views do not jump around the screen and whole sub-tree is animated as a whole. isTransitionGroup = true + stackScreenWrapper = FrameLayout(context).apply { addView(stackScreen) } addView( - stackScreen, + stackScreenWrapper, LayoutParams(MATCH_PARENT, MATCH_PARENT), ) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt index eef3c8c324..213aff7c08 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt @@ -75,13 +75,13 @@ internal class StackScreenHeaderCoordinator( coordinatorLayout: StackScreenCoordinatorLayout, config: StackScreenHeaderConfigurationProviding, ) { - val stackScreen = coordinatorLayout.stackScreen - val params = stackScreen.layoutParams as CoordinatorLayout.LayoutParams + val stackScreenWrapper = coordinatorLayout.stackScreenWrapper + val params = stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams val needsBehavior = appBarLayout != null && !config.isTransparent && !config.isHidden val hasBehavior = params.behavior != null if (needsBehavior != hasBehavior) { params.behavior = if (needsBehavior) AppBarLayout.ScrollingViewBehavior() else null - stackScreen.layoutParams = params + stackScreenWrapper.layoutParams = params } } } From 7529b78e04acd3e336827af988d6426bf70a72b8 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 17:19:27 +0100 Subject: [PATCH 56/92] subclass ScrollingViewBehavior to synchronize content origin offset --- .../gamma/stack/screen/StackScreen.kt | 9 ++++++++- .../screen/StackScreenShadowStateProxy.kt | 18 ++++++++++-------- .../header/StackScreenCoordinatorLayout.kt | 5 ++++- .../header/StackScreenHeaderCoordinator.kt | 8 +++++++- .../StackScreenScrollingViewBehavior.kt | 19 +++++++++++++++++++ 5 files changed, 48 insertions(+), 11 deletions(-) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt index 2dda466712..91fba70a19 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt @@ -54,6 +54,13 @@ class StackScreen( var stateWrapper by shadowStateProxy::stateWrapper + fun updateStateIfNeeded( + x: Int? = null, + y: Int? = null, + width: Int? = null, + height: Int? = null, + ) = shadowStateProxy.updateStateIfNeeded(x, y, width, height) + internal lateinit var eventEmitter: StackScreenEventEmitter /** @@ -88,7 +95,7 @@ class StackScreen( r: Int, b: Int, ) { - shadowStateProxy.updateStateIfNeeded(l, t, r - l, b - t) + shadowStateProxy.updateStateIfNeeded(width = r - l, height = b - t) } override fun getAssociatedFragment(): Fragment? = diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt index 5ce2ddd584..34bda01c43 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt @@ -15,15 +15,17 @@ internal class StackScreenShadowStateProxy { private var lastHeightInDp: Float = 0f fun updateStateIfNeeded( - x: Int, - y: Int, - width: Int, - height: Int, + x: Int? = null, + y: Int? = null, + width: Int? = null, + height: Int? = null, ) { - val xInDp: Float = PixelUtil.toDIPFromPixel(x.toFloat()) - val yInDp: Float = PixelUtil.toDIPFromPixel(y.toFloat()) - val widthInDp: Float = PixelUtil.toDIPFromPixel(width.toFloat()) - val heightInDp: Float = PixelUtil.toDIPFromPixel(height.toFloat()) + val xInDp: Float = x?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastXInDp + val yInDp: Float = y?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastYInDp + val widthInDp: Float = + width?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastWidthInDp + val heightInDp: Float = + height?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastHeightInDp // Check incoming state values. If they're already the correct value, return early to prevent // infinite UpdateState/SetState loop. diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index ad875ffbd9..72e49c6363 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -15,7 +15,10 @@ internal class StackScreenCoordinatorLayout( context: Context, internal val stackScreen: StackScreen, ) : CoordinatorLayout(context) { - private val headerCoordinator = StackScreenHeaderCoordinator(context) + private val headerCoordinator = StackScreenHeaderCoordinator(context) { headerHeight -> + stackScreen.updateStateIfNeeded(y = headerHeight) + } + internal var stackScreenWrapper: FrameLayout init { diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt index 213aff7c08..c34855ee1f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt @@ -10,6 +10,7 @@ import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScre internal class StackScreenHeaderCoordinator( context: Context, + private val onHeaderHeightChanged: (headerHeight: Int) -> Unit, ) { private var appBarLayout: StackScreenAppBarLayout? = null private var currentHeaderType: StackScreenHeaderType? = null @@ -80,7 +81,12 @@ internal class StackScreenHeaderCoordinator( val needsBehavior = appBarLayout != null && !config.isTransparent && !config.isHidden val hasBehavior = params.behavior != null if (needsBehavior != hasBehavior) { - params.behavior = if (needsBehavior) AppBarLayout.ScrollingViewBehavior() else null + params.behavior = if (needsBehavior) { + StackScreenScrollingViewBehavior(onHeaderHeightChanged) + } else { + onHeaderHeightChanged(0) + null + } stackScreenWrapper.layoutParams = params } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt new file mode 100644 index 0000000000..6d1ce6e14c --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt @@ -0,0 +1,19 @@ +package com.swmansion.rnscreens.gamma.stack.screen.header + +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.appbar.AppBarLayout + +internal class StackScreenScrollingViewBehavior( + private val onContentOffsetChanged: (headerHeight: Int) -> Unit, +) : AppBarLayout.ScrollingViewBehavior() { + override fun onDependentViewChanged( + parent: CoordinatorLayout, + child: View, + dependency: View, + ): Boolean { + val result = super.onDependentViewChanged(parent, child, dependency) + onContentOffsetChanged(child.top) + return result + } +} \ No newline at end of file From dd8d51fc74387cdda38e2b4779b0edadb9f7c166 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 17:34:57 +0100 Subject: [PATCH 57/92] add Pressable to SFT --- .../stack-v5/test-stack-header-modes.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx index a83063b29a..06a6f724da 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { Scenario } from '../../shared/helpers'; import { StackContainer } from '../../../shared/gamma/containers/stack'; -import { ScrollView } from 'react-native'; +import { ScrollView, Text, View } from 'react-native'; import LongText from '../../../../src/shared/LongText'; import { StackNavigationButtons } from '../../shared/components/stack-v5/StackNavigationButtons'; import Colors from '../../../../src/shared/styling/Colors'; +import PressableWithFeedback from '../../../../src/shared/PressableWithFeedback'; const SCENARIO: Scenario = { name: 'Stack Header Modes', @@ -44,7 +45,18 @@ function Screen(isHome: boolean) { - + + + + + Pressable + + ); From 6f0e2dfd5315846540d6c397eba04e3c70d12899 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 17:35:21 +0100 Subject: [PATCH 58/92] format android --- .../stack/screen/StackScreenShadowStateProxy.kt | 2 +- .../gamma/stack/screen/StackScreenViewManager.kt | 2 -- .../screen/header/StackScreenCoordinatorLayout.kt | 7 ++++--- .../screen/header/StackScreenHeaderCoordinator.kt | 14 +++++++------- .../header/StackScreenScrollingViewBehavior.kt | 2 +- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt index 34bda01c43..6a40cf3104 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt @@ -56,4 +56,4 @@ internal class StackScreenShadowStateProxy { companion object { private const val DELTA = 0.9f } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt index af1f81942e..b1a0bdfd9e 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt @@ -9,8 +9,6 @@ import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.viewmanagers.RNSStackScreenManagerDelegate import com.facebook.react.viewmanagers.RNSStackScreenManagerInterface -import com.swmansion.rnscreens.BuildConfig -import com.swmansion.rnscreens.Screen import com.swmansion.rnscreens.gamma.helpers.makeEventRegistrationInfo import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidAppearEvent import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidDisappearEvent diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index 72e49c6363..8935539237 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -15,9 +15,10 @@ internal class StackScreenCoordinatorLayout( context: Context, internal val stackScreen: StackScreen, ) : CoordinatorLayout(context) { - private val headerCoordinator = StackScreenHeaderCoordinator(context) { headerHeight -> - stackScreen.updateStateIfNeeded(y = headerHeight) - } + private val headerCoordinator = + StackScreenHeaderCoordinator(context) { headerHeight -> + stackScreen.updateStateIfNeeded(y = headerHeight) + } internal var stackScreenWrapper: FrameLayout diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt index c34855ee1f..b9e4e331c3 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.appcompat.view.ContextThemeWrapper import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.R -import com.google.android.material.appbar.AppBarLayout import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType @@ -81,12 +80,13 @@ internal class StackScreenHeaderCoordinator( val needsBehavior = appBarLayout != null && !config.isTransparent && !config.isHidden val hasBehavior = params.behavior != null if (needsBehavior != hasBehavior) { - params.behavior = if (needsBehavior) { - StackScreenScrollingViewBehavior(onHeaderHeightChanged) - } else { - onHeaderHeightChanged(0) - null - } + params.behavior = + if (needsBehavior) { + StackScreenScrollingViewBehavior(onHeaderHeightChanged) + } else { + onHeaderHeightChanged(0) + null + } stackScreenWrapper.layoutParams = params } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt index 6d1ce6e14c..80c9083cc7 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt @@ -16,4 +16,4 @@ internal class StackScreenScrollingViewBehavior( onContentOffsetChanged(child.top) return result } -} \ No newline at end of file +} From a59984c789cc0e2e4befa22b8062e54f2bb3bebc Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 17:49:57 +0100 Subject: [PATCH 59/92] add comments --- .../stack/screen/header/StackScreenAppBarLayout.kt | 5 ++++- .../screen/header/StackScreenCoordinatorLayout.kt | 11 +++++++++-- .../screen/header/StackScreenHeaderCoordinator.kt | 3 ++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt index da165c9372..cd9d1b3200 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt @@ -22,6 +22,9 @@ internal sealed class StackScreenAppBarLayout( init { layoutParams = CoordinatorLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + + // TODO: this should be exposed in the future via prop. Also, it might not work correctly + // until we set liftOnScrollView manually. isLiftOnScroll = true // TODO: this won't work with nested header but there were some problems with lift on scroll @@ -38,8 +41,8 @@ internal sealed class StackScreenAppBarLayout( layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { // TODO: debug only for small header, must be moved to configuration -// scrollFlags = SCROLL_FLAG_NO_SCROLL scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_SNAP +// scrollFlags = SCROLL_FLAG_NO_SCROLL } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index 8935539237..03572027e7 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -24,16 +24,21 @@ internal class StackScreenCoordinatorLayout( init { // Needed when Transition API is in use to ensure that shadows do not disappear, - // views do not jump around the screen and whole sub-tree is animated as a whole. + // views do not jump around the screen and whole subtree is animated as a whole. isTransitionGroup = true + // Due to how we're synchronizing native & Yoga layout (via contentOriginOffset on + // StackScreen), we can't use StackScreen directly as a child of CoordinatorLayout because + // SurfaceMountingManager will override Y offset (that depends on the header height) with + // Y=0. If we wrap StackScreen in another view, as Y is relative to parent view, value set + // by Yoga will be correct. stackScreenWrapper = FrameLayout(context).apply { addView(stackScreen) } addView( stackScreenWrapper, LayoutParams(MATCH_PARENT, MATCH_PARENT), ) - // TODO: debug-only + // TODO: debug-only, this will be sent in reaction to information from "HeaderConfig" component. applyHeaderConfiguration( object : StackScreenHeaderConfigurationProviding { override val headerType = StackScreenHeaderType.LARGE @@ -43,6 +48,7 @@ internal class StackScreenCoordinatorLayout( }, ) + // TODO: debug only, until we expose props via JS // postDelayed({ // applyHeaderConfiguration( // object : StackScreenHeaderConfigurationProviding { @@ -68,6 +74,7 @@ internal class StackScreenCoordinatorLayout( private fun stackContainerOrNull(): StackContainer? = this.parent as StackContainer? + // TODO: do we need to rely on parent here? internal fun maybeRequestLayoutContainer() { post { stackContainerOrNull()?.forceSubtreeMeasureAndLayoutPass() diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt index b9e4e331c3..27bf662605 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt @@ -52,7 +52,7 @@ internal class StackScreenHeaderCoordinator( private fun applyAppBarConfiguration(config: StackScreenHeaderConfigurationProviding) { appBarLayout?.let { appBar -> applyTitle(appBar, config.title) - // ... + // TODO: other app bar configuration... } } @@ -60,6 +60,7 @@ internal class StackScreenHeaderCoordinator( appBarLayout: StackScreenAppBarLayout, title: String, ) { + // TODO: diffing mechanism? when (appBarLayout) { is StackScreenAppBarLayout.Small -> { appBarLayout.toolbar.title = title From 61b1443ada381df8404cd6aeb0ab6c42871e90f2 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 18:12:42 +0100 Subject: [PATCH 60/92] fix build on iOS, add information to lift on scroll comment --- .../gamma/stack/screen/header/StackScreenAppBarLayout.kt | 8 +++++--- .../renderer/components/rnscreens/RNSStackScreenState.h | 2 ++ ios/gamma/stack/screen/RNSStackScreenComponentView.mm | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt index cd9d1b3200..2353577678 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt @@ -8,6 +8,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.R import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED +import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP import com.google.android.material.appbar.CollapsingToolbarLayout @@ -24,7 +25,8 @@ internal sealed class StackScreenAppBarLayout( layoutParams = CoordinatorLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) // TODO: this should be exposed in the future via prop. Also, it might not work correctly - // until we set liftOnScrollView manually. + // until we set liftOnScrollView manually. Also, we should disable it in transparent + // mode or set elevation higher. isLiftOnScroll = true // TODO: this won't work with nested header but there were some problems with lift on scroll @@ -41,8 +43,8 @@ internal sealed class StackScreenAppBarLayout( layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { // TODO: debug only for small header, must be moved to configuration - scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_SNAP -// scrollFlags = SCROLL_FLAG_NO_SCROLL +// scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_SNAP + scrollFlags = SCROLL_FLAG_NO_SCROLL } } diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h index 59ee1a4ad2..7404471c4d 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h @@ -1,5 +1,7 @@ #pragma once +#include + #ifdef ANDROID #include #include diff --git a/ios/gamma/stack/screen/RNSStackScreenComponentView.mm b/ios/gamma/stack/screen/RNSStackScreenComponentView.mm index 7323f36de8..5c63474cbe 100644 --- a/ios/gamma/stack/screen/RNSStackScreenComponentView.mm +++ b/ios/gamma/stack/screen/RNSStackScreenComponentView.mm @@ -5,6 +5,7 @@ #import #import #import +#import #import "RNSConversions-Stack.h" #import "RNSStackHostComponentView.h" From 94772d66bcddb8fcf09cf1dcf823389d2067811a Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 12 Mar 2026 18:33:25 +0100 Subject: [PATCH 61/92] unify naming --- .../stack/screen/header/StackScreenScrollingViewBehavior.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt index 80c9083cc7..b0accd1f16 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt @@ -5,7 +5,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.appbar.AppBarLayout internal class StackScreenScrollingViewBehavior( - private val onContentOffsetChanged: (headerHeight: Int) -> Unit, + private val onHeaderHeightChanged: (headerHeight: Int) -> Unit, ) : AppBarLayout.ScrollingViewBehavior() { override fun onDependentViewChanged( parent: CoordinatorLayout, @@ -13,7 +13,7 @@ internal class StackScreenScrollingViewBehavior( dependency: View, ): Boolean { val result = super.onDependentViewChanged(parent, child, dependency) - onContentOffsetChanged(child.top) + onHeaderHeightChanged(child.top) return result } } From 07ff4e218c4bb422a08c462bd9d15a4e4a0aaac3 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 16 Mar 2026 15:06:14 +0100 Subject: [PATCH 62/92] use requireContext() instead of passing it down to fragment --- .../swmansion/rnscreens/gamma/stack/host/StackContainer.kt | 4 ++-- .../rnscreens/gamma/stack/screen/StackScreenFragment.kt | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt index 79e2f0e5b6..eae017288d 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt @@ -16,7 +16,7 @@ import java.lang.ref.WeakReference @SuppressLint("ViewConstructor") // Only we construct this view, it is never inflated. internal class StackContainer( - private val context: Context, + context: Context, private val delegate: WeakReference, ) : FrameLayout(context), FragmentManager.OnBackStackChangedListener { @@ -194,7 +194,7 @@ internal class StackContainer( } private fun createFragmentForScreen(screen: StackScreen): StackScreenFragment = - StackScreenFragment(context, screen).also { + StackScreenFragment(screen).also { Log.d(TAG, "Created Fragment $it for screen ${screen.screenKey}") } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt index 4d4d661ee3..21fd19deee 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt @@ -11,7 +11,6 @@ import androidx.transition.Slide import com.swmansion.rnscreens.gamma.stack.screen.header.StackScreenCoordinatorLayout internal class StackScreenFragment( - private val context: Context, internal val stackScreen: StackScreen, ) : Fragment() { private var screenLifecycleEventEmitter: StackScreenAppearanceEventsEmitter? = null @@ -46,7 +45,7 @@ internal class StackScreenFragment( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View = StackScreenCoordinatorLayout(context, stackScreen) + ): View = StackScreenCoordinatorLayout(requireContext(), stackScreen) override fun onViewCreated( view: View, From e775daae8761448f234453ff4d4ae678c810027e Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 16 Mar 2026 15:08:55 +0100 Subject: [PATCH 63/92] add comment informing about potential crash --- .../gamma/stack/screen/header/StackScreenCoordinatorLayout.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt index 03572027e7..810f10af34 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt @@ -72,6 +72,9 @@ internal class StackScreenCoordinatorLayout( // }, 3000) } + /** + * Will crash in case parent is not StackContainer. + */ private fun stackContainerOrNull(): StackContainer? = this.parent as StackContainer? // TODO: do we need to rely on parent here? From 5486a2f4ab9936822dcb1f7e1088bdedf0c825a7 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 16 Mar 2026 15:14:13 +0100 Subject: [PATCH 64/92] format android --- .../rnscreens/gamma/stack/screen/StackScreenFragment.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt index 21fd19deee..922efbfe51 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenFragment.kt @@ -1,6 +1,5 @@ package com.swmansion.rnscreens.gamma.stack.screen -import android.content.Context import android.os.Bundle import android.view.Gravity import android.view.LayoutInflater From 8b00e5bad66cdc669d9746d3261b366389fe490d Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 16 Mar 2026 16:23:57 +0100 Subject: [PATCH 65/92] apply suggestion from code review --- .../react/renderer/components/rnscreens/RNSStackScreenState.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h index 7404471c4d..b8a5dd12a3 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackScreenState.h @@ -32,7 +32,7 @@ class JSI_EXPORT RNSStackScreenState final { (Float)data["contentOffsetY"].getDouble()}) {}; Size frameSize{}; - Point contentOffset; + Point contentOffset{}; folly::dynamic getDynamic() const; MapBuffer getMapBuffer() const { From 92536957f62ccdd2836c702b6046aa92e33c4450 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Tue, 7 Apr 2026 15:47:25 +0200 Subject: [PATCH 66/92] split components --- .../config/StackHeaderConfigViewManager.kt | 10 +++--- .../subview/StackHeaderSubviewViewManager.kt | 10 +++--- .../RNSStackHeaderConfigShadowNode.cpp | 8 ++++- .../RNSStackHeaderConfigShadowNode.h | 9 ++++-- .../RNSStackHeaderSubviewShadowNode.cpp | 2 +- .../RNSStackHeaderSubviewShadowNode.h | 4 +-- .../header/StackHeaderConfig.android.tsx | 20 ++++++++---- .../header/StackHeaderConfig.android.types.ts | 24 ++++++++++++++ .../header/StackHeaderConfig.ios.types.ts | 2 ++ .../stack/header/StackHeaderConfig.types.ts | 32 ++++++------------- .../stack/header/StackHeaderSubview.d.ts | 17 ---------- .../stack/header/StackHeaderSubview.ios.tsx | 5 --- .../stack/header/StackHeaderSubview.web.tsx | 5 --- .../StackHeaderSubview.android.tsx | 8 ++--- .../StackHeaderSubview.android.types.ts} | 0 src/components/gamma/stack/header/index.ts | 6 ++++ .../gamma/stack/{ => host}/StackHost.tsx | 2 +- .../gamma/stack/{ => host}/StackHost.types.ts | 2 +- .../gamma/stack/{ => host}/StackHost.web.tsx | 0 src/components/gamma/stack/host/index.ts | 3 ++ src/components/gamma/stack/index.ts | 21 +++++++----- .../gamma/stack/{ => screen}/StackScreen.tsx | 4 +-- .../stack/{ => screen}/StackScreen.types.ts | 0 .../stack/{ => screen}/StackScreen.web.tsx | 0 src/components/gamma/stack/screen/index.ts | 3 ++ ...tackHeaderConfigAndroidNativeComponent.ts} | 14 +++++--- ...ackHeaderSubviewAndroidNativeComponent.ts} | 10 ++++-- 27 files changed, 125 insertions(+), 96 deletions(-) create mode 100644 src/components/gamma/stack/header/StackHeaderConfig.android.types.ts create mode 100644 src/components/gamma/stack/header/StackHeaderConfig.ios.types.ts delete mode 100644 src/components/gamma/stack/header/StackHeaderSubview.d.ts delete mode 100644 src/components/gamma/stack/header/StackHeaderSubview.ios.tsx delete mode 100644 src/components/gamma/stack/header/StackHeaderSubview.web.tsx rename src/components/gamma/stack/header/{ => android}/StackHeaderSubview.android.tsx (67%) rename src/components/gamma/stack/header/{StackHeaderSubview.types.ts => android/StackHeaderSubview.android.types.ts} (100%) create mode 100644 src/components/gamma/stack/header/index.ts rename src/components/gamma/stack/{ => host}/StackHost.tsx (83%) rename src/components/gamma/stack/{ => host}/StackHost.types.ts (75%) rename src/components/gamma/stack/{ => host}/StackHost.web.tsx (100%) create mode 100644 src/components/gamma/stack/host/index.ts rename src/components/gamma/stack/{ => screen}/StackScreen.tsx (90%) rename src/components/gamma/stack/{ => screen}/StackScreen.types.ts (100%) rename src/components/gamma/stack/{ => screen}/StackScreen.web.tsx (100%) create mode 100644 src/components/gamma/stack/screen/index.ts rename src/fabric/gamma/stack/{StackHeaderConfigNativeComponent.ts => StackHeaderConfigAndroidNativeComponent.ts} (69%) rename src/fabric/gamma/stack/{StackHeaderSubviewNativeComponent.ts => StackHeaderSubviewAndroidNativeComponent.ts} (77%) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt index 2f6cd026c0..36ad0750d5 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt @@ -8,18 +8,18 @@ import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate -import com.facebook.react.viewmanagers.RNSStackHeaderConfigManagerDelegate -import com.facebook.react.viewmanagers.RNSStackHeaderConfigManagerInterface +import com.facebook.react.viewmanagers.RNSStackHeaderConfigAndroidManagerDelegate +import com.facebook.react.viewmanagers.RNSStackHeaderConfigAndroidManagerInterface import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview @ReactModule(name = StackHeaderConfigViewManager.REACT_CLASS) open class StackHeaderConfigViewManager : ViewGroupManager(), - RNSStackHeaderConfigManagerInterface { + RNSStackHeaderConfigAndroidManagerInterface { private val delegate: ViewManagerDelegate init { - delegate = RNSStackHeaderConfigManagerDelegate(this) + delegate = RNSStackHeaderConfigAndroidManagerDelegate(this) } override fun getName() = REACT_CLASS @@ -116,6 +116,6 @@ open class StackHeaderConfigViewManager : } companion object { - const val REACT_CLASS = "RNSStackHeaderConfig" + const val REACT_CLASS = "RNSStackHeaderConfigAndroid" } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt index 31aeaf3bb5..8297946c73 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt @@ -7,17 +7,17 @@ import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate -import com.facebook.react.viewmanagers.RNSStackHeaderSubviewManagerDelegate -import com.facebook.react.viewmanagers.RNSStackHeaderSubviewManagerInterface +import com.facebook.react.viewmanagers.RNSStackHeaderSubviewAndroidManagerDelegate +import com.facebook.react.viewmanagers.RNSStackHeaderSubviewAndroidManagerInterface @ReactModule(name = StackHeaderSubviewViewManager.REACT_CLASS) open class StackHeaderSubviewViewManager : ViewGroupManager(), - RNSStackHeaderSubviewManagerInterface { + RNSStackHeaderSubviewAndroidManagerInterface { private val delegate: ViewManagerDelegate init { - delegate = RNSStackHeaderSubviewManagerDelegate(this) + delegate = RNSStackHeaderSubviewAndroidManagerDelegate(this) } override fun getName() = REACT_CLASS @@ -62,6 +62,6 @@ open class StackHeaderSubviewViewManager : } companion object { - const val REACT_CLASS = "RNSStackHeaderSubview" + const val REACT_CLASS = "RNSStackHeaderSubviewAndroid" } } diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.cpp index 77b0aadb9c..2f509ded46 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.cpp +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.cpp @@ -2,7 +2,13 @@ namespace facebook::react { -extern const char RNSStackHeaderConfigComponentName[] = "RNSStackHeaderConfig"; +#if !defined(ANDROID) +extern const char RNSStackHeaderConfigComponentName[] = + "RNSStackHeaderConfigIOS"; +#else // !defined(ANDROID) +extern const char RNSStackHeaderConfigComponentName[] = + "RNSStackHeaderConfigAndroid"; +#endif // !defined(ANDROID) #ifdef ANDROID Point RNSStackHeaderConfigShadowNode::getContentOriginOffset( diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.h index 733bd638dd..6703f4722e 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.h @@ -13,8 +13,13 @@ JSI_EXPORT extern const char RNSStackHeaderConfigComponentName[]; class JSI_EXPORT RNSStackHeaderConfigShadowNode final : public ConcreteViewShadowNode< RNSStackHeaderConfigComponentName, - RNSStackHeaderConfigProps, - RNSStackHeaderConfigEventEmitter, +#if !defined(ANDROID) + RNSStackHeaderConfigIOSProps, + RNSStackHeaderConfigIOSEventEmitter, +#else // !defined(ANDROID) + RNSStackHeaderConfigAndroidProps, + RNSStackHeaderConfigAndroidEventEmitter, +#endif // !defined(ANDROID) RNSStackHeaderConfigState> { public: using ConcreteViewShadowNode::ConcreteViewShadowNode; diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp index 36f924185d..ef6c1944f3 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp @@ -3,7 +3,7 @@ namespace facebook::react { extern const char RNSStackHeaderSubviewComponentName[] = - "RNSStackHeaderSubview"; + "RNSStackHeaderSubviewAndroid"; #ifdef ANDROID Point RNSStackHeaderSubviewShadowNode::getContentOriginOffset( diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h index 8b5baf9874..851ebdb776 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h @@ -14,8 +14,8 @@ JSI_EXPORT extern const char RNSStackHeaderSubviewComponentName[]; class JSI_EXPORT RNSStackHeaderSubviewShadowNode final : public ConcreteViewShadowNode< RNSStackHeaderSubviewComponentName, - RNSStackHeaderSubviewProps, - RNSStackHeaderSubviewEventEmitter, + RNSStackHeaderSubviewAndroidProps, + RNSStackHeaderSubviewAndroidEventEmitter, RNSStackHeaderSubviewState> { public: using ConcreteViewShadowNode::ConcreteViewShadowNode; diff --git a/src/components/gamma/stack/header/StackHeaderConfig.android.tsx b/src/components/gamma/stack/header/StackHeaderConfig.android.tsx index 12dec587d0..ff6d018bf2 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.android.tsx +++ b/src/components/gamma/stack/header/StackHeaderConfig.android.tsx @@ -1,25 +1,31 @@ import React from 'react'; import { StyleSheet } from 'react-native'; import { StackHeaderConfigProps } from './StackHeaderConfig.types'; -import StackHeaderConfigNativeComponent from '../../../../fabric/gamma/stack/StackHeaderConfigNativeComponent'; -import StackHeaderSubview from './StackHeaderSubview'; +import StackHeaderConfigAndroidNativeComponent from '../../../../fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent'; +import StackHeaderSubview from './android/StackHeaderSubview.android'; /** * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE */ function StackHeaderConfig(props: StackHeaderConfigProps) { + // ios props are safely dropped + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { android, ios, ...baseProps } = props; + const { backgroundSubview, leadingSubview, centerSubview, trailingSubview, - ...filteredProps - } = props; + ...filteredAndroidProps + } = android ?? {}; + return ( - + {...baseProps} + {...filteredAndroidProps}> {backgroundSubview && ( )} - + ); } diff --git a/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts b/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts new file mode 100644 index 0000000000..7625488a4f --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts @@ -0,0 +1,24 @@ +import { ReactNode } from 'react'; +import { StackHeaderSubviewCollapseModeAndroid } from './android/StackHeaderSubview.android.types'; + +export type StackHeaderTypeAndroid = 'small' | 'medium' | 'large'; + +export type StackHeaderBackgroundSubviewCollapseModeAndroid = + StackHeaderSubviewCollapseModeAndroid; + +export interface StackHeaderToolbarSubviewAndroid { + Component: ReactNode; +} + +export interface StackHeaderBackgroundSubviewAndroid { + collapseMode?: StackHeaderSubviewCollapseModeAndroid; + Component: ReactNode; +} + +export interface StackHeaderConfigPropsAndroid { + type?: StackHeaderTypeAndroid; + backgroundSubview?: StackHeaderBackgroundSubviewAndroid; + leadingSubview?: StackHeaderToolbarSubviewAndroid; + centerSubview?: StackHeaderToolbarSubviewAndroid; + trailingSubview?: StackHeaderToolbarSubviewAndroid; +} diff --git a/src/components/gamma/stack/header/StackHeaderConfig.ios.types.ts b/src/components/gamma/stack/header/StackHeaderConfig.ios.types.ts new file mode 100644 index 0000000000..a260512367 --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderConfig.ios.types.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface StackHeaderConfigPropsIOS {} diff --git a/src/components/gamma/stack/header/StackHeaderConfig.types.ts b/src/components/gamma/stack/header/StackHeaderConfig.types.ts index 4ae25c46e0..35cd4e6ba2 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.types.ts +++ b/src/components/gamma/stack/header/StackHeaderConfig.types.ts @@ -1,27 +1,13 @@ -import { ReactNode } from 'react'; -import { StackHeaderSubviewCollapseModeAndroid } from './StackHeaderSubview.types'; +import { StackHeaderConfigPropsAndroid } from './StackHeaderConfig.android.types'; +import { StackHeaderConfigPropsIOS } from './StackHeaderConfig.ios.types'; -export type StackHeaderTypeAndroid = 'small' | 'medium' | 'large'; - -export type StackHeaderToolbarSubviewAndroid = { - Component: ReactNode; -}; - -export type StackHeaderBackgroundSubviewCollapseModeAndroid = - StackHeaderSubviewCollapseModeAndroid; - -export type StackHeaderBackgroundSubviewAndroid = { - collapseMode?: StackHeaderSubviewCollapseModeAndroid; - Component: ReactNode; -}; - -export type StackHeaderConfigProps = { - type?: StackHeaderTypeAndroid; +export interface StackHeaderConfigPropsBase { title?: string; hidden?: boolean; transparent?: boolean; - backgroundSubview?: StackHeaderBackgroundSubviewAndroid; - leadingSubview?: StackHeaderToolbarSubviewAndroid; - centerSubview?: StackHeaderToolbarSubviewAndroid; - trailingSubview?: StackHeaderToolbarSubviewAndroid; -}; +} + +export interface StackHeaderConfigProps extends StackHeaderConfigPropsBase { + android?: StackHeaderConfigPropsAndroid; + ios?: StackHeaderConfigPropsIOS; +} diff --git a/src/components/gamma/stack/header/StackHeaderSubview.d.ts b/src/components/gamma/stack/header/StackHeaderSubview.d.ts deleted file mode 100644 index fb09ed3fa7..0000000000 --- a/src/components/gamma/stack/header/StackHeaderSubview.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * TS module resolution does not support this RN platform extension pattern out of the box. - * Without a base .tsx file, TS will throw a "Cannot find module" error. - * - * This file satisfies the TS compiler by providing the correct type signatures, - * whereas Metro will handle the proper runtime file resolution. - */ - -import React from 'react'; -import { StackHeaderSubviewProps } from './StackHeaderSubview.types'; - -/** - * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE - */ -export default function StackHeaderSubview( - props: StackHeaderSubviewProps, -): React.JSX.Element; diff --git a/src/components/gamma/stack/header/StackHeaderSubview.ios.tsx b/src/components/gamma/stack/header/StackHeaderSubview.ios.tsx deleted file mode 100644 index 2b4e14c84d..0000000000 --- a/src/components/gamma/stack/header/StackHeaderSubview.ios.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { View } from 'react-native'; - -const StackHeaderSubview = View; - -export default StackHeaderSubview; diff --git a/src/components/gamma/stack/header/StackHeaderSubview.web.tsx b/src/components/gamma/stack/header/StackHeaderSubview.web.tsx deleted file mode 100644 index 2b4e14c84d..0000000000 --- a/src/components/gamma/stack/header/StackHeaderSubview.web.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { View } from 'react-native'; - -const StackHeaderSubview = View; - -export default StackHeaderSubview; diff --git a/src/components/gamma/stack/header/StackHeaderSubview.android.tsx b/src/components/gamma/stack/header/android/StackHeaderSubview.android.tsx similarity index 67% rename from src/components/gamma/stack/header/StackHeaderSubview.android.tsx rename to src/components/gamma/stack/header/android/StackHeaderSubview.android.tsx index b6525dd34c..4bc84dc2a6 100644 --- a/src/components/gamma/stack/header/StackHeaderSubview.android.tsx +++ b/src/components/gamma/stack/header/android/StackHeaderSubview.android.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { StackHeaderSubviewProps } from './StackHeaderSubview.types'; -import StackHeaderSubviewNativeComponent from '../../../../fabric/gamma/stack/StackHeaderSubviewNativeComponent'; +import { StackHeaderSubviewProps } from './StackHeaderSubview.android.types'; import { StyleSheet } from 'react-native'; +import StackHeaderSubviewAndroidNativeComponent from '../../../../../fabric/gamma/stack/StackHeaderSubviewAndroidNativeComponent'; /** * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE @@ -9,7 +9,7 @@ import { StyleSheet } from 'react-native'; function StackHeaderSubview(props: StackHeaderSubviewProps) { const { children, ...filteredProps } = props; return ( - {children} - + ); } diff --git a/src/components/gamma/stack/header/StackHeaderSubview.types.ts b/src/components/gamma/stack/header/android/StackHeaderSubview.android.types.ts similarity index 100% rename from src/components/gamma/stack/header/StackHeaderSubview.types.ts rename to src/components/gamma/stack/header/android/StackHeaderSubview.android.types.ts diff --git a/src/components/gamma/stack/header/index.ts b/src/components/gamma/stack/header/index.ts new file mode 100644 index 0000000000..24d614494a --- /dev/null +++ b/src/components/gamma/stack/header/index.ts @@ -0,0 +1,6 @@ +export { default as StackHeaderConfig } from './StackHeaderConfig'; + +export type * from './StackHeaderConfig.types'; + +export type * from './StackHeaderConfig.android.types'; +export type * from './StackHeaderConfig.ios.types'; diff --git a/src/components/gamma/stack/StackHost.tsx b/src/components/gamma/stack/host/StackHost.tsx similarity index 83% rename from src/components/gamma/stack/StackHost.tsx rename to src/components/gamma/stack/host/StackHost.tsx index 60c4118dbe..c0733112ae 100644 --- a/src/components/gamma/stack/StackHost.tsx +++ b/src/components/gamma/stack/host/StackHost.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { StyleSheet } from 'react-native'; -import StackHostNativeComponent from '../../../fabric/gamma/stack/StackHostNativeComponent'; +import StackHostNativeComponent from '../../../../fabric/gamma/stack/StackHostNativeComponent'; import type { StackHostProps } from './StackHost.types'; /** diff --git a/src/components/gamma/stack/StackHost.types.ts b/src/components/gamma/stack/host/StackHost.types.ts similarity index 75% rename from src/components/gamma/stack/StackHost.types.ts rename to src/components/gamma/stack/host/StackHost.types.ts index a57ef13809..ba43b46700 100644 --- a/src/components/gamma/stack/StackHost.types.ts +++ b/src/components/gamma/stack/host/StackHost.types.ts @@ -1,6 +1,6 @@ import React from 'react'; import type { ReactNativeElement, ViewProps } from 'react-native'; -import { type NativeProps } from '../../../fabric/gamma/stack/StackHostNativeComponent'; +import { type NativeProps } from '../../../../fabric/gamma/stack/StackHostNativeComponent'; export type StackHostProps = { children: ViewProps['children']; diff --git a/src/components/gamma/stack/StackHost.web.tsx b/src/components/gamma/stack/host/StackHost.web.tsx similarity index 100% rename from src/components/gamma/stack/StackHost.web.tsx rename to src/components/gamma/stack/host/StackHost.web.tsx diff --git a/src/components/gamma/stack/host/index.ts b/src/components/gamma/stack/host/index.ts new file mode 100644 index 0000000000..2f4fcd8ef6 --- /dev/null +++ b/src/components/gamma/stack/host/index.ts @@ -0,0 +1,3 @@ +export { default as StackHost } from './StackHost'; + +export type * from './StackHost.types'; diff --git a/src/components/gamma/stack/index.ts b/src/components/gamma/stack/index.ts index de8b98eb52..00899c11cc 100644 --- a/src/components/gamma/stack/index.ts +++ b/src/components/gamma/stack/index.ts @@ -1,8 +1,8 @@ -import StackHost from './StackHost'; -import StackScreen from './StackScreen'; -import StackHeaderConfig from './header/StackHeaderConfig'; +import { StackHost } from './host'; +import { StackScreen } from './screen'; +import { StackHeaderConfig } from './header'; -export type { StackHostProps } from './StackHost.types'; +export type { StackHostProps } from './host'; export type { OnDismissEventPayload, @@ -11,15 +11,20 @@ export type { StackScreenActivityMode, StackScreenEventHandler, StackScreenProps, -} from './StackScreen.types'; +} from './screen'; export type { + StackHeaderConfigPropsBase, + StackHeaderConfigProps, + // Android StackHeaderTypeAndroid, - StackHeaderToolbarSubviewAndroid, StackHeaderBackgroundSubviewCollapseModeAndroid, + StackHeaderToolbarSubviewAndroid, StackHeaderBackgroundSubviewAndroid, - StackHeaderConfigProps, -} from './header/StackHeaderConfig.types'; + StackHeaderConfigPropsAndroid, + // iOS + StackHeaderConfigPropsIOS, +} from './header'; /** * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE diff --git a/src/components/gamma/stack/StackScreen.tsx b/src/components/gamma/stack/screen/StackScreen.tsx similarity index 90% rename from src/components/gamma/stack/StackScreen.tsx rename to src/components/gamma/stack/screen/StackScreen.tsx index fadc83a5a0..3920526b59 100644 --- a/src/components/gamma/stack/StackScreen.tsx +++ b/src/components/gamma/stack/screen/StackScreen.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { StyleSheet } from 'react-native'; -import StackScreenNativeComponent from '../../../fabric/gamma/stack/StackScreenNativeComponent'; +import StackScreenNativeComponent from '../../../../fabric/gamma/stack/StackScreenNativeComponent'; import { OnDismissEvent, StackScreenProps } from './StackScreen.types'; -import { useRenderDebugInfo } from '../../../private/'; +import { useRenderDebugInfo } from '../../../../private'; /** * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE diff --git a/src/components/gamma/stack/StackScreen.types.ts b/src/components/gamma/stack/screen/StackScreen.types.ts similarity index 100% rename from src/components/gamma/stack/StackScreen.types.ts rename to src/components/gamma/stack/screen/StackScreen.types.ts diff --git a/src/components/gamma/stack/StackScreen.web.tsx b/src/components/gamma/stack/screen/StackScreen.web.tsx similarity index 100% rename from src/components/gamma/stack/StackScreen.web.tsx rename to src/components/gamma/stack/screen/StackScreen.web.tsx diff --git a/src/components/gamma/stack/screen/index.ts b/src/components/gamma/stack/screen/index.ts new file mode 100644 index 0000000000..c5bef76de7 --- /dev/null +++ b/src/components/gamma/stack/screen/index.ts @@ -0,0 +1,3 @@ +export { default as StackScreen } from './StackScreen'; + +export type * from './StackScreen.types'; diff --git a/src/fabric/gamma/stack/StackHeaderConfigNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts similarity index 69% rename from src/fabric/gamma/stack/StackHeaderConfigNativeComponent.ts rename to src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts index 95aa652e5d..f4b63ad3d3 100644 --- a/src/fabric/gamma/stack/StackHeaderConfigNativeComponent.ts +++ b/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts @@ -6,12 +6,18 @@ import { codegenNativeComponent } from 'react-native'; type StackHeaderTypeAndroid = 'small' | 'medium' | 'large'; export interface NativeProps extends ViewProps { - type?: CT.WithDefault; title?: string; hidden?: CT.WithDefault; transparent?: CT.WithDefault; + + // Android-specific props + type?: CT.WithDefault; } -export default codegenNativeComponent('RNSStackHeaderConfig', { - interfaceOnly: true, -}); +export default codegenNativeComponent( + 'RNSStackHeaderConfigAndroid', + { + interfaceOnly: true, + excludedPlatforms: ['iOS'], + }, +); diff --git a/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderSubviewAndroidNativeComponent.ts similarity index 77% rename from src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts rename to src/fabric/gamma/stack/StackHeaderSubviewAndroidNativeComponent.ts index d01f95a728..5f067b46be 100644 --- a/src/fabric/gamma/stack/StackHeaderSubviewNativeComponent.ts +++ b/src/fabric/gamma/stack/StackHeaderSubviewAndroidNativeComponent.ts @@ -18,6 +18,10 @@ export interface NativeProps extends ViewProps { >; } -export default codegenNativeComponent('RNSStackHeaderSubview', { - interfaceOnly: true, -}); +export default codegenNativeComponent( + 'RNSStackHeaderSubviewAndroid', + { + interfaceOnly: true, + excludedPlatforms: ['iOS'], + }, +); From b560f2fbd34defc9f82d361f30b68de0992a11d5 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Tue, 7 Apr 2026 16:17:08 +0200 Subject: [PATCH 67/92] adjust SFT after split --- .../stack-v5/test-stack-subviews.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx index ae1194b064..a288b7a8c0 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx @@ -138,14 +138,16 @@ function buildHeaderConfig(config: Config): StackHeaderConfigProps | undefined { : undefined; return { - type: config.type, title: config.title === 'short' ? SHORT_TITLE : LONG_TITLE, hidden: config.hidden, transparent: config.transparent, - backgroundSubview, - leadingSubview: makeToolbarSubview(config.leadingSize, 'L'), - centerSubview: makeToolbarSubview(config.centerSize, 'C'), - trailingSubview: makeToolbarSubview(config.trailingSize, 'T'), + android: { + type: config.type, + backgroundSubview, + leadingSubview: makeToolbarSubview(config.leadingSize, 'L'), + centerSubview: makeToolbarSubview(config.centerSize, 'C'), + trailingSubview: makeToolbarSubview(config.trailingSize, 'T'), + }, }; } From 6d8b8972fc8036b181ccce827c1cc8fa908972c0 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 8 Apr 2026 12:45:30 +0200 Subject: [PATCH 68/92] build on iOS --- .../stack/header/StackHeaderConfig.ios.tsx | 17 +++++++++++++++-- .../StackHeaderConfigIOSNativeComponent.ts | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts diff --git a/src/components/gamma/stack/header/StackHeaderConfig.ios.tsx b/src/components/gamma/stack/header/StackHeaderConfig.ios.tsx index c65135ec55..69118bb4c9 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.ios.tsx +++ b/src/components/gamma/stack/header/StackHeaderConfig.ios.tsx @@ -1,5 +1,18 @@ -import { View } from 'react-native'; +import { StackHeaderConfigProps } from './StackHeaderConfig.types'; +import StackHeaderConfigIOSNativeComponent from '../../../../fabric/gamma/stack/StackHeaderConfigIOSNativeComponent'; +import React from 'react'; -const StackHeaderConfig = View; +/** + * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE + */ +function StackHeaderConfig(props: StackHeaderConfigProps) { + // android props are safely dropped + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { android, ios, ...baseProps } = props; + + return ( + + ); +} export default StackHeaderConfig; diff --git a/src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts new file mode 100644 index 0000000000..c461d4b063 --- /dev/null +++ b/src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts @@ -0,0 +1,17 @@ +'use client'; + +import type { CodegenTypes as CT, ViewProps } from 'react-native'; +import { codegenNativeComponent } from 'react-native'; + +export interface NativeProps extends ViewProps { + title?: string; + hidden?: CT.WithDefault; + transparent?: CT.WithDefault; + + // iOS-specific props +} + +export default codegenNativeComponent('RNSStackHeaderConfigIOS', { + interfaceOnly: true, + excludedPlatforms: ['android'], +}); From a4b6c6bd3f17e23fa7260e48d43c260e039a25b6 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 8 Apr 2026 13:28:11 +0200 Subject: [PATCH 69/92] remove old files --- .../screen/StackScreenShadowStateProxy.kt | 59 --------- .../screen/header/StackScreenAppBarLayout.kt | 123 ------------------ .../header/StackScreenCoordinatorLayout.kt | 89 ------------- .../header/StackScreenHeaderCoordinator.kt | 94 ------------- .../StackScreenScrollingViewBehavior.kt | 19 --- ...StackScreenHeaderConfigurationProviding.kt | 8 -- .../configuration/StackScreenHeaderType.kt | 7 - 7 files changed, 399 deletions(-) delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt deleted file mode 100644 index 6a40cf3104..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenShadowStateProxy.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.screen - -import com.facebook.react.bridge.WritableMap -import com.facebook.react.bridge.WritableNativeMap -import com.facebook.react.uimanager.PixelUtil -import com.facebook.react.uimanager.StateWrapper -import kotlin.math.abs - -internal class StackScreenShadowStateProxy { - internal var stateWrapper: StateWrapper? = null - - private var lastXInDp: Float = 0f - private var lastYInDp: Float = 0f - private var lastWidthInDp: Float = 0f - private var lastHeightInDp: Float = 0f - - fun updateStateIfNeeded( - x: Int? = null, - y: Int? = null, - width: Int? = null, - height: Int? = null, - ) { - val xInDp: Float = x?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastXInDp - val yInDp: Float = y?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastYInDp - val widthInDp: Float = - width?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastWidthInDp - val heightInDp: Float = - height?.let { PixelUtil.toDIPFromPixel(it.toFloat()) } ?: lastHeightInDp - - // Check incoming state values. If they're already the correct value, return early to prevent - // infinite UpdateState/SetState loop. - if ( - abs(lastXInDp - xInDp) < DELTA && - abs(lastYInDp - yInDp) < DELTA && - abs(lastWidthInDp - widthInDp) < DELTA && - abs(lastHeightInDp - heightInDp) < DELTA - ) { - return - } - - lastXInDp = xInDp - lastYInDp = yInDp - lastWidthInDp = widthInDp - lastHeightInDp = heightInDp - - val map: WritableMap = - WritableNativeMap().apply { - putDouble("frameWidth", widthInDp.toDouble()) - putDouble("frameHeight", heightInDp.toDouble()) - putDouble("contentOffsetX", xInDp.toDouble()) - putDouble("contentOffsetY", yInDp.toDouble()) - } - stateWrapper?.updateState(map) - } - - companion object { - private const val DELTA = 0.9f - } -} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt deleted file mode 100644 index 2353577678..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenAppBarLayout.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.screen.header - -import android.annotation.SuppressLint -import android.content.Context -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.R -import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED -import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL -import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL -import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP -import com.google.android.material.appbar.CollapsingToolbarLayout -import com.google.android.material.appbar.MaterialToolbar -import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType -import com.swmansion.rnscreens.utils.resolveDimensionAttr - -internal sealed class StackScreenAppBarLayout( - context: Context, -) : AppBarLayout(context) { - abstract val toolbar: MaterialToolbar - - init { - layoutParams = CoordinatorLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - - // TODO: this should be exposed in the future via prop. Also, it might not work correctly - // until we set liftOnScrollView manually. Also, we should disable it in transparent - // mode or set elevation higher. - isLiftOnScroll = true - - // TODO: this won't work with nested header but there were some problems with lift on scroll - // without it when I was researching this. - fitsSystemWindows = true - } - - internal class Small( - context: Context, - ) : StackScreenAppBarLayout(context) { - override val toolbar = - MaterialToolbar(context).apply { - elevation = 0f - layoutParams = - LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { - // TODO: debug only for small header, must be moved to configuration -// scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_SNAP - scrollFlags = SCROLL_FLAG_NO_SCROLL - } - } - - init { - addView(toolbar) - } - } - - @SuppressLint("ViewConstructor") - internal class Collapsing( - context: Context, - val type: StackScreenHeaderType, - ) : StackScreenAppBarLayout(context) { - init { - require( - type == StackScreenHeaderType.MEDIUM || - type == StackScreenHeaderType.LARGE, - ) { - "[RNScreens] Collapsing StackScreenAppBarLayout must be MEDIUM or LARGE type." - } - } - - override val toolbar = - MaterialToolbar(context).apply { - elevation = 0f - layoutParams = - CollapsingToolbarLayout - .LayoutParams( - MATCH_PARENT, - resolveDimensionAttr(context, android.R.attr.actionBarSize), - ).apply { - collapseMode = CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PIN - } - } - - val collapsingToolbarLayout: CollapsingToolbarLayout = - run { - val (styleAttr, sizeAttr) = - when (type) { - StackScreenHeaderType.MEDIUM -> - Pair(R.attr.collapsingToolbarLayoutMediumStyle, R.attr.collapsingToolbarLayoutMediumSize) - StackScreenHeaderType.LARGE -> - Pair(R.attr.collapsingToolbarLayoutLargeStyle, R.attr.collapsingToolbarLayoutLargeSize) - else -> error("[RNScreens] Invalid header mode.") - } - CollapsingToolbarLayout(context, null, styleAttr).apply { - fitsSystemWindows = false - layoutParams = - LayoutParams( - MATCH_PARENT, - resolveDimensionAttr(context, sizeAttr), - ).apply { - // TODO: debug only for medium/large header, must be moved to configuration - scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED or SCROLL_FLAG_SNAP -// scrollFlags = SCROLL_FLAG_NO_SCROLL - } - addView(toolbar) - } - } - - init { - addView(collapsingToolbarLayout) - } - } - - companion object { - fun create( - context: Context, - type: StackScreenHeaderType, - ): StackScreenAppBarLayout = - when (type) { - StackScreenHeaderType.SMALL -> Small(context) - StackScreenHeaderType.MEDIUM, StackScreenHeaderType.LARGE -> Collapsing(context, type) - } - } -} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt deleted file mode 100644 index 810f10af34..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.screen.header - -import android.annotation.SuppressLint -import android.content.Context -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.widget.FrameLayout -import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.swmansion.rnscreens.gamma.stack.host.StackContainer -import com.swmansion.rnscreens.gamma.stack.screen.StackScreen -import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding -import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType - -@SuppressLint("ViewConstructor") -internal class StackScreenCoordinatorLayout( - context: Context, - internal val stackScreen: StackScreen, -) : CoordinatorLayout(context) { - private val headerCoordinator = - StackScreenHeaderCoordinator(context) { headerHeight -> - stackScreen.updateStateIfNeeded(y = headerHeight) - } - - internal var stackScreenWrapper: FrameLayout - - init { - // Needed when Transition API is in use to ensure that shadows do not disappear, - // views do not jump around the screen and whole subtree is animated as a whole. - isTransitionGroup = true - - // Due to how we're synchronizing native & Yoga layout (via contentOriginOffset on - // StackScreen), we can't use StackScreen directly as a child of CoordinatorLayout because - // SurfaceMountingManager will override Y offset (that depends on the header height) with - // Y=0. If we wrap StackScreen in another view, as Y is relative to parent view, value set - // by Yoga will be correct. - stackScreenWrapper = FrameLayout(context).apply { addView(stackScreen) } - addView( - stackScreenWrapper, - LayoutParams(MATCH_PARENT, MATCH_PARENT), - ) - - // TODO: debug-only, this will be sent in reaction to information from "HeaderConfig" component. - applyHeaderConfiguration( - object : StackScreenHeaderConfigurationProviding { - override val headerType = StackScreenHeaderType.LARGE - override val title = "Hello, World!" - override val isHidden = false - override val isTransparent = false - }, - ) - - // TODO: debug only, until we expose props via JS -// postDelayed({ -// applyHeaderConfiguration( -// object : StackScreenHeaderConfigurationProviding { -// override val headerType = StackScreenHeaderType.LARGE -// override val title = "Hello, World!" -// override val isHidden = true -// override val isTransparent = false -// }, -// ) -// -// postDelayed({ -// applyHeaderConfiguration( -// object : StackScreenHeaderConfigurationProviding { -// override val headerType = StackScreenHeaderType.LARGE -// override val title = "Hello, World!" -// override val isHidden = false -// override val isTransparent = false -// }, -// ) -// }, 3000) -// }, 3000) - } - - /** - * Will crash in case parent is not StackContainer. - */ - private fun stackContainerOrNull(): StackContainer? = this.parent as StackContainer? - - // TODO: do we need to rely on parent here? - internal fun maybeRequestLayoutContainer() { - post { - stackContainerOrNull()?.forceSubtreeMeasureAndLayoutPass() - } - } - - internal fun applyHeaderConfiguration(headerConfigurationProviding: StackScreenHeaderConfigurationProviding) = - headerCoordinator.applyHeaderConfiguration(this, headerConfigurationProviding) -} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt deleted file mode 100644 index 27bf662605..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.screen.header - -import android.content.Context -import androidx.appcompat.view.ContextThemeWrapper -import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.R -import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderConfigurationProviding -import com.swmansion.rnscreens.gamma.stack.screen.header.configuration.StackScreenHeaderType - -internal class StackScreenHeaderCoordinator( - context: Context, - private val onHeaderHeightChanged: (headerHeight: Int) -> Unit, -) { - private var appBarLayout: StackScreenAppBarLayout? = null - private var currentHeaderType: StackScreenHeaderType? = null - - private val wrappedContext = - ContextThemeWrapper( - context, - R.style.Theme_Material3_DayNight_NoActionBar, - ) - - internal fun applyHeaderConfiguration( - coordinatorLayout: StackScreenCoordinatorLayout, - config: StackScreenHeaderConfigurationProviding, - ) { - applyStructure(coordinatorLayout, config) - applyAppBarConfiguration(config) - applyContentBehavior(coordinatorLayout, config) - coordinatorLayout.maybeRequestLayoutContainer() - } - - private fun applyStructure( - coordinatorLayout: StackScreenCoordinatorLayout, - config: StackScreenHeaderConfigurationProviding, - ) { - val desiredType = if (config.isHidden) null else config.headerType - - if (desiredType == currentHeaderType) return - - appBarLayout?.let { coordinatorLayout.removeView(it) } - appBarLayout = - desiredType?.let { - StackScreenAppBarLayout.create(wrappedContext, it).also { appBar -> - coordinatorLayout.addView(appBar, 0) - } - } - - currentHeaderType = desiredType - } - - private fun applyAppBarConfiguration(config: StackScreenHeaderConfigurationProviding) { - appBarLayout?.let { appBar -> - applyTitle(appBar, config.title) - // TODO: other app bar configuration... - } - } - - private fun applyTitle( - appBarLayout: StackScreenAppBarLayout, - title: String, - ) { - // TODO: diffing mechanism? - when (appBarLayout) { - is StackScreenAppBarLayout.Small -> { - appBarLayout.toolbar.title = title - } - is StackScreenAppBarLayout.Collapsing -> { - appBarLayout.toolbar.title = null - appBarLayout.collapsingToolbarLayout.title = title - } - } - } - - private fun applyContentBehavior( - coordinatorLayout: StackScreenCoordinatorLayout, - config: StackScreenHeaderConfigurationProviding, - ) { - val stackScreenWrapper = coordinatorLayout.stackScreenWrapper - val params = stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams - val needsBehavior = appBarLayout != null && !config.isTransparent && !config.isHidden - val hasBehavior = params.behavior != null - if (needsBehavior != hasBehavior) { - params.behavior = - if (needsBehavior) { - StackScreenScrollingViewBehavior(onHeaderHeightChanged) - } else { - onHeaderHeightChanged(0) - null - } - stackScreenWrapper.layoutParams = params - } - } -} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt deleted file mode 100644 index b0accd1f16..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenScrollingViewBehavior.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.screen.header - -import android.view.View -import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.appbar.AppBarLayout - -internal class StackScreenScrollingViewBehavior( - private val onHeaderHeightChanged: (headerHeight: Int) -> Unit, -) : AppBarLayout.ScrollingViewBehavior() { - override fun onDependentViewChanged( - parent: CoordinatorLayout, - child: View, - dependency: View, - ): Boolean { - val result = super.onDependentViewChanged(parent, child, dependency) - onHeaderHeightChanged(child.top) - return result - } -} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt deleted file mode 100644 index 32c2a1c73c..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderConfigurationProviding.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.screen.header.configuration - -internal interface StackScreenHeaderConfigurationProviding { - val headerType: StackScreenHeaderType - val title: String - val isHidden: Boolean - val isTransparent: Boolean -} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt deleted file mode 100644 index fbaa11873f..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/configuration/StackScreenHeaderType.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.screen.header.configuration - -internal enum class StackScreenHeaderType { - SMALL, - MEDIUM, - LARGE, -} From 433face15451fc42b406802b1e31dbfb4be4bf18 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 8 Apr 2026 13:32:46 +0200 Subject: [PATCH 70/92] add comments about the order of subviews --- .../rnscreens/gamma/stack/header/config/StackHeaderConfig.kt | 1 + .../gamma/stack/header/StackHeaderConfig.android.tsx | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index 3076d74058..1bad645f35 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -97,6 +97,7 @@ class StackHeaderConfig( internal val configSubviewsCount: Int get() = listOfNotNull(backgroundSubview, leadingSubview, centerSubview, trailingSubview).size + // The order of the subviews MUST match the order of JS StackHeaderConfig children. internal fun getConfigSubviewAt(index: Int): StackHeaderSubview? = listOfNotNull(backgroundSubview, leadingSubview, centerSubview, trailingSubview).getOrNull(index) } diff --git a/src/components/gamma/stack/header/StackHeaderConfig.android.tsx b/src/components/gamma/stack/header/StackHeaderConfig.android.tsx index ff6d018bf2..bae9933a58 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.android.tsx +++ b/src/components/gamma/stack/header/StackHeaderConfig.android.tsx @@ -26,6 +26,10 @@ function StackHeaderConfig(props: StackHeaderConfigProps) { style={StyleSheet.absoluteFill} {...baseProps} {...filteredAndroidProps}> + {/* + Please note that the order of the subviews MUST match + the order in native StackHeaderConfig.getConfigSubviewAt. + */} {backgroundSubview && ( Date: Wed, 8 Apr 2026 14:22:19 +0200 Subject: [PATCH 71/92] remove old comment, update class name in error message, make StackHeaderSubviewProviding internal --- .../rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt | 2 +- .../gamma/stack/header/StackHeaderCoordinatorLayout.kt | 1 - .../gamma/stack/header/subview/StackHeaderSubviewProviding.kt | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt index 6a373aebc7..24c438f012 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt @@ -98,7 +98,7 @@ internal sealed class StackHeaderAppBarLayout( type == StackHeaderType.MEDIUM || type == StackHeaderType.LARGE, ) { - "[RNScreens] Collapsing StackScreenAppBarLayout must be MEDIUM or LARGE type." + "[RNScreens] Collapsing StackHeaderAppBarLayout must be MEDIUM or LARGE type." } addView(collapsingToolbarLayout) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index 237e778926..24ef6b0e2d 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -75,7 +75,6 @@ internal class StackHeaderCoordinatorLayout( } internal fun maybeRequestLayoutContainer() { - // TODO: do we need to rely on parent here? post { stackContainerOrNull()?.forceSubtreeMeasureAndLayoutPass() } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt index 02bccf151d..d471f21acc 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt @@ -2,7 +2,7 @@ package com.swmansion.rnscreens.gamma.stack.header.subview import android.view.View -interface StackHeaderSubviewProviding { +internal interface StackHeaderSubviewProviding { val type: StackHeaderSubviewType val collapseMode: StackHeaderSubviewCollapseMode val view: View From 71dce2d7a4cdfbd852d073592791015c7511fd8b Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 8 Apr 2026 14:24:51 +0200 Subject: [PATCH 72/92] add comment explaining why we call requestApplyInsets --- .../rnscreens/gamma/stack/header/StackHeaderCoordinator.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 9d88261d9b..f44ef1509f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -134,7 +134,9 @@ internal class StackHeaderCoordinator( setContentBehavior(coordinatorLayout) } + // Make sure that we receive insets, necessary when changing header mode in runtime. appBar.requestApplyInsets() + maybeApplyRtlCollapsingToolbarLayoutWorkaround(coordinatorLayout, config, appBar) populateAppBar(appBar, config) } else { From 95924c180f335a584a05ebbceb55166ef0061f7a Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 8 Apr 2026 14:28:35 +0200 Subject: [PATCH 73/92] adjust delta for ShadowStateProxy --- .../com/swmansion/rnscreens/gamma/common/ShadowStateProxy.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/common/ShadowStateProxy.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/common/ShadowStateProxy.kt index 7f762b0e13..0f07a0d794 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/common/ShadowStateProxy.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/common/ShadowStateProxy.kt @@ -53,6 +53,6 @@ internal class ShadowStateProxy( } companion object { - private const val DELTA = 0.9f + private const val DELTA = 0.1f } } From 1d0b7ed19e9661f49e9dbe37473eadf0c510eb85 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 8 Apr 2026 18:11:52 +0200 Subject: [PATCH 74/92] refactor layout flow part 1, fix rtl workaround for collapsing header --- .../stack/header/StackHeaderCoordinator.kt | 74 +++++-------------- .../header/StackHeaderCoordinatorLayout.kt | 6 -- .../stack/header/config/StackHeaderConfig.kt | 12 +++ .../config/StackHeaderConfigViewManager.kt | 6 ++ .../header/subview/StackHeaderSubview.kt | 36 ++++++++- .../gamma/stack/host/StackContainer.kt | 9 --- .../rnscreens/gamma/stack/host/StackHost.kt | 27 +++++++ 7 files changed, 96 insertions(+), 74 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index f44ef1509f..16965735f2 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -44,9 +44,6 @@ internal class StackHeaderCoordinator( private var attachedCenterSubview: StackHeaderSubviewProviding? = null private var attachedTrailingSubview: StackHeaderSubviewProviding? = null private var attachedBackgroundSubview: StackHeaderSubviewProviding? = null - private var lastLeadingSubviewSize: Pair? = null - private var lastCenterSubviewSize: Pair? = null - private var lastTrailingSubviewSize: Pair? = null private var lastBackgroundSubviewCollapseMode: StackHeaderSubviewCollapseMode? = null // For small header, we need to use custom title view in order to @@ -68,8 +65,9 @@ internal class StackHeaderCoordinator( removeHeader(coordinatorLayout) } + // TODO: move to specific places if (shouldRequestLayout) { - coordinatorLayout.maybeRequestLayoutContainer() + appBarLayout?.toolbar?.requestLayout() shouldRequestLayout = false } } @@ -101,10 +99,6 @@ internal class StackHeaderCoordinator( if (config.trailingSubview !== attachedTrailingSubview) return true if (config.backgroundSubview !== attachedBackgroundSubview) return true - if (config.leadingSubview?.viewSize != lastLeadingSubviewSize) return true - if (config.centerSubview?.viewSize != lastCenterSubviewSize) return true - if (config.trailingSubview?.viewSize != lastTrailingSubviewSize) return true - if (appBarLayout is StackHeaderAppBarLayout.Collapsing) { if (config.backgroundSubview?.collapseMode != lastBackgroundSubviewCollapseMode) return true } @@ -137,8 +131,8 @@ internal class StackHeaderCoordinator( // Make sure that we receive insets, necessary when changing header mode in runtime. appBar.requestApplyInsets() - maybeApplyRtlCollapsingToolbarLayoutWorkaround(coordinatorLayout, config, appBar) populateAppBar(appBar, config) + maybeApplyRtlCollapsingToolbarLayoutWorkaround(coordinatorLayout, config, appBar) } else { removeContentBehavior(coordinatorLayout) } @@ -163,9 +157,6 @@ internal class StackHeaderCoordinator( attachedCenterSubview = config.centerSubview attachedTrailingSubview = config.trailingSubview attachedBackgroundSubview = config.backgroundSubview - lastLeadingSubviewSize = config.leadingSubview?.viewSize - lastCenterSubviewSize = config.centerSubview?.viewSize - lastTrailingSubviewSize = config.trailingSubview?.viewSize lastBackgroundSubviewCollapseMode = config.backgroundSubview?.collapseMode } @@ -177,58 +168,27 @@ internal class StackHeaderCoordinator( attachedCenterSubview = null attachedTrailingSubview = null attachedBackgroundSubview = null - lastLeadingSubviewSize = null - lastCenterSubviewSize = null - lastTrailingSubviewSize = null lastBackgroundSubviewCollapseMode = null } private fun detachSubviews() { val appBar = appBarLayout ?: return - attachedLeadingSubview?.let { unwrapAndRemoveFrom(it, appBar.toolbar) } - attachedCenterSubview?.let { unwrapAndRemoveFrom(it, appBar.toolbar) } - attachedTrailingSubview?.let { unwrapAndRemoveFrom(it, appBar.toolbar) } + attachedLeadingSubview?.let { appBar.toolbar.removeView(it.view) } + attachedCenterSubview?.let { appBar.toolbar.removeView(it.view) } + attachedTrailingSubview?.let { appBar.toolbar.removeView(it.view) } if (appBar is StackHeaderAppBarLayout.Collapsing) { attachedBackgroundSubview?.let { - unwrapAndRemoveFrom(it, appBar.collapsingToolbarLayout) + val wrapper = it.view.parent as? FrameLayout ?: return + wrapper.removeView(it.view) + appBar.collapsingToolbarLayout.removeView(wrapper) } } } // endregion - // region Subview wrapping - // - // All subviews are wrapped in a FrameLayout before being added to the - // toolbar or collapsing toolbar layout. This ensures the React view has - // a relative offset of (0,0) within its native parent, matching what - // Yoga expects (it always thinks views are at origin). - - private fun wrapSubview( - subview: StackHeaderSubviewProviding, - context: Context, - wrapperWidth: Int = WRAP_CONTENT, - wrapperHeight: Int = WRAP_CONTENT, - ): FrameLayout { - subview.view.detachFromCurrentParent() - return FrameLayout(context).apply { - addView(subview.view, FrameLayout.LayoutParams(wrapperWidth, wrapperHeight)) - } - } - - private fun unwrapAndRemoveFrom( - subview: StackHeaderSubviewProviding, - parent: android.view.ViewGroup, - ) { - val wrapper = subview.view.parent as? FrameLayout ?: return - wrapper.removeView(subview.view) - parent.removeView(wrapper) - } - - // endregion - // region App bar population private fun populateAppBar( @@ -240,13 +200,13 @@ internal class StackHeaderCoordinator( // Toolbar measures children in insertion order. Leading and trailing go first so the // title/center gets the remaining space. config.leadingSubview?.let { - val wrapper = wrapSubview(it, toolbar.context) - toolbar.addView(wrapper, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.START)) + it.view.detachFromCurrentParent() + toolbar.addView(it.view, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.START)) } config.trailingSubview?.let { - val wrapper = wrapSubview(it, toolbar.context) - toolbar.addView(wrapper, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.END)) + it.view.detachFromCurrentParent() + toolbar.addView(it.view, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.END)) } populateTitleOrCenter(appBar, toolbar, config) @@ -264,8 +224,8 @@ internal class StackHeaderCoordinator( toolbar.removeView(managedTitleView) managedTitleView = null - val wrapper = wrapSubview(centerSubview, toolbar.context) - toolbar.addView(wrapper, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.CENTER_HORIZONTAL)) + centerSubview.view.detachFromCurrentParent() + toolbar.addView(centerSubview.view, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.CENTER_HORIZONTAL)) } else { Log.e(TAG, "[RNScreens] Center subview is supported only for small header type.") } @@ -293,10 +253,12 @@ internal class StackHeaderCoordinator( // attaches to the disposable wrapper, not the reused React view. This avoids // stale parallax offsets persisting across collapse mode rebuilds therefore allowing // runtime changes to this property. + backgroundSubview.view.detachFromCurrentParent() val wrapper = - wrapSubview(backgroundSubview, appBar.context, MATCH_PARENT, MATCH_PARENT).apply { + FrameLayout(appBar.context).apply { // We're setting `fitsSystemWindows` so that the background renders behind status bar (edge-to-edge). fitsSystemWindows = true + addView(backgroundSubview.view, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)) } appBar.collapsingToolbarLayout.addView( diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index 24ef6b0e2d..bf11228a66 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -74,12 +74,6 @@ internal class StackHeaderCoordinatorLayout( handleHeaderConfigAttach(stackScreen.headerConfig) } - internal fun maybeRequestLayoutContainer() { - post { - stackContainerOrNull()?.forceSubtreeMeasureAndLayoutPass() - } - } - private fun handleHeaderConfigAttach(config: StackHeaderConfig?) { // Disconnect old config to prevent spurious updates from a detached config currentConfig?.onConfigChangeListener = null diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index 1bad645f35..2eb6cdb498 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.util.LayoutDirection import com.facebook.react.bridge.ReactContext import com.facebook.react.views.view.ReactViewGroup +import com.swmansion.rnscreens.ext.parentAsView import com.swmansion.rnscreens.gamma.common.ShadowStateProxy import com.swmansion.rnscreens.gamma.stack.header.subview.OnStackHeaderSubviewChangeListener import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview @@ -100,4 +101,15 @@ class StackHeaderConfig( // The order of the subviews MUST match the order of JS StackHeaderConfig children. internal fun getConfigSubviewAt(index: Int): StackHeaderSubview? = listOfNotNull(backgroundSubview, leadingSubview, centerSubview, trailingSubview).getOrNull(index) + + override fun requestLayout() { + // This super is called to avoid a warning but ReactViewGroup.requestLayout is a no-op. + super.requestLayout() + + // Invalidate layout flags. + forceLayout() + + // Rely on parent to request the layout. + parentAsView()?.requestLayout() + } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt index 36ad0750d5..51932f6694 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt @@ -28,6 +28,12 @@ open class StackHeaderConfigViewManager : override fun getDelegate(): ViewManagerDelegate = delegate + /** + * Subviews need to be positioned by native layout from Toolbar and CollapsingToolbarLayout. + * Even with this option enabled, we receive dimensions calculated by Yoga via onMeasure. + */ + override fun needsCustomLayoutForChildren() = true + override fun addView( parent: StackHeaderConfig, child: View, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt index de95070764..49f3b40c9b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -3,6 +3,7 @@ package com.swmansion.rnscreens.gamma.stack.header.subview import android.annotation.SuppressLint import com.facebook.react.bridge.ReactContext import com.facebook.react.views.view.ReactViewGroup +import com.swmansion.rnscreens.ext.parentAsView import com.swmansion.rnscreens.gamma.common.ShadowStateProxy import java.lang.ref.WeakReference import kotlin.properties.Delegates @@ -39,6 +40,9 @@ class StackHeaderSubview( internal var onStackHeaderSubviewChangeListener: WeakReference? = null + private var yogaWidth: Int = 0 + private var yogaHeight: Int = 0 + private var lastNotifiedSize: Pair? = null override fun onLayout( @@ -48,7 +52,6 @@ class StackHeaderSubview( right: Int, bottom: Int, ) { - super.onLayout(changed, left, top, right, bottom) val newSize = (right - left) to (bottom - top) if (lastNotifiedSize != newSize) { lastNotifiedSize = newSize @@ -61,10 +64,37 @@ class StackHeaderSubview( widthMeasureSpec: Int, heightMeasureSpec: Int, ) { - if (width > 0 && height > 0) { - setMeasuredDimension(width, height) + var invalidated = false + + // SurfaceMountingManager always delivers Yoga dimensions as EXACTLY specs. + // Cache them so we can report the correct size when the Toolbar remeasures us. + if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) { + yogaWidth = MeasureSpec.getSize(widthMeasureSpec) + invalidated = true + } + if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) { + yogaHeight = MeasureSpec.getSize(heightMeasureSpec) + invalidated = true + } + + if (yogaWidth > 0 && yogaHeight > 0) { + setMeasuredDimension(yogaWidth, yogaHeight) + if (invalidated) { + requestLayout() + } } else { super.onMeasure(widthMeasureSpec, heightMeasureSpec) } } + + override fun requestLayout() { + // This super is called to avoid a warning but ReactViewGroup.requestLayout is a no-op. + super.requestLayout() + + // Invalidate layout flags. + forceLayout() + + // Rely on parent to request the layout. + parentAsView()?.requestLayout() + } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt index eae017288d..3f1b1de8d6 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt @@ -251,15 +251,6 @@ internal class StackContainer( } } - internal fun forceSubtreeMeasureAndLayoutPass() { - measure( - MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), - ) - - layout(left, top, right, bottom) - } - companion object { const val TAG = "StackContainer" } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackHost.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackHost.kt index 1eb9d965b4..d925b41dc2 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackHost.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackHost.kt @@ -24,6 +24,7 @@ class StackHost( internal val renderedScreens: ArrayList = arrayListOf() private val container = StackContainer(reactContext, WeakReference(this)) private val containerUpdateCoordinator = StackContainerUpdateCoordinator() + private var isLayoutEnqueued = false init { addView(container) @@ -113,6 +114,32 @@ class StackHost( container.layout(l, t, r, b) } + // ReactViewGroup overrides requestLayout to a no-op therefore the request might not be + // propagated to the root view. That's why we need to manually force measure and layout pass. + override fun requestLayout() { + super.requestLayout() + refreshLayout() + } + + private fun refreshLayout() { + if (!isLayoutEnqueued) { + isLayoutEnqueued = true + post { + isLayoutEnqueued = false + forceSubtreeMeasureAndLayoutPass() + } + } + } + + private fun forceSubtreeMeasureAndLayoutPass() { + measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), + ) + + layout(left, top, right, bottom) + } + override fun layoutContainerNow() { if (measuredWidth != container.measuredWidth || measuredHeight != container.measuredHeight) { container.measure( From 5a1bff0a0f303a15b1579eee5d040c1f12dc2b64 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 8 Apr 2026 18:25:13 +0200 Subject: [PATCH 75/92] fix layout for RTL in small header; use Arabic titles in RTL Unfortunately, the text is not ellipsized correctly when subviews are used. --- .../gamma/stack/header/StackHeaderCoordinator.kt | 3 ++- .../stack-v5/test-stack-subviews.tsx | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 16965735f2..03c22eae66 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -234,7 +234,8 @@ internal class StackHeaderCoordinator( // Toolbar's native title - it would be laid out to the leading side of leading subview. val titleView = createManagedTitleView(toolbar) managedTitleView = titleView - toolbar.addView(titleView) + val index = if (config.isRtl) 0 else -1 + toolbar.addView(titleView, index, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.START)) } } diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx index a288b7a8c0..bf240c9ea8 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Image, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { I18nManager, Image, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Scenario } from '../../shared/helpers'; import { StackContainer, @@ -25,9 +25,10 @@ const SCENARIO: Scenario = { export default SCENARIO; -const SHORT_TITLE = 'Hello'; -const LONG_TITLE = - 'A Very Long Title That Should Ellipsize When There Is Not Enough Space Available'; +const SHORT_TITLE = I18nManager.isRTL ? 'مرحبا' : 'Hello'; +const LONG_TITLE = I18nManager.isRTL + ? 'عنوان طويل جدا يجب أن يتم اقتطاعه عندما لا تتوفر مساحة كافية لعرضه بالكامل' + : 'A Very Long Title That Should Ellipsize When There Is Not Enough Space Available'; type SubviewSize = 'none' | 'sm' | 'md' | 'lg'; type HitSlopValue = '0' | '10' | '30'; From 5fc00c36de1219036d29ae0a019f942bdfe27918 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 8 Apr 2026 18:40:00 +0200 Subject: [PATCH 76/92] minor refactors --- .../stack/header/StackHeaderCoordinator.kt | 5 +---- .../stack/header/config/StackHeaderConfig.kt | 11 ----------- .../stack/header/subview/StackHeaderSubview.kt | 18 +++++++++++++----- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 03c22eae66..89ce4204e9 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -180,7 +180,7 @@ internal class StackHeaderCoordinator( if (appBar is StackHeaderAppBarLayout.Collapsing) { attachedBackgroundSubview?.let { - val wrapper = it.view.parent as? FrameLayout ?: return + val wrapper = it.view.parent as? FrameLayout ?: return@let wrapper.removeView(it.view) appBar.collapsingToolbarLayout.removeView(wrapper) } @@ -460,6 +460,3 @@ internal class StackHeaderCoordinator( private const val TAG = "StackHeaderCoordinator" } } - -private val StackHeaderSubviewProviding.viewSize: Pair - get() = view.width to view.height diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index 2eb6cdb498..72c3462f91 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -101,15 +101,4 @@ class StackHeaderConfig( // The order of the subviews MUST match the order of JS StackHeaderConfig children. internal fun getConfigSubviewAt(index: Int): StackHeaderSubview? = listOfNotNull(backgroundSubview, leadingSubview, centerSubview, trailingSubview).getOrNull(index) - - override fun requestLayout() { - // This super is called to avoid a warning but ReactViewGroup.requestLayout is a no-op. - super.requestLayout() - - // Invalidate layout flags. - forceLayout() - - // Rely on parent to request the layout. - parentAsView()?.requestLayout() - } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt index 49f3b40c9b..e4ca8f0973 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -52,6 +52,7 @@ class StackHeaderSubview( right: Int, bottom: Int, ) { + // We don't call super.onLayout here because ReactViewGroup.onLayout is a no-op. val newSize = (right - left) to (bottom - top) if (lastNotifiedSize != newSize) { lastNotifiedSize = newSize @@ -69,17 +70,24 @@ class StackHeaderSubview( // SurfaceMountingManager always delivers Yoga dimensions as EXACTLY specs. // Cache them so we can report the correct size when the Toolbar remeasures us. if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) { - yogaWidth = MeasureSpec.getSize(widthMeasureSpec) - invalidated = true + val newWidth = MeasureSpec.getSize(widthMeasureSpec) + if (newWidth != yogaWidth) { + yogaWidth = newWidth + invalidated = true + } } + if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) { - yogaHeight = MeasureSpec.getSize(heightMeasureSpec) - invalidated = true + val newHeight = MeasureSpec.getSize(heightMeasureSpec) + if (newHeight != yogaHeight) { + yogaHeight = newHeight + invalidated = true + } } if (yogaWidth > 0 && yogaHeight > 0) { setMeasuredDimension(yogaWidth, yogaHeight) - if (invalidated) { + if (invalidated && !isInLayout) { requestLayout() } } else { From 9434a9a69e06e600a33c20e2e2f84136a3934424 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 8 Apr 2026 18:50:10 +0200 Subject: [PATCH 77/92] use requestLayout instead of flag --- .../stack/header/StackHeaderCoordinator.kt | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 89ce4204e9..9abd7e6f6c 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -50,26 +50,16 @@ internal class StackHeaderCoordinator( // render a subview to the leading side of the title. private var managedTitleView: AppCompatTextView? = null - private var shouldRequestLayout = false - internal fun applyHeaderConfig( coordinatorLayout: StackHeaderCoordinatorLayout, config: StackHeaderConfigProviding?, ) { - shouldRequestLayout = false - currentConfig = config if (config != null) { updateHeader(coordinatorLayout, config) } else { removeHeader(coordinatorLayout) } - - // TODO: move to specific places - if (shouldRequestLayout) { - appBarLayout?.toolbar?.requestLayout() - shouldRequestLayout = false - } } private fun updateHeader( @@ -85,7 +75,7 @@ internal class StackHeaderCoordinator( private fun removeHeader(coordinatorLayout: StackHeaderCoordinatorLayout) { teardown(coordinatorLayout) removeContentBehavior(coordinatorLayout) - shouldRequestLayout = true + coordinatorLayout.requestLayout() } // region Rebuild detection @@ -133,12 +123,13 @@ internal class StackHeaderCoordinator( populateAppBar(appBar, config) maybeApplyRtlCollapsingToolbarLayoutWorkaround(coordinatorLayout, config, appBar) + appBar.toolbar.requestLayout() } else { removeContentBehavior(coordinatorLayout) + coordinatorLayout.requestLayout() } cacheRebuildTriggers(config) - shouldRequestLayout = true } private fun teardown(coordinatorLayout: StackHeaderCoordinatorLayout) { @@ -306,9 +297,7 @@ internal class StackHeaderCoordinator( when (appBar) { is StackHeaderAppBarLayout.Small -> { managedTitleView?.text = config.title - - // Changing small title requires layout - shouldRequestLayout = true + managedTitleView?.requestLayout() } is StackHeaderAppBarLayout.Collapsing -> { @@ -341,7 +330,7 @@ internal class StackHeaderCoordinator( updateShadowState(contentTop, dependency) } coordinatorLayout.stackScreenWrapper.layoutParams = params - shouldRequestLayout = true + coordinatorLayout.stackScreenWrapper.requestLayout() } } @@ -351,7 +340,7 @@ internal class StackHeaderCoordinator( params.behavior = null coordinatorLayout.stackScreenWrapper.layoutParams = params onHeaderHeightChanged(0) - shouldRequestLayout = true + coordinatorLayout.stackScreenWrapper.requestLayout() } } @@ -447,6 +436,8 @@ internal class StackHeaderCoordinator( private fun moveDummyViewToFront(toolbar: Toolbar) { for (i in 0 until toolbar.childCount) { val child = toolbar.getChildAt(i) + // Assumes only StackHeaderSubview children exist in Collapsing toolbar besides + // the CTL dummy view. if (child !is StackHeaderSubview) { val lp = child.layoutParams toolbar.removeViewAt(i) From 9ea1b70d1c3c820e4459519b1eab51393577aabb Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 8 Apr 2026 19:38:13 +0200 Subject: [PATCH 78/92] remove unused method --- .../gamma/stack/header/StackHeaderCoordinatorLayout.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index bf11228a66..2d393aad8f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -8,7 +8,6 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import com.swmansion.rnscreens.gamma.stack.header.config.OnHeaderConfigAttachListener import com.swmansion.rnscreens.gamma.stack.header.config.OnHeaderConfigChangeListener import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfig -import com.swmansion.rnscreens.gamma.stack.host.StackContainer import com.swmansion.rnscreens.gamma.stack.screen.StackScreen import java.lang.ref.WeakReference @@ -84,9 +83,4 @@ internal class StackHeaderCoordinatorLayout( } headerCoordinator.applyHeaderConfig(this, config) } - - /** - * Will crash in case parent is not StackContainer. - */ - private fun stackContainerOrNull(): StackContainer? = this.parent as StackContainer? } From 4ea12e35f5cebf7fac47961262fd17660801fc55 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 9 Apr 2026 17:38:04 +0200 Subject: [PATCH 79/92] fix shadow tree sync for transparent header, use abstraction in listeners where possible --- .../stack/header/StackHeaderCoordinator.kt | 63 +++++++++++++------ .../header/StackHeaderCoordinatorLayout.kt | 8 ++- .../config/OnHeaderConfigAttachListener.kt | 2 +- .../config/OnHeaderConfigChangeListener.kt | 2 +- .../stack/header/config/StackHeaderConfig.kt | 2 +- .../config/StackHeaderConfigProviding.kt | 5 +- .../header/subview/StackHeaderSubview.kt | 17 ----- .../subview/StackHeaderSubviewProviding.kt | 2 +- 8 files changed, 59 insertions(+), 42 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 9abd7e6f6c..6619f85b22 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -14,6 +14,7 @@ import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.widget.TextViewCompat import com.google.android.material.R +import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.CollapsingToolbarLayout import com.swmansion.rnscreens.ext.detachFromCurrentParent import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigProviding @@ -120,6 +121,7 @@ internal class StackHeaderCoordinator( // Make sure that we receive insets, necessary when changing header mode in runtime. appBar.requestApplyInsets() + attachAppBarListeners(appBar) populateAppBar(appBar, config) maybeApplyRtlCollapsingToolbarLayoutWorkaround(coordinatorLayout, config, appBar) @@ -134,7 +136,10 @@ internal class StackHeaderCoordinator( private fun teardown(coordinatorLayout: StackHeaderCoordinatorLayout) { detachSubviews() - appBarLayout?.let { coordinatorLayout.removeView(it) } + appBarLayout?.let { + detachAppBarListeners(it) + coordinatorLayout.removeView(it) + } appBarLayout = null managedTitleView = null clearCachedRebuildTriggers() @@ -325,9 +330,8 @@ internal class StackHeaderCoordinator( val params = coordinatorLayout.stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams if (params.behavior == null) { params.behavior = - StackHeaderScrollingViewBehavior { contentTop, dependency -> + StackHeaderScrollingViewBehavior { contentTop, _ -> onHeaderHeightChanged(contentTop) - updateShadowState(contentTop, dependency) } coordinatorLayout.stackScreenWrapper.layoutParams = params coordinatorLayout.stackScreenWrapper.requestLayout() @@ -346,32 +350,55 @@ internal class StackHeaderCoordinator( // endregion - // region Shadow state updates (Yoga synchronization) + // region Shadow state synchronization + // + // Shadow state (header frame + subview offsets) must be kept in sync with Yoga. + // For non-transparent headers the ScrollingViewBehavior drives content positioning, + // but shadow state is always driven by these two AppBarLayout listeners which cover + // all change scenarios: + // - OnOffsetChangedListener: fires when the appbar's scroll offset changes + // - OnLayoutChangeListener: fires when the appbar's bounds change (e.g. size change) + + private val appBarOffsetListener = + AppBarLayout.OnOffsetChangedListener { _, _ -> + syncShadowState() + } + + private val appBarLayoutChangeListener = + View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + syncShadowState() + } + + private fun attachAppBarListeners(appBar: StackHeaderAppBarLayout) { + appBar.addOnOffsetChangedListener(appBarOffsetListener) + appBar.addOnLayoutChangeListener(appBarLayoutChangeListener) + } + + private fun detachAppBarListeners(appBar: StackHeaderAppBarLayout) { + appBar.removeOnOffsetChangedListener(appBarOffsetListener) + appBar.removeOnLayoutChangeListener(appBarLayoutChangeListener) + } /** - * Called on every AppBarLayout change (scroll, size, position) via - * [StackHeaderScrollingViewBehavior.onDependentViewChanged]. - * - * @param contentTop Y position of the content area (StackScreen wrapper) in the CoordinatorLayout - * @param dependency the AppBarLayout view + * Synchronizes the header config and subview shadow state with the current + * native layout. Called from both [appBarOffsetListener] and [appBarLayoutChangeListener]. */ - private fun updateShadowState( - contentTop: Int, - dependency: View, - ) { + private fun syncShadowState() { val config = currentConfig ?: return val appBar = appBarLayout ?: return - // For header config we need to: - // - cancel out the StackScreen's Y offset (contentTop), - // - handle AppBarLayout's negative offset when collapsed. + // When config is transparent, the StackScreen is static so we need to offset the header + // config by the offset of the AppBarLayout (which is 0 or is negative). When config is + // opaque, the Screen always moves with the config, that's why we need to offset the header + // config by the negative value of AppBarLayout's height. + val configOffset = if (config.transparent) appBar.top else appBar.top - appBar.bottom + config.updateHeaderFrame( width = appBar.width, height = appBar.height, - contentOffsetY = appBar.top - contentTop, + contentOffsetY = configOffset, ) - // For subviews report position relative to AppBarLayout updateSubviewOffsets(appBar, config) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index 2d393aad8f..e27494d546 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -8,6 +8,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import com.swmansion.rnscreens.gamma.stack.header.config.OnHeaderConfigAttachListener import com.swmansion.rnscreens.gamma.stack.header.config.OnHeaderConfigChangeListener import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfig +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigProviding import com.swmansion.rnscreens.gamma.stack.screen.StackScreen import java.lang.ref.WeakReference @@ -49,7 +50,7 @@ internal class StackHeaderCoordinatorLayout( } } - private var currentConfig: StackHeaderConfig? = null + private var currentConfig: StackHeaderConfigProviding? = null internal var stackScreenWrapper: FrameLayout @@ -73,7 +74,7 @@ internal class StackHeaderCoordinatorLayout( handleHeaderConfigAttach(stackScreen.headerConfig) } - private fun handleHeaderConfigAttach(config: StackHeaderConfig?) { + private fun handleHeaderConfigAttach(config: StackHeaderConfigProviding?) { // Disconnect old config to prevent spurious updates from a detached config currentConfig?.onConfigChangeListener = null currentConfig = config @@ -81,6 +82,9 @@ internal class StackHeaderCoordinatorLayout( if (config != null) { config.onConfigChangeListener = WeakReference(onHeaderConfigChange) } + + // We run this even if config is null to properly remove the header if config + // is removed in runtime. headerCoordinator.applyHeaderConfig(this, config) } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigAttachListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigAttachListener.kt index 3c4d2f1ee3..8454161421 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigAttachListener.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigAttachListener.kt @@ -1,5 +1,5 @@ package com.swmansion.rnscreens.gamma.stack.header.config internal fun interface OnHeaderConfigAttachListener { - fun onHeaderConfigAttach(config: StackHeaderConfig?) + fun onHeaderConfigAttach(config: StackHeaderConfigProviding?) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigChangeListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigChangeListener.kt index 0ca047411d..c597482ea7 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigChangeListener.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigChangeListener.kt @@ -1,5 +1,5 @@ package com.swmansion.rnscreens.gamma.stack.header.config -internal fun interface OnHeaderConfigChangeListener { +fun interface OnHeaderConfigChangeListener { fun onHeaderConfigChange(config: StackHeaderConfigProviding) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index 72c3462f91..c10e04f8a7 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -54,7 +54,7 @@ class StackHeaderConfig( ) } - internal var onConfigChangeListener: WeakReference? = null + override var onConfigChangeListener: WeakReference? = null internal fun notifyConfigChanged() { onConfigChangeListener?.get()?.onHeaderConfigChange(this) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt index 980f217bf2..0b19e0144e 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt @@ -1,8 +1,9 @@ package com.swmansion.rnscreens.gamma.stack.header.config import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewProviding +import java.lang.ref.WeakReference -internal interface StackHeaderConfigProviding { +interface StackHeaderConfigProviding { val type: StackHeaderType val title: String val hidden: Boolean @@ -19,4 +20,6 @@ internal interface StackHeaderConfigProviding { height: Int, contentOffsetY: Int, ) + + var onConfigChangeListener: WeakReference? } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt index e4ca8f0973..22a9c6d16f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -43,23 +43,6 @@ class StackHeaderSubview( private var yogaWidth: Int = 0 private var yogaHeight: Int = 0 - private var lastNotifiedSize: Pair? = null - - override fun onLayout( - changed: Boolean, - left: Int, - top: Int, - right: Int, - bottom: Int, - ) { - // We don't call super.onLayout here because ReactViewGroup.onLayout is a no-op. - val newSize = (right - left) to (bottom - top) - if (lastNotifiedSize != newSize) { - lastNotifiedSize = newSize - onStackHeaderSubviewChangeListener?.get()?.onStackHeaderSubviewChange() - } - } - // Rely on Yoga layout instead of native Toolbar layout which stretches subview to match parent. override fun onMeasure( widthMeasureSpec: Int, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt index d471f21acc..02bccf151d 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt @@ -2,7 +2,7 @@ package com.swmansion.rnscreens.gamma.stack.header.subview import android.view.View -internal interface StackHeaderSubviewProviding { +interface StackHeaderSubviewProviding { val type: StackHeaderSubviewType val collapseMode: StackHeaderSubviewCollapseMode val view: View From 9c81b740497d7028b3c2fb3e7755a41ceeaaf444 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 9 Apr 2026 19:15:44 +0200 Subject: [PATCH 80/92] format android --- .../rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt | 1 - .../rnscreens/gamma/stack/header/config/StackHeaderConfig.kt | 1 - 2 files changed, 2 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index e27494d546..f18d1db5d7 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -7,7 +7,6 @@ import android.widget.FrameLayout import androidx.coordinatorlayout.widget.CoordinatorLayout import com.swmansion.rnscreens.gamma.stack.header.config.OnHeaderConfigAttachListener import com.swmansion.rnscreens.gamma.stack.header.config.OnHeaderConfigChangeListener -import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfig import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigProviding import com.swmansion.rnscreens.gamma.stack.screen.StackScreen import java.lang.ref.WeakReference diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index c10e04f8a7..6e571a996d 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import android.util.LayoutDirection import com.facebook.react.bridge.ReactContext import com.facebook.react.views.view.ReactViewGroup -import com.swmansion.rnscreens.ext.parentAsView import com.swmansion.rnscreens.gamma.common.ShadowStateProxy import com.swmansion.rnscreens.gamma.stack.header.subview.OnStackHeaderSubviewChangeListener import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview From e91dc4479c7d08043fd64b42e29a615bbb095d8e Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Tue, 14 Apr 2026 15:33:03 +0200 Subject: [PATCH 81/92] remove old test, ensure proper test naming --- .../single-feature-tests/stack-v5/index.ts | 2 +- .../stack-v5/test-stack-header-modes.tsx | 63 ------------------- ...ws.tsx => test-stack-subviews-android.tsx} | 17 +++-- 3 files changed, 13 insertions(+), 69 deletions(-) delete mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx rename apps/src/tests/single-feature-tests/stack-v5/{test-stack-subviews.tsx => test-stack-subviews-android.tsx} (96%) diff --git a/apps/src/tests/single-feature-tests/stack-v5/index.ts b/apps/src/tests/single-feature-tests/stack-v5/index.ts index a6e2b19830..fa63f433e5 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/index.ts +++ b/apps/src/tests/single-feature-tests/stack-v5/index.ts @@ -2,7 +2,7 @@ import type { ScenarioGroup } from '@apps/tests/shared/helpers'; import PreventNativeDismissSingleStack from './prevent-native-dismiss-single-stack'; import PreventNativeDismissNestedStack from './prevent-native-dismiss-nested-stack'; import AnimationAndroid from './test-animation-android'; -import TestStackSubviews from './test-stack-subviews'; +import TestStackSubviews from './test-stack-subviews-android'; const scenarios = { PreventNativeDismissSingleStack, diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx deleted file mode 100644 index 06a6f724da..0000000000 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-modes.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import { Scenario } from '../../shared/helpers'; -import { StackContainer } from '../../../shared/gamma/containers/stack'; -import { ScrollView, Text, View } from 'react-native'; -import LongText from '../../../../src/shared/LongText'; -import { StackNavigationButtons } from '../../shared/components/stack-v5/StackNavigationButtons'; -import Colors from '../../../../src/shared/styling/Colors'; -import PressableWithFeedback from '../../../../src/shared/PressableWithFeedback'; - -const SCENARIO: Scenario = { - name: 'Stack Header Modes', - key: 'test-stack-header-modes', - details: '[WIP] Tests different header modes.', - platforms: ['android'], - AppComponent: App, -}; - -export default SCENARIO; - -export function App() { - return ; -} - -function StackSetup() { - return ( - Screen(true), - options: {}, - }, - { - name: 'A', - Component: () => Screen(false), - options: {}, - }, - ]} - /> - ); -} - -function Screen(isHome: boolean) { - return ( - - - - - - Pressable - - - - - ); -} diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android.tsx similarity index 96% rename from apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx rename to apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android.tsx index bf240c9ea8..e6f76be6ec 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android.tsx @@ -1,14 +1,21 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { I18nManager, Image, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { + I18nManager, + Image, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; import { Scenario } from '../../shared/helpers'; import { StackContainer, useStackNavigationContext, } from '../../../shared/gamma/containers/stack'; import { SettingsPicker, SettingsSwitch } from '../../../shared'; -import PressableWithFeedback from '../../../../src/shared/PressableWithFeedback'; -import Colors from '../../../../src/shared/styling/Colors'; -import LongText from '../../../../src/shared/LongText'; +import PressableWithFeedback from '../../../shared/PressableWithFeedback'; +import Colors from '../../../shared/styling/Colors'; +import LongText from '../../../shared/LongText'; import type { StackHeaderConfigProps, StackHeaderTypeAndroid, @@ -17,7 +24,7 @@ import type { const SCENARIO: Scenario = { name: 'Stack Subviews', - key: 'test-stack-subviews', + key: 'test-stack-subviews-android', details: 'Tests header config and subview customization.', platforms: ['android'], AppComponent: App, From d181137b21812b6f73baf21c2ae3434287d59766 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 16 Apr 2026 13:20:32 +0200 Subject: [PATCH 82/92] rename Rtl -> RTL --- .../gamma/stack/header/StackHeaderCoordinator.kt | 8 ++++---- .../gamma/stack/header/config/StackHeaderConfig.kt | 2 +- .../stack/header/config/StackHeaderConfigProviding.kt | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 6619f85b22..2053a4f447 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -124,7 +124,7 @@ internal class StackHeaderCoordinator( attachAppBarListeners(appBar) populateAppBar(appBar, config) - maybeApplyRtlCollapsingToolbarLayoutWorkaround(coordinatorLayout, config, appBar) + maybeApplyRTLCollapsingToolbarLayoutWorkaround(coordinatorLayout, config, appBar) appBar.toolbar.requestLayout() } else { removeContentBehavior(coordinatorLayout) @@ -230,7 +230,7 @@ internal class StackHeaderCoordinator( // Toolbar's native title - it would be laid out to the leading side of leading subview. val titleView = createManagedTitleView(toolbar) managedTitleView = titleView - val index = if (config.isRtl) 0 else -1 + val index = if (config.isRTL) 0 else -1 toolbar.addView(titleView, index, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.START)) } } @@ -432,7 +432,7 @@ internal class StackHeaderCoordinator( // endregion - private fun maybeApplyRtlCollapsingToolbarLayoutWorkaround( + private fun maybeApplyRTLCollapsingToolbarLayoutWorkaround( coordinatorLayout: StackHeaderCoordinatorLayout, config: StackHeaderConfigProviding, appBar: StackHeaderAppBarLayout, @@ -442,7 +442,7 @@ internal class StackHeaderCoordinator( // our subviews at higher indices than the dummy view so they get // positioned first in RTL layout. Forcing a measure triggers the // dummy view creation. - if (appBar is StackHeaderAppBarLayout.Collapsing && config.isRtl) { + if (appBar is StackHeaderAppBarLayout.Collapsing && config.isRTL) { appBar.measure( View.MeasureSpec.makeMeasureSpec(coordinatorLayout.width, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index 6e571a996d..d619950a6f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -34,7 +34,7 @@ class StackHeaderConfig( override var trailingSubview: StackHeaderSubview? = null private set - override val isRtl: Boolean + override val isRTL: Boolean get() = layoutDirection == LayoutDirection.RTL private val shadowStateProxy = ShadowStateProxy() diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt index 0b19e0144e..0672572abb 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt @@ -13,7 +13,7 @@ interface StackHeaderConfigProviding { val trailingSubview: StackHeaderSubviewProviding? val backgroundSubview: StackHeaderSubviewProviding? - val isRtl: Boolean + val isRTL: Boolean fun updateHeaderFrame( width: Int, From 8da43614e891b069edbee93b20ad55bcfeff09df Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 16 Apr 2026 13:41:16 +0200 Subject: [PATCH 83/92] ensure HeaderConfig is last child of StackScreen --- .../gamma/stack/screen/StackScreenViewManager.kt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt index 679084bb1b..8153e3bbd1 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt @@ -1,6 +1,7 @@ package com.swmansion.rnscreens.gamma.stack.screen import android.view.View +import com.facebook.react.bridge.JSApplicationCausedNativeException import com.facebook.react.bridge.JSApplicationIllegalArgumentException import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.ReactStylesDiffMap @@ -36,9 +37,20 @@ class StackScreenViewManager : child: View, index: Int, ) { + // HeaderConfig is not added to native hierarchy & it must be the last child of StackScreen. if (child is StackHeaderConfig) { + if (index < parent.childCount) { + throw JSApplicationCausedNativeException( + "[RNScreens] StackHeaderConfig must be the last child of StackScreen. ", + ) + } parent.attachHeaderConfig(child) } else { + if (index > parent.childCount) { + throw JSApplicationCausedNativeException( + "[RNScreens] StackHeaderConfig must be the last child of StackScreen. ", + ) + } super.addView(parent, child, index) } } @@ -54,13 +66,11 @@ class StackScreenViewManager : } } - // Header config is not added as a native child (it's stored as a reference - // on StackScreen), but React tracks it by index. Since it's always the last child - // in the React tree, we only need to handle the last index specially. override fun removeViewAt( parent: StackScreen, index: Int, ) { + // HeaderConfig is not added to native hierarchy & it must be the last child of StackScreen. if (index == getChildCount(parent) - 1 && parent.headerConfig != null) { parent.headerConfig?.let { parent.detachHeaderConfig(it) } } else { From 34ed72644656e6688fd606c95eb22bcb22a5d46c Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Thu, 16 Apr 2026 15:25:31 +0200 Subject: [PATCH 84/92] adjust test to new convention --- .../index.tsx} | 14 ++++----- .../test-stack-subviews-android/scenario.md | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) rename apps/src/tests/single-feature-tests/stack-v5/{test-stack-subviews-android.tsx => test-stack-subviews-android/index.tsx} (95%) create mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/scenario.md diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/index.tsx similarity index 95% rename from apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android.tsx rename to apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/index.tsx index e6f76be6ec..40e69ffdb3 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/index.tsx @@ -7,15 +7,15 @@ import { Text, View, } from 'react-native'; -import { Scenario } from '../../shared/helpers'; +import { Scenario } from '@apps/tests/shared/helpers'; import { StackContainer, useStackNavigationContext, -} from '../../../shared/gamma/containers/stack'; -import { SettingsPicker, SettingsSwitch } from '../../../shared'; -import PressableWithFeedback from '../../../shared/PressableWithFeedback'; -import Colors from '../../../shared/styling/Colors'; -import LongText from '../../../shared/LongText'; +} from '@apps/shared/gamma/containers/stack'; +import { SettingsPicker, SettingsSwitch } from '@apps/shared'; +import PressableWithFeedback from '@apps/shared/PressableWithFeedback'; +import { Colors } from '@apps/shared/styling'; +import LongText from '@apps/shared/LongText'; import type { StackHeaderConfigProps, StackHeaderTypeAndroid, @@ -130,7 +130,7 @@ function buildHeaderConfig(config: Config): StackHeaderConfigProps | undefined { Component: ( diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/scenario.md b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/scenario.md new file mode 100644 index 0000000000..e0956f25a1 --- /dev/null +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/scenario.md @@ -0,0 +1,29 @@ +# Test Scenario: Header Subviews + +## Details + +**Description:** This test focuses on handling custom subviews in the header on Android. As subview layout and synchronization in Shadow Tree is sensitive to any changes to other props, nearly full configuration of the header is provided. + +**OS test creation version:** API 36 + +## E2E test + +Other - the subview API is still subject to significant changes. + +## Prerequisites + +- Android emulator + +## Note (Optional) + +This feature is still WIP. + +### Known Issues/Important Observations + +- entire hierarchy is rebuild when number of subviews is changed in runtime +- hierarchy rebuild causes a flash and resets scroll position +- text ellipsize in RTL does not work with subviews + +## Steps + +This feature is still WIP. Step-by-step instructions will be provided when API stabilizes. From b62258ee2b4fb65ded6678d9a6b1c4ee02e7b8e3 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 20 Apr 2026 14:09:07 +0200 Subject: [PATCH 85/92] remove unused override --- .../rnscreens/RNSStackHeaderSubviewComponentDescriptor.h | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h index 4e682f7b16..9b16bdea82 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h @@ -7,13 +7,6 @@ namespace facebook::react { class RNSStackHeaderSubviewComponentDescriptor final - : public ConcreteComponentDescriptor { - public: - using ConcreteComponentDescriptor::ConcreteComponentDescriptor; - - void adopt(ShadowNode &shadowNode) const override { - ConcreteComponentDescriptor::adopt(shadowNode); - } -}; + : public ConcreteComponentDescriptor {}; } // namespace facebook::react From 3b3e51b6ee698e8df9a4b75a62265684f6a6ae29 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 20 Apr 2026 14:11:16 +0200 Subject: [PATCH 86/92] add comment in SubviewCollapseMode --- .../stack/header/subview/StackHeaderSubviewCollapseMode.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt index cec030c3e8..9fae5edba1 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt @@ -2,6 +2,11 @@ package com.swmansion.rnscreens.gamma.stack.header.subview import com.google.android.material.appbar.CollapsingToolbarLayout +// Currently, PIN collapse mode is unsupported for background +// header subviews. To enable flex layouts within the background +// we use absolute fill, which effectively makes PIN mode equivalent +// to OFF. +// More details: https://github.com/software-mansion/react-native-screens/pull/3796. enum class StackHeaderSubviewCollapseMode { OFF, PARALLAX, From 4c34b0335f318ad3131aedf17ce830743c4933fd Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 20 Apr 2026 14:47:57 +0200 Subject: [PATCH 87/92] bring back using clause to inherit constructor in HSComponentDescriptor --- .../rnscreens/RNSStackHeaderSubviewComponentDescriptor.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h index 9b16bdea82..3c763e276e 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h @@ -7,6 +7,8 @@ namespace facebook::react { class RNSStackHeaderSubviewComponentDescriptor final - : public ConcreteComponentDescriptor {}; + : public ConcreteComponentDescriptor { + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; +}; } // namespace facebook::react From 68e9553ad2e4e984ffbe5a1ab3f4aa5d5788fcb6 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 20 Apr 2026 14:53:43 +0200 Subject: [PATCH 88/92] adjust test to new scenario convention --- .../stack-v5/test-stack-subviews-android/index.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/index.tsx index 40e69ffdb3..5355225faa 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/index.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/index.tsx @@ -7,7 +7,10 @@ import { Text, View, } from 'react-native'; -import { Scenario } from '@apps/tests/shared/helpers'; +import { + createScenario, + ScenarioDescription, +} from '@apps/tests/shared/helpers'; import { StackContainer, useStackNavigationContext, @@ -22,16 +25,13 @@ import type { StackHeaderBackgroundSubviewCollapseModeAndroid, } from 'react-native-screens/experimental'; -const SCENARIO: Scenario = { +const scenarioDescription: ScenarioDescription = { name: 'Stack Subviews', key: 'test-stack-subviews-android', details: 'Tests header config and subview customization.', platforms: ['android'], - AppComponent: App, }; -export default SCENARIO; - const SHORT_TITLE = I18nManager.isRTL ? 'مرحبا' : 'Hello'; const LONG_TITLE = I18nManager.isRTL ? 'عنوان طويل جدا يجب أن يتم اقتطاعه عندما لا تتوفر مساحة كافية لعرضه بالكامل' @@ -323,3 +323,5 @@ const styles = StyleSheet.create({ fontWeight: 'bold', }, }); + +export default createScenario(App, scenarioDescription); From 66871328ce47e459c1b09f620ffcaf17f708c0e6 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 20 Apr 2026 15:00:09 +0200 Subject: [PATCH 89/92] fix types after enabling exactOptionalPropertyTypes TS flag --- .../gamma/containers/stack/StackContainer.types.tsx | 4 ++-- .../stack/header/StackHeaderConfig.android.types.ts | 12 ++++++------ .../gamma/stack/header/StackHeaderConfig.types.ts | 10 +++++----- .../android/StackHeaderSubview.android.types.ts | 6 +++--- .../stack/StackHeaderConfigAndroidNativeComponent.ts | 2 +- .../stack/StackHeaderConfigIOSNativeComponent.ts | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.types.tsx b/apps/src/shared/gamma/containers/stack/StackContainer.types.tsx index 73a5080f18..b7db0b0a90 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.types.tsx +++ b/apps/src/shared/gamma/containers/stack/StackContainer.types.tsx @@ -10,7 +10,7 @@ export type StackRouteOptions = Omit< StackScreenProps, 'children' | 'activityMode' | 'screenKey' > & { - headerConfig?: StackHeaderConfigProps; + headerConfig?: StackHeaderConfigProps | undefined; }; /** @@ -25,7 +25,7 @@ export type StackRouteConfig = { export type StackRoute = StackRouteConfig & { activityMode: StackScreenProps['activityMode']; routeKey: StackScreenProps['screenKey']; - isMarkedForDismissal: Boolean, // whether this route is during or after dismissal process + isMarkedForDismissal: Boolean; // whether this route is during or after dismissal process }; /// StackContainer props diff --git a/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts b/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts index 7625488a4f..5cee393d11 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts +++ b/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts @@ -11,14 +11,14 @@ export interface StackHeaderToolbarSubviewAndroid { } export interface StackHeaderBackgroundSubviewAndroid { - collapseMode?: StackHeaderSubviewCollapseModeAndroid; + collapseMode?: StackHeaderSubviewCollapseModeAndroid | undefined; Component: ReactNode; } export interface StackHeaderConfigPropsAndroid { - type?: StackHeaderTypeAndroid; - backgroundSubview?: StackHeaderBackgroundSubviewAndroid; - leadingSubview?: StackHeaderToolbarSubviewAndroid; - centerSubview?: StackHeaderToolbarSubviewAndroid; - trailingSubview?: StackHeaderToolbarSubviewAndroid; + type?: StackHeaderTypeAndroid | undefined; + backgroundSubview?: StackHeaderBackgroundSubviewAndroid | undefined; + leadingSubview?: StackHeaderToolbarSubviewAndroid | undefined; + centerSubview?: StackHeaderToolbarSubviewAndroid | undefined; + trailingSubview?: StackHeaderToolbarSubviewAndroid | undefined; } diff --git a/src/components/gamma/stack/header/StackHeaderConfig.types.ts b/src/components/gamma/stack/header/StackHeaderConfig.types.ts index 35cd4e6ba2..a46708d8ab 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.types.ts +++ b/src/components/gamma/stack/header/StackHeaderConfig.types.ts @@ -2,12 +2,12 @@ import { StackHeaderConfigPropsAndroid } from './StackHeaderConfig.android.types import { StackHeaderConfigPropsIOS } from './StackHeaderConfig.ios.types'; export interface StackHeaderConfigPropsBase { - title?: string; - hidden?: boolean; - transparent?: boolean; + title?: string | undefined; + hidden?: boolean | undefined; + transparent?: boolean | undefined; } export interface StackHeaderConfigProps extends StackHeaderConfigPropsBase { - android?: StackHeaderConfigPropsAndroid; - ios?: StackHeaderConfigPropsIOS; + android?: StackHeaderConfigPropsAndroid | undefined; + ios?: StackHeaderConfigPropsIOS | undefined; } diff --git a/src/components/gamma/stack/header/android/StackHeaderSubview.android.types.ts b/src/components/gamma/stack/header/android/StackHeaderSubview.android.types.ts index 966956fb66..15dd4e0cc9 100644 --- a/src/components/gamma/stack/header/android/StackHeaderSubview.android.types.ts +++ b/src/components/gamma/stack/header/android/StackHeaderSubview.android.types.ts @@ -9,8 +9,8 @@ export type StackHeaderSubviewTypeAndroid = export type StackHeaderSubviewCollapseModeAndroid = 'off' | 'parallax'; export type StackHeaderSubviewProps = { - children?: ViewProps['children']; + children?: ViewProps['children'] | undefined; - type?: StackHeaderSubviewTypeAndroid; - collapseMode?: StackHeaderSubviewCollapseModeAndroid; + type?: StackHeaderSubviewTypeAndroid | undefined; + collapseMode?: StackHeaderSubviewCollapseModeAndroid | undefined; }; diff --git a/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts index f4b63ad3d3..f230da5d64 100644 --- a/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts +++ b/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts @@ -6,7 +6,7 @@ import { codegenNativeComponent } from 'react-native'; type StackHeaderTypeAndroid = 'small' | 'medium' | 'large'; export interface NativeProps extends ViewProps { - title?: string; + title?: string | undefined; hidden?: CT.WithDefault; transparent?: CT.WithDefault; diff --git a/src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts index c461d4b063..39578f8cac 100644 --- a/src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts +++ b/src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts @@ -4,7 +4,7 @@ import type { CodegenTypes as CT, ViewProps } from 'react-native'; import { codegenNativeComponent } from 'react-native'; export interface NativeProps extends ViewProps { - title?: string; + title?: string | undefined; hidden?: CT.WithDefault; transparent?: CT.WithDefault; From 050aa98348789f0dda522091d21b30b3a973eeb1 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 20 Apr 2026 15:26:10 +0200 Subject: [PATCH 90/92] keep weak ref in headerconfigproviding.onConfigChangeListener as implementation detail --- .../gamma/stack/header/StackHeaderCoordinatorLayout.kt | 6 ++---- .../gamma/stack/header/config/StackHeaderConfig.kt | 5 ++++- .../gamma/stack/header/config/StackHeaderConfigProviding.kt | 3 +-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index f18d1db5d7..ab5bdff601 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -75,12 +75,10 @@ internal class StackHeaderCoordinatorLayout( private fun handleHeaderConfigAttach(config: StackHeaderConfigProviding?) { // Disconnect old config to prevent spurious updates from a detached config - currentConfig?.onConfigChangeListener = null + currentConfig?.setOnConfigChangeListener(null) currentConfig = config - if (config != null) { - config.onConfigChangeListener = WeakReference(onHeaderConfigChange) - } + config?.setOnConfigChangeListener(onHeaderConfigChange) // We run this even if config is null to properly remove the header if config // is removed in runtime. diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index d619950a6f..119b6894b1 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -52,8 +52,11 @@ class StackHeaderConfig( contentOffsetY = contentOffsetY, ) } + private var onConfigChangeListener: WeakReference? = null - override var onConfigChangeListener: WeakReference? = null + override fun setOnConfigChangeListener(listener: OnHeaderConfigChangeListener?) { + onConfigChangeListener = listener?.let { WeakReference(it) } + } internal fun notifyConfigChanged() { onConfigChangeListener?.get()?.onHeaderConfigChange(this) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt index 0672572abb..89d61b111e 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt @@ -1,7 +1,6 @@ package com.swmansion.rnscreens.gamma.stack.header.config import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewProviding -import java.lang.ref.WeakReference interface StackHeaderConfigProviding { val type: StackHeaderType @@ -21,5 +20,5 @@ interface StackHeaderConfigProviding { contentOffsetY: Int, ) - var onConfigChangeListener: WeakReference? + fun setOnConfigChangeListener(listener: OnHeaderConfigChangeListener?) } From 5e85b14774f1cb66387d4ecd9fcb5849f0d4f204 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 20 Apr 2026 16:02:08 +0200 Subject: [PATCH 91/92] refactor safe-stringify --- apps/src/shared/gamma/containers/shared/safe-stringify.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/src/shared/gamma/containers/shared/safe-stringify.ts b/apps/src/shared/gamma/containers/shared/safe-stringify.ts index 72a70181d2..1a9ba931f2 100644 --- a/apps/src/shared/gamma/containers/shared/safe-stringify.ts +++ b/apps/src/shared/gamma/containers/shared/safe-stringify.ts @@ -1,9 +1,13 @@ +import { isValidElement } from 'react'; + export function safeStringify(value: unknown, space?: number): string { const seen = new WeakSet(); return JSON.stringify( value, (_key, val) => { if (typeof val === 'object' && val !== null) { + // Note: this also flags shared (non-circular) refs as [Circular], + // e.g. `{ x: a, y: a }` but it's good enough for debug logs. if (seen.has(val)) { return '[Circular]'; } @@ -12,8 +16,7 @@ export function safeStringify(value: unknown, space?: number): string { if (typeof val === 'function') { return '[Function]'; } - // React elements - if (val != null && typeof val === 'object' && '$$typeof' in val) { + if (isValidElement(val)) { return '[ReactElement]'; } return val; From 948490603bf56b6cfd50f6215cde93ccc5cfe8ab Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Mon, 20 Apr 2026 16:03:42 +0200 Subject: [PATCH 92/92] format android --- .../rnscreens/gamma/stack/header/config/StackHeaderConfig.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index 119b6894b1..2506f15f40 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -52,6 +52,7 @@ class StackHeaderConfig( contentOffsetY = contentOffsetY, ) } + private var onConfigChangeListener: WeakReference? = null override fun setOnConfigChangeListener(listener: OnHeaderConfigChangeListener?) {