diff --git a/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt b/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt index 421fa76569..aad10be3d9 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.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 import com.swmansion.rnscreens.gamma.tabs.host.TabsHostViewManager @@ -55,6 +57,8 @@ class RNScreensPackage : BaseReactPackage() { StackHostViewManager(), StackScreenViewManager(), ScrollViewMarkerViewManager(), + StackHeaderConfigViewManager(), + StackHeaderSubviewViewManager(), ) } 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/common/ShadowStateProxy.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/common/ShadowStateProxy.kt new file mode 100644 index 0000000000..0f07a0d794 --- /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.1f + } +} 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 74% 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..24c438f012 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 @@ -10,13 +10,12 @@ 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.gamma.stack.header.config.StackHeaderType import com.swmansion.rnscreens.utils.resolveDimensionAttr -internal sealed class StackScreenAppBarLayout( +internal sealed class StackHeaderAppBarLayout( context: Context, ) : AppBarLayout(context) { abstract val toolbar: MaterialToolbar @@ -36,14 +35,13 @@ internal sealed class StackScreenAppBarLayout( internal class Small( context: Context, - ) : StackScreenAppBarLayout(context) { + ) : StackHeaderAppBarLayout(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 + // TODO: debug only for small header, must be moved to config scrollFlags = SCROLL_FLAG_NO_SCROLL } } @@ -56,17 +54,8 @@ internal sealed class StackScreenAppBarLayout( @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." - } - } - + val type: StackHeaderType, + ) : StackHeaderAppBarLayout(context) { override val toolbar = MaterialToolbar(context).apply { elevation = 0f @@ -80,13 +69,13 @@ internal sealed class StackScreenAppBarLayout( } } - val collapsingToolbarLayout: CollapsingToolbarLayout = + internal val collapsingToolbarLayout: CollapsingToolbarLayout = 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.") } @@ -97,15 +86,20 @@ internal sealed class StackScreenAppBarLayout( 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 + // TODO: debug only for medium/large header, must be moved to config + scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED } addView(toolbar) } } init { + require( + type == StackHeaderType.MEDIUM || + type == StackHeaderType.LARGE, + ) { + "[RNScreens] Collapsing StackHeaderAppBarLayout must be MEDIUM or LARGE type." + } addView(collapsingToolbarLayout) } } @@ -113,11 +107,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..2053a4f447 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -0,0 +1,480 @@ +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.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 +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 +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( + 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 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 + 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. + private var managedTitleView: AppCompatTextView? = null + + internal fun applyHeaderConfig( + coordinatorLayout: StackHeaderCoordinatorLayout, + config: StackHeaderConfigProviding?, + ) { + currentConfig = config + if (config != null) { + updateHeader(coordinatorLayout, config) + } else { + removeHeader(coordinatorLayout) + } + } + + private fun updateHeader( + coordinatorLayout: StackHeaderCoordinatorLayout, + config: StackHeaderConfigProviding, + ) { + if (requiresRebuild(config)) { + rebuild(coordinatorLayout, config) + } + applyProps(config) + } + + private fun removeHeader(coordinatorLayout: StackHeaderCoordinatorLayout) { + teardown(coordinatorLayout) + removeContentBehavior(coordinatorLayout) + coordinatorLayout.requestLayout() + } + + // region Rebuild detection + + private fun requiresRebuild(config: StackHeaderConfigProviding): Boolean { + 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 + if (config.backgroundSubview !== attachedBackgroundSubview) return true + + if (appBarLayout is StackHeaderAppBarLayout.Collapsing) { + if (config.backgroundSubview?.collapseMode != lastBackgroundSubviewCollapseMode) return true + } + + return false + } + + // endregion + + // region Full rebuild + + private fun rebuild( + coordinatorLayout: StackHeaderCoordinatorLayout, + config: StackHeaderConfigProviding, + ) { + teardown(coordinatorLayout) + + if (!config.hidden) { + val appBar = StackHeaderAppBarLayout.create(wrappedContext, config.type) + appBarLayout = appBar + + if (config.transparent) { + removeContentBehavior(coordinatorLayout) + coordinatorLayout.addView(appBar) + } else { + coordinatorLayout.addView(appBar, 0) + setContentBehavior(coordinatorLayout) + } + + // Make sure that we receive insets, necessary when changing header mode in runtime. + appBar.requestApplyInsets() + attachAppBarListeners(appBar) + + populateAppBar(appBar, config) + maybeApplyRTLCollapsingToolbarLayoutWorkaround(coordinatorLayout, config, appBar) + appBar.toolbar.requestLayout() + } else { + removeContentBehavior(coordinatorLayout) + coordinatorLayout.requestLayout() + } + + cacheRebuildTriggers(config) + } + + private fun teardown(coordinatorLayout: StackHeaderCoordinatorLayout) { + detachSubviews() + appBarLayout?.let { + detachAppBarListeners(it) + coordinatorLayout.removeView(it) + } + appBarLayout = null + managedTitleView = 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 + lastBackgroundSubviewCollapseMode = config.backgroundSubview?.collapseMode + } + + private fun clearCachedRebuildTriggers() { + lastHeaderType = null + lastHidden = false + lastTransparent = false + attachedLeadingSubview = null + attachedCenterSubview = null + attachedTrailingSubview = null + attachedBackgroundSubview = null + lastBackgroundSubviewCollapseMode = null + } + + 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) } + + if (appBar is StackHeaderAppBarLayout.Collapsing) { + attachedBackgroundSubview?.let { + val wrapper = it.view.parent as? FrameLayout ?: return@let + wrapper.removeView(it.view) + appBar.collapsingToolbarLayout.removeView(wrapper) + } + } + } + + // endregion + + // region App bar population + + private fun populateAppBar( + appBar: StackHeaderAppBarLayout, + config: StackHeaderConfigProviding, + ) { + val toolbar = appBar.toolbar + + // 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)) + } + + config.trailingSubview?.let { + it.view.detachFromCurrentParent() + toolbar.addView(it.view, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.END)) + } + + populateTitleOrCenter(appBar, toolbar, config) + populateBackground(appBar, config) + } + + private fun populateTitleOrCenter( + appBar: StackHeaderAppBarLayout, + toolbar: Toolbar, + config: StackHeaderConfigProviding, + ) { + val centerSubview = config.centerSubview + if (centerSubview != null) { + if (appBar is StackHeaderAppBarLayout.Small) { + toolbar.removeView(managedTitleView) + managedTitleView = null + + 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.") + } + } 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 leading side of leading subview. + val titleView = createManagedTitleView(toolbar) + managedTitleView = titleView + val index = if (config.isRTL) 0 else -1 + toolbar.addView(titleView, index, Toolbar.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.START)) + } + } + + private fun populateBackground( + appBar: StackHeaderAppBarLayout, + config: StackHeaderConfigProviding, + ) { + 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 + } + + // 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. + backgroundSubview.view.detachFromCurrentParent() + val wrapper = + 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( + wrapper, + 0, + CollapsingToolbarLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT).apply { + collapseMode = backgroundSubview.collapseMode.toNativeCollapseMode() + }, + ) + } + + private fun createManagedTitleView(toolbar: Toolbar): AppCompatTextView = + AppCompatTextView(toolbar.context).apply { + 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: 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 + marginEnd = toolbar.titleMarginEnd + topMargin = toolbar.titleMarginTop + bottomMargin = toolbar.titleMarginBottom + } + } + + // endregion + + // region In-place prop updates (no rebuild) + + private fun applyProps(config: StackHeaderConfigProviding) { + val appBar = appBarLayout ?: return + + when (appBar) { + is StackHeaderAppBarLayout.Small -> { + managedTitleView?.text = config.title + managedTitleView?.requestLayout() + } + + is StackHeaderAppBarLayout.Collapsing -> { + appBar.collapsingToolbarLayout.title = config.title + applyBackgroundCollapseMode(config) + } + } + } + + private fun applyBackgroundCollapseMode(config: StackHeaderConfigProviding) { + val backgroundSubview = config.backgroundSubview ?: 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 + } + } + + // endregion + + // region Content behavior + + private fun setContentBehavior(coordinatorLayout: StackHeaderCoordinatorLayout) { + val params = coordinatorLayout.stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams + if (params.behavior == null) { + params.behavior = + StackHeaderScrollingViewBehavior { contentTop, _ -> + onHeaderHeightChanged(contentTop) + } + coordinatorLayout.stackScreenWrapper.layoutParams = params + coordinatorLayout.stackScreenWrapper.requestLayout() + } + } + + 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) + coordinatorLayout.stackScreenWrapper.requestLayout() + } + } + + // endregion + + // 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) + } + + /** + * Synchronizes the header config and subview shadow state with the current + * native layout. Called from both [appBarOffsetListener] and [appBarLayoutChangeListener]. + */ + private fun syncShadowState() { + val config = currentConfig ?: return + val appBar = appBarLayout ?: return + + // 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 = configOffset, + ) + + updateSubviewOffsets(appBar, config) + } + + private fun updateSubviewOffsets( + appBar: StackHeaderAppBarLayout, + config: StackHeaderConfigProviding, + ) { + config.leadingSubview?.let { updateSubviewOffset(it, appBar) } + config.centerSubview?.let { updateSubviewOffset(it, appBar) } + config.trailingSubview?.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 + + private fun maybeApplyRTLCollapsingToolbarLayoutWorkaround( + coordinatorLayout: StackHeaderCoordinatorLayout, + config: StackHeaderConfigProviding, + 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) + } + } + + /** + * 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) + // Assumes only StackHeaderSubview children exist in Collapsing toolbar besides + // the CTL dummy view. + if (child !is StackHeaderSubview) { + val lp = child.layoutParams + toolbar.removeViewAt(i) + toolbar.addView(child, 0, lp) + return + } + } + } + + companion object { + private const val TAG = "StackHeaderCoordinator" + } +} 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 new file mode 100644 index 0000000000..ab5bdff601 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -0,0 +1,87 @@ +package com.swmansion.rnscreens.gamma.stack.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.header.config.OnHeaderConfigAttachListener +import com.swmansion.rnscreens.gamma.stack.header.config.OnHeaderConfigChangeListener +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigProviding +import com.swmansion.rnscreens.gamma.stack.screen.StackScreen +import java.lang.ref.WeakReference + +@SuppressLint("ViewConstructor") +internal class StackHeaderCoordinatorLayout( + context: Context, + internal val stackScreen: StackScreen, +) : CoordinatorLayout(context) { + private val headerCoordinator = + StackHeaderCoordinator(context) { headerHeight -> + stackScreen.updateStateIfNeeded(y = headerHeight) + } + + /** + * This callback is used to detect when header config is attached. + * This allows us to configure listener for header config changes. + */ + private val onHeaderConfigAttach = + OnHeaderConfigAttachListener { config -> + handleHeaderConfigAttach(config) + } + + private var isHeaderUpdatePending = false + + /** + * This callback is used to listen for header config changes. + * We use [isHeaderUpdatePending] to batch changes and pass them to [headerCoordinator]. + */ + private val onHeaderConfigChange = + OnHeaderConfigChangeListener { + if (!isHeaderUpdatePending) { + isHeaderUpdatePending = true + // 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.applyHeaderConfig(this, currentConfig) + } + } + } + + private var currentConfig: StackHeaderConfigProviding? = null + + 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), + ) + + stackScreen.onHeaderConfigAttachListener = WeakReference(onHeaderConfigAttach) + handleHeaderConfigAttach(stackScreen.headerConfig) + } + + private fun handleHeaderConfigAttach(config: StackHeaderConfigProviding?) { + // Disconnect old config to prevent spurious updates from a detached config + currentConfig?.setOnConfigChangeListener(null) + currentConfig = config + + config?.setOnConfigChangeListener(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/screen/header/StackScreenScrollingViewBehavior.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderScrollingViewBehavior.kt similarity index 64% 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..43c1eb7c32 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,11 +1,11 @@ -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( - private val onHeaderHeightChanged: (headerHeight: Int) -> Unit, +internal class StackHeaderScrollingViewBehavior( + private val onDependencyChanged: (contentTop: Int, dependency: View) -> 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) - onHeaderHeightChanged(child.top) + onDependencyChanged(child.top, dependency) return result } } 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..8454161421 --- /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: 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 new file mode 100644 index 0000000000..c597482ea7 --- /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 + +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 new file mode 100644 index 0000000000..2506f15f40 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -0,0 +1,107 @@ +package com.swmansion.rnscreens.gamma.stack.header.config + +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 +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 +import java.lang.ref.WeakReference + +@SuppressLint("ViewConstructor") +class StackHeaderConfig( + val reactContext: ReactContext, +) : ReactViewGroup(reactContext), + StackHeaderConfigProviding, + 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 + override var leadingSubview: StackHeaderSubview? = null + private set + override var centerSubview: StackHeaderSubview? = null + private set + override var trailingSubview: StackHeaderSubview? = null + private set + + override val isRTL: Boolean + get() = layoutDirection == LayoutDirection.RTL + + private val shadowStateProxy = ShadowStateProxy() + + internal var stateWrapper by shadowStateProxy::stateWrapper + + override fun updateHeaderFrame( + width: Int, + height: Int, + contentOffsetY: Int, + ) { + shadowStateProxy.updateStateIfNeeded( + frameWidth = width, + frameHeight = height, + contentOffsetY = contentOffsetY, + ) + } + + private var onConfigChangeListener: WeakReference? = null + + override fun setOnConfigChangeListener(listener: OnHeaderConfigChangeListener?) { + onConfigChangeListener = listener?.let { WeakReference(it) } + } + + internal fun notifyConfigChanged() { + onConfigChangeListener?.get()?.onHeaderConfigChange(this) + } + + override fun onStackHeaderSubviewChange() = notifyConfigChanged() + + internal fun addConfigSubview(headerSubview: StackHeaderSubview) { + when (headerSubview.type) { + StackHeaderSubviewType.BACKGROUND -> backgroundSubview = headerSubview + StackHeaderSubviewType.LEADING -> leadingSubview = headerSubview + StackHeaderSubviewType.CENTER -> centerSubview = headerSubview + StackHeaderSubviewType.TRAILING -> trailingSubview = headerSubview + } + headerSubview.onStackHeaderSubviewChangeListener = WeakReference(this) + notifyConfigChanged() + } + + internal fun removeConfigSubview(headerSubview: StackHeaderSubview) { + headerSubview.onStackHeaderSubviewChangeListener = null + when (headerSubview.type) { + StackHeaderSubviewType.BACKGROUND -> backgroundSubview = null + StackHeaderSubviewType.LEADING -> leadingSubview = null + StackHeaderSubviewType.CENTER -> centerSubview = null + StackHeaderSubviewType.TRAILING -> trailingSubview = null + } + notifyConfigChanged() + } + + internal fun removeConfigSubviewAt(index: Int) { + getConfigSubviewAt(index)?.let { removeConfigSubview(it) } + } + + internal fun removeAllConfigSubviews() { + backgroundSubview?.let { removeConfigSubview(it) } + leadingSubview?.let { removeConfigSubview(it) } + centerSubview?.let { removeConfigSubview(it) } + trailingSubview?.let { removeConfigSubview(it) } + } + + 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/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 new file mode 100644 index 0000000000..89d61b111e --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt @@ -0,0 +1,24 @@ +package com.swmansion.rnscreens.gamma.stack.header.config + +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewProviding + +interface StackHeaderConfigProviding { + val type: StackHeaderType + val title: String + val hidden: Boolean + val transparent: Boolean + val leadingSubview: StackHeaderSubviewProviding? + val centerSubview: StackHeaderSubviewProviding? + val trailingSubview: StackHeaderSubviewProviding? + val backgroundSubview: StackHeaderSubviewProviding? + + val isRTL: Boolean + + fun updateHeaderFrame( + width: Int, + height: Int, + contentOffsetY: Int, + ) + + fun setOnConfigChangeListener(listener: OnHeaderConfigChangeListener?) +} 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 new file mode 100644 index 0000000000..51932f6694 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt @@ -0,0 +1,127 @@ +package com.swmansion.rnscreens.gamma.stack.header.config + +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 +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(), + RNSStackHeaderConfigAndroidManagerInterface { + private val delegate: ViewManagerDelegate + + init { + delegate = RNSStackHeaderConfigAndroidManagerDelegate(this) + } + + override fun getName() = REACT_CLASS + + override fun createViewInstance(reactContext: ThemedReactContext) = StackHeaderConfig(reactContext) + + 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, + index: Int, + ) { + require(child is StackHeaderSubview) { + "[RNScreens] StackHeaderConfig can only have children of type StackHeaderSubview. Received $child instead." + } + parent.addConfigSubview(child) + } + + override fun removeView( + parent: StackHeaderConfig, + view: View, + ) { + require(view is StackHeaderSubview) { + "[RNScreens] StackHeaderConfig can only have children of type StackHeaderSubview. Attempted to remove $view instead." + } + parent.removeConfigSubview(view) + } + + override fun removeViewAt( + parent: StackHeaderConfig, + index: Int, + ) { + parent.removeConfigSubviewAt(index) + } + + override fun removeAllViews(parent: StackHeaderConfig) { + parent.removeAllConfigSubviews() + } + + override fun getChildCount(parent: StackHeaderConfig): Int = parent.configSubviewsCount + + override fun getChildAt( + parent: StackHeaderConfig, + index: Int, + ): View? = parent.getConfigSubviewAt(index) + + override fun updateState( + view: StackHeaderConfig, + props: ReactStylesDiffMap?, + stateWrapper: StateWrapper?, + ): Any? { + view.stateWrapper = stateWrapper + return super.updateState(view, props, stateWrapper) + } + + override fun onAfterUpdateTransaction(view: StackHeaderConfig) { + super.onAfterUpdateTransaction(view) + view.notifyConfigChanged() + } + + override fun setType( + view: StackHeaderConfig, + value: String?, + ) { + view.type = + when (value) { + "small" -> StackHeaderType.SMALL + "medium" -> StackHeaderType.MEDIUM + "large" -> StackHeaderType.LARGE + else -> throw JSApplicationIllegalArgumentException("[RNScreens] Invalid StackHeaderConfig type: $value.") + } + } + + override fun setTitle( + view: StackHeaderConfig, + value: String?, + ) { + view.title = value ?: "" + } + + override fun setHidden( + view: StackHeaderConfig, + value: Boolean, + ) { + view.hidden = value + } + + override fun setTransparent( + view: StackHeaderConfig, + value: Boolean, + ) { + view.transparent = value + } + + companion object { + const val REACT_CLASS = "RNSStackHeaderConfigAndroid" + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderType.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderType.kt new file mode 100644 index 0000000000..601b21a500 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderType.kt @@ -0,0 +1,7 @@ +package com.swmansion.rnscreens.gamma.stack.header.config + +enum class StackHeaderType { + SMALL, + MEDIUM, + LARGE, +} 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 new file mode 100644 index 0000000000..22a9c6d16f --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -0,0 +1,91 @@ +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 + +@SuppressLint("ViewConstructor") +class StackHeaderSubview( + val reactContext: ReactContext, +) : ReactViewGroup(reactContext), + StackHeaderSubviewProviding { + override var type: StackHeaderSubviewType = StackHeaderSubviewType.CENTER + internal set + + override var collapseMode: StackHeaderSubviewCollapseMode by Delegates.observable( + StackHeaderSubviewCollapseMode.OFF, + ) { _, oldValue, newValue -> + if (oldValue != newValue) { + onStackHeaderSubviewChangeListener?.get()?.onStackHeaderSubviewChange() + } + } + internal set + + override val view = this + + private val shadowStateProxy = ShadowStateProxy(includesFrameSize = false) + + internal 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 yogaWidth: Int = 0 + private var yogaHeight: Int = 0 + + // Rely on Yoga layout instead of native Toolbar layout which stretches subview to match parent. + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + 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) { + val newWidth = MeasureSpec.getSize(widthMeasureSpec) + if (newWidth != yogaWidth) { + yogaWidth = newWidth + invalidated = true + } + } + + if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) { + val newHeight = MeasureSpec.getSize(heightMeasureSpec) + if (newHeight != yogaHeight) { + yogaHeight = newHeight + invalidated = true + } + } + + if (yogaWidth > 0 && yogaHeight > 0) { + setMeasuredDimension(yogaWidth, yogaHeight) + if (invalidated && !isInLayout) { + 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/header/subview/StackHeaderSubviewCollapseMode.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt new file mode 100644 index 0000000000..9fae5edba1 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewCollapseMode.kt @@ -0,0 +1,20 @@ +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, + ; + + 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 new file mode 100644 index 0000000000..02bccf151d --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt @@ -0,0 +1,14 @@ +package com.swmansion.rnscreens.gamma.stack.header.subview + +import android.view.View + +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/StackHeaderSubviewType.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt new file mode 100644 index 0000000000..46e843f4b6 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewType.kt @@ -0,0 +1,8 @@ +package com.swmansion.rnscreens.gamma.stack.header.subview + +enum class StackHeaderSubviewType { + BACKGROUND, + LEADING, + CENTER, + 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 new file mode 100644 index 0000000000..8297946c73 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewViewManager.kt @@ -0,0 +1,67 @@ +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 +import com.facebook.react.viewmanagers.RNSStackHeaderSubviewAndroidManagerDelegate +import com.facebook.react.viewmanagers.RNSStackHeaderSubviewAndroidManagerInterface + +@ReactModule(name = StackHeaderSubviewViewManager.REACT_CLASS) +open class StackHeaderSubviewViewManager : + ViewGroupManager(), + RNSStackHeaderSubviewAndroidManagerInterface { + private val delegate: ViewManagerDelegate + + init { + delegate = RNSStackHeaderSubviewAndroidManagerDelegate(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?, + ) { + view.type = + when (value) { + "leading" -> StackHeaderSubviewType.LEADING + "center" -> StackHeaderSubviewType.CENTER + "trailing" -> StackHeaderSubviewType.TRAILING + "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 + "parallax" -> StackHeaderSubviewCollapseMode.PARALLAX + else -> throw JSApplicationIllegalArgumentException("[RNScreens] Invalid StackHeaderSubview collapseMode: $value") + } + } + + 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 = "RNSStackHeaderSubviewAndroid" + } +} 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( 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..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 @@ -7,6 +7,9 @@ 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.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 @@ -50,16 +53,38 @@ class StackScreen( field = value } - private val shadowStateProxy = StackScreenShadowStateProxy() + private val shadowStateProxy = ShadowStateProxy() - var stateWrapper by shadowStateProxy::stateWrapper + internal 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) + ) = shadowStateProxy.updateStateIfNeeded( + contentOffsetX = x, + contentOffsetY = y, + frameWidth = width, + frameHeight = height, + ) + + internal var headerConfig: StackHeaderConfig? = null + private set + + internal var onHeaderConfigAttachListener: WeakReference? = null + + internal fun attachHeaderConfig(header: StackHeaderConfig) { + headerConfig = header + onHeaderConfigAttachListener?.get()?.onHeaderConfigAttach(header) + } + + internal fun detachHeaderConfig(header: StackHeaderConfig) { + if (headerConfig === header) { + headerConfig = null + onHeaderConfigAttachListener?.get()?.onHeaderConfigAttach(null) + } + } internal lateinit var eventEmitter: StackScreenEventEmitter @@ -95,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/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/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/StackScreenViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreenViewManager.kt index b1a0bdfd9e..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,5 +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 @@ -10,6 +12,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.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 @@ -29,6 +32,64 @@ class StackScreenViewManager : override fun createViewInstance(reactContext: ThemedReactContext) = StackScreen(reactContext) + override fun addView( + parent: StackScreen, + 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) + } + } + + override fun removeView( + parent: StackScreen, + view: View, + ) { + if (view is StackHeaderConfig) { + parent.detachHeaderConfig(view) + } else { + super.removeView(parent, view) + } + } + + 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 { + super.removeViewAt(parent, index) + } + } + + 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.headerConfig != null) { + return parent.headerConfig + } + return parent.getChildAt(index) + } + override fun addEventEmitters( reactContext: ThemedReactContext, view: StackScreen, 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 580f9066f2..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenCoordinatorLayout.kt +++ /dev/null @@ -1,65 +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 - }, - ) - } - - /** - * Will crash in case parent is not StackContainer. - */ - private fun stackContainerOrNull(): StackContainer? = this.parent as StackContainer? - - 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 a094a18829..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/header/StackScreenHeaderCoordinator.kt +++ /dev/null @@ -1,93 +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, - ) { - 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, -} diff --git a/android/src/main/jni/rnscreens.h b/android/src/main/jni/rnscreens.h index a3d5690d39..8be4c22a90 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/apps/src/shared/gamma/containers/shared/safe-stringify.ts b/apps/src/shared/gamma/containers/shared/safe-stringify.ts new file mode 100644 index 0000000000..1a9ba931f2 --- /dev/null +++ b/apps/src/shared/gamma/containers/shared/safe-stringify.ts @@ -0,0 +1,26 @@ +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]'; + } + seen.add(val); + } + if (typeof val === 'function') { + return '[Function]'; + } + if (isValidElement(val)) { + return '[ReactElement]'; + } + return val; + }, + space, + ); +} diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.tsx b/apps/src/shared/gamma/containers/stack/StackContainer.tsx index 65f11e17e7..d5e0b25689 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.tsx +++ b/apps/src/shared/gamma/containers/stack/StackContainer.tsx @@ -62,7 +62,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 }, @@ -83,6 +83,9 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { onNativeDismiss={onScreenNativelyDismissed}> + {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 27a4bb9c79..b7db0b0a90 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 | undefined; +}; /** * Blueprint for a route. @@ -20,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/apps/src/shared/gamma/containers/stack/reducer.tsx b/apps/src/shared/gamma/containers/stack/reducer.tsx index d5257b4454..aac8c7ea3f 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 '../shared/id-generator'; +import { safeStringify } from '../shared/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/tests/single-feature-tests/stack-v5/index.ts b/apps/src/tests/single-feature-tests/stack-v5/index.ts index 7f59ed374e..8f5166de6b 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/index.ts +++ b/apps/src/tests/single-feature-tests/stack-v5/index.ts @@ -2,15 +2,15 @@ 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'; import TestStackSimpleNav from './test-stack-simple-nav'; +import TestStackSubviews from './test-stack-subviews-android'; const scenarios = { PreventNativeDismissSingleStack, PreventNativeDismissNestedStack, AnimationAndroid, - TestStackHeaderModes, TestStackSimpleNav, + 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 de6a14cc7b..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 type { ScenarioDescription } from '@apps/tests/shared/helpers'; -import { createScenario } from '@apps/tests/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 '@apps/shared/styling'; -import PressableWithFeedback from '../../../../src/shared/PressableWithFeedback'; - -const scenarioDescription: ScenarioDescription = { - name: 'Stack Header Modes', - key: 'test-stack-header-modes', - details: '[WIP] Tests different header modes.', - platforms: ['android'], -}; - -export function App() { - return ; -} - -function StackSetup() { - return ( - Screen(true), - options: {}, - }, - { - name: 'A', - Component: () => Screen(false), - options: {}, - }, - ]} - /> - ); -} - -function Screen(isHome: boolean) { - return ( - - - - - - Pressable - - - - - ); -} - -export default createScenario(App, scenarioDescription); 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 new file mode 100644 index 0000000000..5355225faa --- /dev/null +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/index.tsx @@ -0,0 +1,327 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + I18nManager, + Image, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { + createScenario, + ScenarioDescription, +} from '@apps/tests/shared/helpers'; +import { + StackContainer, + useStackNavigationContext, +} 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, + StackHeaderBackgroundSubviewCollapseModeAndroid, +} from 'react-native-screens/experimental'; + +const scenarioDescription: ScenarioDescription = { + name: 'Stack Subviews', + key: 'test-stack-subviews-android', + details: 'Tests header config and subview customization.', + platforms: ['android'], +}; + +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'; +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: 24, 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 { + title: config.title === 'short' ? SHORT_TITLE : LONG_TITLE, + hidden: config.hidden, + transparent: config.transparent, + android: { + type: config.type, + backgroundSubview, + leadingSubview: makeToolbarSubview(config.leadingSize, 'L'), + centerSubview: makeToolbarSubview(config.centerSize, 'C'), + trailingSubview: makeToolbarSubview(config.trailingSize, 'T'), + }, + }; +} + +export function App() { + return ; +} + +function StackSetup() { + 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', + }, +}); + +export default createScenario(App, scenarioDescription); 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. diff --git a/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigComponentDescriptor.h new file mode 100644 index 0000000000..67c54cba3a --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigComponentDescriptor.h @@ -0,0 +1,43 @@ +#pragma once + +#ifdef ANDROID +#include +#endif // ANDROID + +#include +#include +#include "RNSStackHeaderConfigShadowNode.h" + +namespace facebook::react { + +class RNSStackHeaderConfigComponentDescriptor final + : public ConcreteComponentDescriptor { + public: + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + + void adopt(ShadowNode &shadowNode) const override { +#ifdef ANDROID + react_native_assert( + dynamic_cast(&shadowNode)); + auto &configShadowNode = + static_cast(shadowNode); + react_native_assert( + dynamic_cast(&configShadowNode)); + auto &layoutableShadowNode = + static_cast(configShadowNode); + + auto state = std::static_pointer_cast< + const RNSStackHeaderConfigShadowNode::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); + } +}; + +} // namespace facebook::react 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..2f509ded46 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.cpp @@ -0,0 +1,21 @@ +#include "RNSStackHeaderConfigShadowNode.h" + +namespace facebook::react { + +#if !defined(ANDROID) +extern const char RNSStackHeaderConfigComponentName[] = + "RNSStackHeaderConfigIOS"; +#else // !defined(ANDROID) +extern const char RNSStackHeaderConfigComponentName[] = + "RNSStackHeaderConfigAndroid"; +#endif // !defined(ANDROID) + +#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/RNSStackHeaderConfigShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.h new file mode 100644 index 0000000000..6703f4722e --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include +#include "RNSStackHeaderConfigState.h" + +namespace facebook::react { + +JSI_EXPORT extern const char RNSStackHeaderConfigComponentName[]; + +class JSI_EXPORT RNSStackHeaderConfigShadowNode final + : public ConcreteViewShadowNode< + RNSStackHeaderConfigComponentName, +#if !defined(ANDROID) + RNSStackHeaderConfigIOSProps, + RNSStackHeaderConfigIOSEventEmitter, +#else // !defined(ANDROID) + RNSStackHeaderConfigAndroidProps, + RNSStackHeaderConfigAndroidEventEmitter, +#endif // !defined(ANDROID) + RNSStackHeaderConfigState> { + 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/RNSStackHeaderConfigState.cpp b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.cpp new file mode 100644 index 0000000000..9db860c70a --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.cpp @@ -0,0 +1,13 @@ +#include "RNSStackHeaderConfigState.h" + +namespace facebook::react { + +#ifdef ANDROID +folly::dynamic RNSStackHeaderConfigState::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/RNSStackHeaderConfigState.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.h new file mode 100644 index 0000000000..eca2d7e609 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +#ifdef ANDROID +#include +#include +#include +#include +#include +#endif // ANDROID + +namespace facebook::react { + +class JSI_EXPORT RNSStackHeaderConfigState final { + public: + using Shared = std::shared_ptr; + + RNSStackHeaderConfigState() {} + +#ifdef ANDROID + RNSStackHeaderConfigState( + RNSStackHeaderConfigState 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/RNSStackHeaderSubviewComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h new file mode 100644 index 0000000000..3c763e276e --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewComponentDescriptor.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include +#include "RNSStackHeaderSubviewShadowNode.h" + +namespace facebook::react { + +class RNSStackHeaderSubviewComponentDescriptor final + : public ConcreteComponentDescriptor { + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; +}; + +} // 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..ef6c1944f3 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.cpp @@ -0,0 +1,30 @@ +#include "RNSStackHeaderSubviewShadowNode.h" + +namespace facebook::react { + +extern const char RNSStackHeaderSubviewComponentName[] = + "RNSStackHeaderSubviewAndroid"; + +#ifdef ANDROID +Point RNSStackHeaderSubviewShadowNode::getContentOriginOffset( + bool /*includeTransform*/) const { + auto stateData = getStateData(); + return stateData.contentOffset; +} + +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; +} +#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 new file mode 100644 index 0000000000..851ebdb776 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewShadowNode.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "RNSStackHeaderSubviewState.h" + +namespace facebook::react { + +JSI_EXPORT extern const char RNSStackHeaderSubviewComponentName[]; + +class JSI_EXPORT RNSStackHeaderSubviewShadowNode final + : public ConcreteViewShadowNode< + RNSStackHeaderSubviewComponentName, + RNSStackHeaderSubviewAndroidProps, + RNSStackHeaderSubviewAndroidEventEmitter, + RNSStackHeaderSubviewState> { + public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + using StateData = ConcreteViewShadowNode::ConcreteStateData; + +#ifdef ANDROID +#pragma mark - ShadowNode overrides + + Point getContentOriginOffset(bool includeTransform) const override; + + void layout(LayoutContext layoutContext) override; + +#pragma mark - Custom interface + private: + void applyFrameCorrections(); +#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 new file mode 100644 index 0000000000..df1fb09233 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.cpp @@ -0,0 +1,12 @@ +#include "RNSStackHeaderSubviewState.h" + +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 new file mode 100644 index 0000000000..ccd3a0639e --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSStackHeaderSubviewState.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +#ifdef ANDROID +#include +#include +#include +#include +#include +#endif // ANDROID + +namespace facebook::react { + +class JSI_EXPORT RNSStackHeaderSubviewState final { + public: + 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 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/react-native.config.js b/react-native.config.js index 6bf41244e4..635291d71d 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -16,7 +16,9 @@ module.exports = { 'RNSModalScreenComponentDescriptor', 'RNSTabsHostComponentDescriptor', 'RNSSafeAreaViewComponentDescriptor', - 'RNSStackScreenComponentDescriptor' + 'RNSStackScreenComponentDescriptor', + 'RNSStackHeaderConfigComponentDescriptor', + 'RNSStackHeaderSubviewComponentDescriptor' ], cmakeListsPath: "../android/src/main/jni/CMakeLists.txt" }, diff --git a/src/components/gamma/stack/header/StackHeaderConfig.android.tsx b/src/components/gamma/stack/header/StackHeaderConfig.android.tsx new file mode 100644 index 0000000000..bae9933a58 --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderConfig.android.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; +import { StackHeaderConfigProps } from './StackHeaderConfig.types'; +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, + ...filteredAndroidProps + } = android ?? {}; + + return ( + + {/* + Please note that the order of the subviews MUST match + the order in native StackHeaderConfig.getConfigSubviewAt. + */} + {backgroundSubview && ( + + {backgroundSubview.Component} + + )} + {leadingSubview && ( + + {leadingSubview.Component} + + )} + {centerSubview && ( + + {centerSubview.Component} + + )} + {trailingSubview && ( + + {trailingSubview.Component} + + )} + + ); +} + +export default StackHeaderConfig; 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..5cee393d11 --- /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 | undefined; + Component: ReactNode; +} + +export interface StackHeaderConfigPropsAndroid { + type?: StackHeaderTypeAndroid | undefined; + backgroundSubview?: StackHeaderBackgroundSubviewAndroid | undefined; + leadingSubview?: StackHeaderToolbarSubviewAndroid | undefined; + centerSubview?: StackHeaderToolbarSubviewAndroid | undefined; + trailingSubview?: StackHeaderToolbarSubviewAndroid | undefined; +} 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..69118bb4c9 --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderConfig.ios.tsx @@ -0,0 +1,18 @@ +import { StackHeaderConfigProps } from './StackHeaderConfig.types'; +import StackHeaderConfigIOSNativeComponent from '../../../../fabric/gamma/stack/StackHeaderConfigIOSNativeComponent'; +import React from 'react'; + +/** + * 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/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 new file mode 100644 index 0000000000..a46708d8ab --- /dev/null +++ b/src/components/gamma/stack/header/StackHeaderConfig.types.ts @@ -0,0 +1,13 @@ +import { StackHeaderConfigPropsAndroid } from './StackHeaderConfig.android.types'; +import { StackHeaderConfigPropsIOS } from './StackHeaderConfig.ios.types'; + +export interface StackHeaderConfigPropsBase { + title?: string | undefined; + hidden?: boolean | undefined; + transparent?: boolean | undefined; +} + +export interface StackHeaderConfigProps extends StackHeaderConfigPropsBase { + android?: StackHeaderConfigPropsAndroid | undefined; + ios?: StackHeaderConfigPropsIOS | undefined; +} 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/android/StackHeaderSubview.android.tsx b/src/components/gamma/stack/header/android/StackHeaderSubview.android.tsx new file mode 100644 index 0000000000..4bc84dc2a6 --- /dev/null +++ b/src/components/gamma/stack/header/android/StackHeaderSubview.android.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +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 + */ +function StackHeaderSubview(props: StackHeaderSubviewProps) { + const { children, ...filteredProps } = props; + return ( + + {children} + + ); +} + +export default StackHeaderSubview; + +const styles = StyleSheet.create({ + absoluteStartTop: { + position: 'absolute', + start: 0, + top: 0, + }, +}); diff --git a/src/components/gamma/stack/header/android/StackHeaderSubview.android.types.ts b/src/components/gamma/stack/header/android/StackHeaderSubview.android.types.ts new file mode 100644 index 0000000000..15dd4e0cc9 --- /dev/null +++ b/src/components/gamma/stack/header/android/StackHeaderSubview.android.types.ts @@ -0,0 +1,16 @@ +import { ViewProps } from 'react-native'; + +export type StackHeaderSubviewTypeAndroid = + | 'background' + | 'leading' + | 'center' + | 'trailing'; + +export type StackHeaderSubviewCollapseModeAndroid = 'off' | 'parallax'; + +export type StackHeaderSubviewProps = { + children?: ViewProps['children'] | undefined; + + type?: StackHeaderSubviewTypeAndroid | undefined; + collapseMode?: StackHeaderSubviewCollapseModeAndroid | undefined; +}; 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 77% rename from src/components/gamma/stack/StackHost.types.ts rename to src/components/gamma/stack/host/StackHost.types.ts index 2788a1cb26..4db576df26 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 2edc487e64..00899c11cc 100644 --- a/src/components/gamma/stack/index.ts +++ b/src/components/gamma/stack/index.ts @@ -1,7 +1,8 @@ -import StackHost from './StackHost'; -import StackScreen from './StackScreen'; +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, @@ -10,7 +11,20 @@ export type { StackScreenActivityMode, StackScreenEventHandler, StackScreenProps, -} from './StackScreen.types'; +} from './screen'; + +export type { + StackHeaderConfigPropsBase, + StackHeaderConfigProps, + // Android + StackHeaderTypeAndroid, + StackHeaderBackgroundSubviewCollapseModeAndroid, + StackHeaderToolbarSubviewAndroid, + StackHeaderBackgroundSubviewAndroid, + StackHeaderConfigPropsAndroid, + // iOS + StackHeaderConfigPropsIOS, +} from './header'; /** * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE @@ -18,4 +32,5 @@ export type { export const Stack = { Host: StackHost, Screen: StackScreen, + HeaderConfig: StackHeaderConfig, }; 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/StackHeaderConfigAndroidNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts new file mode 100644 index 0000000000..f230da5d64 --- /dev/null +++ b/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts @@ -0,0 +1,23 @@ +'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 { + title?: string | undefined; + hidden?: CT.WithDefault; + transparent?: CT.WithDefault; + + // Android-specific props + type?: CT.WithDefault; +} + +export default codegenNativeComponent( + 'RNSStackHeaderConfigAndroid', + { + interfaceOnly: true, + excludedPlatforms: ['iOS'], + }, +); diff --git a/src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts new file mode 100644 index 0000000000..39578f8cac --- /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 | undefined; + hidden?: CT.WithDefault; + transparent?: CT.WithDefault; + + // iOS-specific props +} + +export default codegenNativeComponent('RNSStackHeaderConfigIOS', { + interfaceOnly: true, + excludedPlatforms: ['android'], +}); diff --git a/src/fabric/gamma/stack/StackHeaderSubviewAndroidNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderSubviewAndroidNativeComponent.ts new file mode 100644 index 0000000000..5f067b46be --- /dev/null +++ b/src/fabric/gamma/stack/StackHeaderSubviewAndroidNativeComponent.ts @@ -0,0 +1,27 @@ +'use client'; + +import type { CodegenTypes as CT, ViewProps } from 'react-native'; +import { codegenNativeComponent } from 'react-native'; + +type StackHeaderSubviewTypeAndroid = + | 'background' + | 'leading' + | 'center' + | 'trailing'; +type StackHeaderSubviewBackgroundCollapseModeAndroid = 'off' | 'parallax'; + +export interface NativeProps extends ViewProps { + type?: CT.WithDefault; + collapseMode?: CT.WithDefault< + StackHeaderSubviewBackgroundCollapseModeAndroid, + 'off' + >; +} + +export default codegenNativeComponent( + 'RNSStackHeaderSubviewAndroid', + { + interfaceOnly: true, + excludedPlatforms: ['iOS'], + }, +);