From 708fbb4af5b9234b873c8c555f28e65b534e3d43 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 10 Jun 2026 13:46:28 +0200 Subject: [PATCH 01/10] first version --- .../stack/header/StackHeaderAppBarLayout.kt | 3 + .../stack/header/StackHeaderApplicator.kt | 481 +++++++++++++ .../stack/header/StackHeaderCoordinator.kt | 664 ------------------ .../header/StackHeaderCoordinatorLayout.kt | 318 +++++++-- .../config/OnHeaderConfigAttachListener.kt | 5 - .../OnHeaderConfigurationAttachListener.kt | 8 + .../stack/header/config/StackHeaderConfig.kt | 264 +++++-- .../config/StackHeaderConfigViewManager.kt | 6 +- ...kt => StackHeaderConfigurationObserver.kt} | 7 +- ...t => StackHeaderConfigurationProviding.kt} | 15 +- .../header/config/StackHeaderDelegate.kt | 11 + .../header/config/StackHeaderUpdateFlags.kt | 25 + .../StackHeaderToolbarMenuCoordinator.kt | 205 ------ .../gamma/stack/screen/StackScreen.kt | 8 +- 14 files changed, 984 insertions(+), 1036 deletions(-) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigAttachListener.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigurationAttachListener.kt rename android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/{StackHeaderConfigDelegate.kt => StackHeaderConfigurationObserver.kt} (60%) rename android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/{StackHeaderConfigProviding.kt => StackHeaderConfigurationProviding.kt} (78%) create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderDelegate.kt create mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderUpdateFlags.kt delete mode 100644 android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuCoordinator.kt diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt index 79ad6618d1..f2b1729db9 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.appcompat.widget.AppCompatTextView import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.R import com.google.android.material.appbar.AppBarLayout @@ -42,6 +43,8 @@ internal sealed class StackHeaderAppBarLayout( layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) } + var managedTitleView: AppCompatTextView? = null + init { addView(toolbar) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt new file mode 100644 index 0000000000..8f4b2ea6b0 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt @@ -0,0 +1,481 @@ +package com.swmansion.rnscreens.gamma.stack.header + +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable +import android.text.TextUtils +import android.util.Log +import android.view.Gravity +import android.view.Menu +import android.view.MenuItem +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.core.graphics.drawable.DrawableCompat +import androidx.core.view.MenuItemCompat +import androidx.core.widget.TextViewCompat +import com.google.android.material.R +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS +import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED +import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED +import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL +import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP +import com.google.android.material.appbar.CollapsingToolbarLayout +import com.google.android.material.appbar.MaterialToolbar +import com.swmansion.rnscreens.ext.detachFromCurrentParent +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigurationProviding +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview +import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemConfig +import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemOptions +import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarUpdate +import com.swmansion.rnscreens.utils.resolveDrawableAttr + +internal class StackHeaderApplicator( + private val wrappedContext: ContextThemeWrapper, +) { + // region Rebuild + + fun rebuild( + coordinatorLayout: StackHeaderCoordinatorLayout, + config: StackHeaderConfigurationProviding, + canNavigateBack: Boolean, + onNavigationIconClick: () -> Unit, + ): StackHeaderAppBarLayout { + val appBar = StackHeaderAppBarLayout.create(wrappedContext, config.type) + + if (config.transparent) { + coordinatorLayout.removeContentBehavior() + coordinatorLayout.addView(appBar) + } else { + coordinatorLayout.addView(appBar, 0) + coordinatorLayout.setContentBehavior() + } + + appBar.requestApplyInsets() + populateAppBar(appBar, config) + maybeApplyRTLCollapsingToolbarLayoutWorkaround(coordinatorLayout, config, appBar) + appBar.toolbar.requestLayout() + + return appBar + } + + // endregion + + // region App bar population + + private fun populateAppBar( + appBar: StackHeaderAppBarLayout, + config: StackHeaderConfigurationProviding, + ) { + val toolbar = appBar.toolbar + + 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: StackHeaderConfigurationProviding, + ) { + val centerSubview = config.centerSubview + if (centerSubview != null) { + if (appBar is StackHeaderAppBarLayout.Small) { + 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) { + val titleView = createManagedTitleView(toolbar) + appBar.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: StackHeaderConfigurationProviding, + ) { + val backgroundSubview = config.backgroundSubview ?: return + + if (appBar !is StackHeaderAppBarLayout.Collapsing) { + Log.e(TAG, "[RNScreens] Background subview is supported only for collapsing header types (medium, large).") + return + } + + backgroundSubview.view.detachFromCurrentParent() + val wrapper = + FrameLayout(appBar.context).apply { + 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 { + marginStart = toolbar.titleMarginStart + toolbar.contentInsetStart + marginEnd = toolbar.titleMarginEnd + topMargin = toolbar.titleMarginTop + bottomMargin = toolbar.titleMarginBottom + } + } + + // endregion + + // region In-place updates + + fun applyTitle( + appBar: StackHeaderAppBarLayout, + config: StackHeaderConfigurationProviding, + ) { + when (appBar) { + is StackHeaderAppBarLayout.Small -> { + appBar.managedTitleView?.text = config.title + appBar.managedTitleView?.requestLayout() + } + + is StackHeaderAppBarLayout.Collapsing -> { + appBar.collapsingToolbarLayout.title = config.title + } + } + } + + fun applyBackButton( + toolbar: MaterialToolbar, + config: StackHeaderConfigurationProviding, + canNavigateBack: Boolean, + onNavigationIconClick: () -> Unit, + ) { + val visible = canNavigateBack && !config.backButtonHidden + + if (!visible) { + toolbar.navigationIcon = null + toolbar.setNavigationOnClickListener(null) + return + } + + toolbar.clearNavigationIconTint() + + val baseDrawable = + config.backButtonIcon + ?.let { getResizedDrawable(toolbar, it) } + ?: resolveDefaultBackButtonIcon() + + val tintList = resolveBackButtonTintList(config) + toolbar.navigationIcon = + if (tintList != null && baseDrawable != null) { + DrawableCompat.wrap(baseDrawable.mutate()).also { + DrawableCompat.setTintList(it, tintList) + } + } else { + baseDrawable + } + + toolbar.setNavigationOnClickListener { onNavigationIconClick() } + } + + fun applyScrollFlags( + appBar: StackHeaderAppBarLayout, + config: StackHeaderConfigurationProviding, + ) { + warnInvalidScrollFlagCombinations(config) + + val desired = computeScrollFlags(config) + val target: View = + when (appBar) { + is StackHeaderAppBarLayout.Small -> appBar.toolbar + is StackHeaderAppBarLayout.Collapsing -> appBar.collapsingToolbarLayout + } + val params = target.layoutParams as AppBarLayout.LayoutParams + params.scrollFlags = desired + target.layoutParams = params + appBar.setExpanded(true, false) + } + + // endregion + + // region Toolbar menu + + fun rebuildToolbarMenu( + toolbar: MaterialToolbar, + items: List, + onItemClicked: (id: String) -> Unit, + ): Pair, Map> { + toolbar.menu.clear() + + val forwardIdMap = mutableMapOf() + val reverseIdMap = mutableMapOf() + + items.forEachIndexed { index, item -> + val nativeId = index + 1 + forwardIdMap[item.id] = nativeId + reverseIdMap[nativeId] = item.id + val menuItem = toolbar.menu.add(Menu.NONE, nativeId, index, null) + applyMenuItemOptions(toolbar, menuItem, item.toOptions()) + } + + toolbar.setOnMenuItemClickListener { menuItem -> + reverseIdMap[menuItem.itemId]?.let(onItemClicked) + true + } + + return Pair(forwardIdMap.toMap(), reverseIdMap.toMap()) + } + + fun updateToolbarMenuItem( + toolbar: MaterialToolbar, + forwardIdMap: Map, + id: String, + options: StackHeaderToolbarMenuItemOptions, + ) { + val item = + forwardIdMap[id]?.let { toolbar.menu.findItem(it) } ?: run { + Log.e(TAG, "[RNScreens] Unable to find menu item.") + return + } + applyMenuItemOptions(toolbar, item, options) + } + + private fun applyMenuItemOptions( + toolbar: MaterialToolbar, + menuItem: MenuItem, + options: StackHeaderToolbarMenuItemOptions, + ) { + options.title?.let { menuItem.title = it } + options.hidden?.let { menuItem.isVisible = !it } + options.showAsAction?.let { menuItem.setShowAsAction(it.toNativeShowAsAction()) } + + options.icon?.let { + when (it) { + StackHeaderToolbarUpdate.Reset -> menuItem.icon = null + is StackHeaderToolbarUpdate.Set -> + menuItem.icon = getResizedDrawable(toolbar, it.value) + } + } + + if (options.requiresIconTintColorUpdate || options.icon != null) { + MenuItemCompat.setIconTintList(menuItem, getResolvedIconTintList(menuItem, options)) + } + } + + private fun StackHeaderToolbarMenuItemConfig.toOptions() = + StackHeaderToolbarMenuItemOptions( + title = title, + hidden = hidden, + showAsAction = showAsAction, + icon = StackHeaderToolbarUpdate.from(icon), + iconTintColorNormal = StackHeaderToolbarUpdate.from(iconTintColorNormal), + iconTintColorPressed = StackHeaderToolbarUpdate.from(iconTintColorPressed), + iconTintColorFocused = StackHeaderToolbarUpdate.from(iconTintColorFocused), + iconTintColorDisabled = StackHeaderToolbarUpdate.from(iconTintColorDisabled), + ) + + private fun getResolvedIconTintList( + menuItem: MenuItem, + options: StackHeaderToolbarMenuItemOptions, + ): ColorStateList? { + val currentTintList = MenuItemCompat.getIconTintList(menuItem) + val currentNormal = currentTintList?.resolvedColorOrNull(intArrayOf(android.R.attr.state_enabled)) + + val finalNormal = + when (val update = options.iconTintColorNormal) { + StackHeaderToolbarUpdate.Reset -> null + is StackHeaderToolbarUpdate.Set -> update.value + null -> currentNormal + } + + val finalDisabled = + when (val update = options.iconTintColorDisabled) { + StackHeaderToolbarUpdate.Reset -> null + is StackHeaderToolbarUpdate.Set -> update.value + null -> + currentTintList + ?.resolvedColorOrNull(intArrayOf(-android.R.attr.state_enabled)) + ?.takeIf { it != currentNormal } + } + + val finalPressed = + when (val update = options.iconTintColorPressed) { + StackHeaderToolbarUpdate.Reset -> null + is StackHeaderToolbarUpdate.Set -> update.value + null -> + currentTintList + ?.resolvedColorOrNull(intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed)) + ?.takeIf { it != currentNormal } + } + + val finalFocused = + when (val update = options.iconTintColorFocused) { + StackHeaderToolbarUpdate.Reset -> null + is StackHeaderToolbarUpdate.Set -> update.value + null -> + currentTintList + ?.resolvedColorOrNull(intArrayOf(android.R.attr.state_enabled, android.R.attr.state_focused)) + ?.takeIf { it != currentNormal } + } + + val states = mutableListOf() + val colors = mutableListOf() + + finalDisabled?.let { + states.add(intArrayOf(-android.R.attr.state_enabled)) + colors.add(it) + } + + finalPressed?.let { + states.add(intArrayOf(android.R.attr.state_pressed)) + colors.add(it) + } + + finalFocused?.let { + states.add(intArrayOf(android.R.attr.state_focused)) + colors.add(it) + } + + finalNormal?.let { + states.add(intArrayOf()) + colors.add(it) + } + + return if (states.isNotEmpty()) { + ColorStateList(states.toTypedArray(), colors.toIntArray()) + } else { + null + } + } + + private fun ColorStateList.resolvedColorOrNull(stateSet: IntArray): Int? { + val a = getColorForState(stateSet, SENTINEL_A) + val b = getColorForState(stateSet, SENTINEL_B) + return if (a == b) a else null + } + + // endregion + + // region Helpers + + private fun resolveBackButtonTintList(config: StackHeaderConfigurationProviding): ColorStateList? { + val normal = config.backButtonTintColorNormal + val pressed = config.backButtonTintColorPressed + val focused = config.backButtonTintColorFocused + + if (normal == null && pressed == null && focused == null) return null + + val states = mutableListOf() + val colors = mutableListOf() + + pressed?.let { + states.add(intArrayOf(android.R.attr.state_pressed)) + colors.add(it) + } + focused?.let { + states.add(intArrayOf(android.R.attr.state_focused)) + colors.add(it) + } + normal?.let { + states.add(intArrayOf()) + colors.add(it) + } + + return ColorStateList(states.toTypedArray(), colors.toIntArray()) + } + + private fun computeScrollFlags(config: StackHeaderConfigurationProviding): Int { + var flags = 0 + if (config.scrollFlagScroll) flags = flags or SCROLL_FLAG_SCROLL + if (config.scrollFlagEnterAlways) flags = flags or SCROLL_FLAG_ENTER_ALWAYS + if (config.scrollFlagEnterAlwaysCollapsed) flags = flags or SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED + if (config.scrollFlagExitUntilCollapsed) flags = flags or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED + if (config.scrollFlagSnap) flags = flags or SCROLL_FLAG_SNAP + return flags + } + + private fun warnInvalidScrollFlagCombinations(config: StackHeaderConfigurationProviding) { + val anyDependentFlag = + config.scrollFlagEnterAlways || + config.scrollFlagEnterAlwaysCollapsed || + config.scrollFlagExitUntilCollapsed || + config.scrollFlagSnap + if (anyDependentFlag && !config.scrollFlagScroll) { + Log.e(TAG, "[RNScreens] scrollFlag* requires scrollFlagScroll to take effect.") + } + if (config.scrollFlagEnterAlwaysCollapsed && !config.scrollFlagEnterAlways) { + Log.e(TAG, "[RNScreens] scrollFlagEnterAlwaysCollapsed requires scrollFlagEnterAlways to take effect.") + } + } + + private fun resolveDefaultBackButtonIcon(): Drawable? = + resolveDrawableAttr(wrappedContext, androidx.appcompat.R.attr.homeAsUpIndicator) + + private fun maybeApplyRTLCollapsingToolbarLayoutWorkaround( + coordinatorLayout: StackHeaderCoordinatorLayout, + config: StackHeaderConfigurationProviding, + appBar: StackHeaderAppBarLayout, + ) { + if (appBar is StackHeaderAppBarLayout.Collapsing && config.isRTL) { + appBar.measure( + View.MeasureSpec.makeMeasureSpec(coordinatorLayout.width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + ) + moveDummyViewToFront(appBar.toolbar) + } + } + + private fun moveDummyViewToFront(toolbar: Toolbar) { + for (i in 0 until toolbar.childCount) { + val child = toolbar.getChildAt(i) + if (child !is StackHeaderSubview) { + val lp = child.layoutParams + toolbar.removeViewAt(i) + toolbar.addView(child, 0, lp) + return + } + } + } + + // endregion + + companion object { + private const val TAG = "StackHeaderApplicator" + + private const val SENTINEL_A = 0x00000001 + private const val SENTINEL_B = 0x00000002 + } +} 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 deleted file mode 100644 index c4dfe7781d..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ /dev/null @@ -1,664 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.header - -import android.content.Context -import android.content.res.ColorStateList -import android.graphics.drawable.Drawable -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.graphics.drawable.DrawableCompat -import androidx.core.widget.TextViewCompat -import com.google.android.material.R -import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS -import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED -import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED -import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL -import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP -import com.google.android.material.appbar.CollapsingToolbarLayout -import com.google.android.material.appbar.MaterialToolbar -import com.swmansion.rnscreens.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 -import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuCoordinator -import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemOptions -import com.swmansion.rnscreens.utils.resolveDrawableAttr - -internal class StackHeaderCoordinator( - context: Context, - private val canNavigateBack: Boolean, - private val onHeaderHeightChanged: (headerHeight: Int) -> Unit, - private val onNavigationIconClick: () -> Unit, -) { - private val wrappedContext = - ContextThemeWrapper( - context, - R.style.Theme_Material3_DayNight_NoActionBar, - ) - - private var appBarLayout: StackHeaderAppBarLayout? = null - private var currentConfig: StackHeaderConfigProviding? = null - - private val toolbarMenuCoordinator = - StackHeaderToolbarMenuCoordinator { id -> - currentConfig?.onMenuItemClick(id) - } - - // 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 - - private var lastBackButtonVisible: Boolean? = null - private var lastBackButtonTintColorNormal: Int? = null - private var lastBackButtonTintColorPressed: Int? = null - private var lastBackButtonTintColorFocused: Int? = null - private var lastBackButtonIcon: Drawable? = null - - private var lastScrollFlags: Int? = 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) { - resetHeader(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, - ) { - resetHeader(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) - } - - internal fun tearDown(coordinatorLayout: StackHeaderCoordinatorLayout) { - removeHeader(coordinatorLayout) - currentConfig = null - } - - private fun resetHeader(coordinatorLayout: StackHeaderCoordinatorLayout) { - detachSubviews() - appBarLayout?.let { - detachAppBarListeners(it) - coordinatorLayout.removeView(it) - } - appBarLayout = null - managedTitleView = null - lastBackButtonVisible = null - lastBackButtonTintColorNormal = null - lastBackButtonTintColorPressed = null - lastBackButtonTintColorFocused = null - lastBackButtonIcon = null - lastScrollFlags = null - clearCachedRebuildTriggers() - toolbarMenuCoordinator.clear() - } - - 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) - } - } - - applyScrollFlags(appBar, config) - applyBackButton(appBar.toolbar, config) - applyToolbarMenu(appBar.toolbar, config) - } - - private fun applyToolbarMenu( - toolbar: MaterialToolbar, - config: StackHeaderConfigProviding, - ) { - toolbarMenuCoordinator.rebuildMenuIfNeeded(toolbar, config.toolbarMenuItems) - } - - 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 - } - } - - private fun applyScrollFlags( - appBar: StackHeaderAppBarLayout, - config: StackHeaderConfigProviding, - ) { - val desired = computeScrollFlags(config) - - if (desired == lastScrollFlags) return - lastScrollFlags = desired - - warnInvalidScrollFlagCombinations(config) - - val target: View = - when (appBar) { - is StackHeaderAppBarLayout.Small -> appBar.toolbar - is StackHeaderAppBarLayout.Collapsing -> appBar.collapsingToolbarLayout - } - val params = target.layoutParams as AppBarLayout.LayoutParams - params.scrollFlags = desired - target.layoutParams = params - // Snap back to expanded so the visible state matches the new flags. - appBar.setExpanded(true, false) - } - - private fun computeScrollFlags(config: StackHeaderConfigProviding): Int { - var flags = 0 - if (config.scrollFlagScroll) flags = flags or SCROLL_FLAG_SCROLL - if (config.scrollFlagEnterAlways) flags = flags or SCROLL_FLAG_ENTER_ALWAYS - if (config.scrollFlagEnterAlwaysCollapsed) flags = flags or SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED - if (config.scrollFlagExitUntilCollapsed) flags = flags or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED - if (config.scrollFlagSnap) flags = flags or SCROLL_FLAG_SNAP - return flags - } - - private fun warnInvalidScrollFlagCombinations(config: StackHeaderConfigProviding) { - val anyDependentFlag = - config.scrollFlagEnterAlways || - config.scrollFlagEnterAlwaysCollapsed || - config.scrollFlagExitUntilCollapsed || - config.scrollFlagSnap - if (anyDependentFlag && !config.scrollFlagScroll) { - Log.e(TAG, "[RNScreens] scrollFlag* requires scrollFlagScroll to take effect.") - } - if (config.scrollFlagEnterAlwaysCollapsed && !config.scrollFlagEnterAlways) { - Log.e(TAG, "[RNScreens] scrollFlagEnterAlwaysCollapsed requires scrollFlagEnterAlways to take effect.") - } - } - - // endregion - - // region Back button - - private fun applyBackButton( - toolbar: MaterialToolbar, - config: StackHeaderConfigProviding, - ) { - val visible = canNavigateBack && !config.backButtonHidden - val visibilityChanged = visible != lastBackButtonVisible - val iconChanged = config.backButtonIcon !== lastBackButtonIcon - val tintChanged = - config.backButtonTintColorNormal != lastBackButtonTintColorNormal || - config.backButtonTintColorPressed != lastBackButtonTintColorPressed || - config.backButtonTintColorFocused != lastBackButtonTintColorFocused - - if (!visibilityChanged && !iconChanged && !tintChanged) return - - lastBackButtonVisible = visible - lastBackButtonIcon = config.backButtonIcon - lastBackButtonTintColorNormal = config.backButtonTintColorNormal - lastBackButtonTintColorPressed = config.backButtonTintColorPressed - lastBackButtonTintColorFocused = config.backButtonTintColorFocused - - if (!visible) { - toolbar.navigationIcon = null - toolbar.setNavigationOnClickListener(null) - return - } - - toolbar.clearNavigationIconTint() - - val baseDrawable = - config.backButtonIcon - ?.let { getResizedDrawable(toolbar, it) } - ?: resolveDefaultBackButtonIcon() - - val tintList = resolveBackButtonTintList(config) - toolbar.navigationIcon = - if (tintList != null && baseDrawable != null) { - DrawableCompat.wrap(baseDrawable.mutate()).also { - DrawableCompat.setTintList(it, tintList) - } - } else { - baseDrawable - } - - toolbar.setNavigationOnClickListener { onNavigationIconClick() } - } - - private fun resolveBackButtonTintList(config: StackHeaderConfigProviding): ColorStateList? { - val normal = config.backButtonTintColorNormal - val pressed = config.backButtonTintColorPressed - val focused = config.backButtonTintColorFocused - - if (normal == null && pressed == null && focused == null) return null - - val states = mutableListOf() - val colors = mutableListOf() - - pressed?.let { - states.add(intArrayOf(android.R.attr.state_pressed)) - colors.add(it) - } - focused?.let { - states.add(intArrayOf(android.R.attr.state_focused)) - colors.add(it) - } - normal?.let { - states.add(intArrayOf()) - colors.add(it) - } - - return ColorStateList(states.toTypedArray(), colors.toIntArray()) - } - - // 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 - } - } - } - - internal fun handleMenuItemUpdate( - id: String, - options: StackHeaderToolbarMenuItemOptions, - ) { - appBarLayout?.toolbar?.let { - toolbarMenuCoordinator.updateItem(it, id, options) - } - } - - private fun resolveDefaultBackButtonIcon(): Drawable? = resolveDrawableAttr(wrappedContext, androidx.appcompat.R.attr.homeAsUpIndicator) - - 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 index 6d0560cb03..9209cb1414 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -2,14 +2,21 @@ package com.swmansion.rnscreens.gamma.stack.header import android.annotation.SuppressLint import android.content.Context +import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.activity.OnBackPressedDispatcherOwner +import androidx.appcompat.view.ContextThemeWrapper import androidx.coordinatorlayout.widget.CoordinatorLayout import com.facebook.react.bridge.ReactContext -import com.swmansion.rnscreens.gamma.stack.header.config.OnHeaderConfigAttachListener -import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigDelegate -import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigProviding +import com.google.android.material.R +import com.google.android.material.appbar.AppBarLayout +import com.swmansion.rnscreens.gamma.stack.header.config.OnHeaderConfigurationAttachListener +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigurationObserver +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigurationProviding +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderDelegate +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderUpdateFlags +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewProviding import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemOptions import com.swmansion.rnscreens.gamma.stack.screen.StackScreen import java.lang.ref.WeakReference @@ -18,114 +25,279 @@ import java.lang.ref.WeakReference internal class StackHeaderCoordinatorLayout( context: Context, internal val stackScreen: StackScreen, - canNavigateBack: Boolean, + private val canNavigateBack: Boolean, ) : CoordinatorLayout(context) { - private val headerCoordinator = - StackHeaderCoordinator( - context = context, - canNavigateBack = canNavigateBack, - onHeaderHeightChanged = { headerHeight -> - stackScreen.updateStateIfNeeded(y = headerHeight) - }, - onNavigationIconClick = { - val activity = - (stackScreen.context as? ReactContext)?.currentActivity - as? OnBackPressedDispatcherOwner - activity?.onBackPressedDispatcher?.onBackPressed() - }, + private val wrappedContext = + ContextThemeWrapper( + context, + R.style.Theme_Material3_DayNight_NoActionBar, ) - /** - * This callback is used to detect when header config is attached. - * This allows us to configure the delegate for header config interactions. - */ - private val onHeaderConfigAttach = - OnHeaderConfigAttachListener { config -> - handleHeaderConfigAttach(config) - } + private val applicator = StackHeaderApplicator(wrappedContext) - private var isHeaderUpdatePending = false + private var currentProvider: StackHeaderConfigurationProviding? = null + private var currentDelegate: StackHeaderDelegate? = null + private var appBarLayout: StackHeaderAppBarLayout? = null - // Read currentConfig when the runnable executes, not when it's posted, - // to avoid applying a stale config that was swapped out in the meantime. - private val headerUpdateRunnable = - Runnable { - isHeaderUpdatePending = false - headerCoordinator.applyHeaderConfig(this, currentConfig) - } + private var toolbarMenuForwardIdMap = emptyMap() + private var toolbarMenuReverseIdMap = emptyMap() - /** - * Single delegate that owns all interactions flowing from [StackHeaderConfig] to this layout. - * [onConfigChange] is batched via [post] to coalesce rapid updates. - * [onMenuItemUpdate] is dispatched immediately — commands must not be deferred. - */ - private val headerConfigDelegate = - object : StackHeaderConfigDelegate { - override fun onConfigChange(config: StackHeaderConfigProviding) { - if (!isHeaderUpdatePending) { - isHeaderUpdatePending = true - post(headerUpdateRunnable) - } - } + private val onNavigationIconClick: () -> Unit = { + val activity = + (stackScreen.context as? ReactContext)?.currentActivity + as? OnBackPressedDispatcherOwner + activity?.onBackPressedDispatcher?.onBackPressed() + } + + // region Configuration observer + + private val configObserver = + object : StackHeaderConfigurationObserver { + override fun onConfigChanged( + config: StackHeaderConfigurationProviding, + flags: StackHeaderUpdateFlags, + ) = processUpdate(config, flags) override fun onMenuItemUpdate( id: String, options: StackHeaderToolbarMenuItemOptions, ) { - headerCoordinator.handleMenuItemUpdate(id, options) + val toolbar = appBarLayout?.toolbar ?: return + applicator.updateToolbarMenuItem(toolbar, toolbarMenuForwardIdMap, id, options) } } - private var currentConfig: StackHeaderConfigProviding? = null + // endregion + + // region Config attach / detach + + private val onHeaderConfigAttach = + OnHeaderConfigurationAttachListener { provider, delegate -> + handleHeaderConfigAttach(provider, delegate) + } + + private fun handleHeaderConfigAttach( + provider: StackHeaderConfigurationProviding?, + delegate: StackHeaderDelegate?, + ) { + currentProvider?.setConfigObserver(null) + + currentProvider = provider + currentDelegate = delegate + + provider?.setConfigObserver(configObserver) + + if (provider != null) { + processUpdate(provider, StackHeaderUpdateFlags.ALL) + } else { + removeHeader() + } + } + + // endregion + + // region Init 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) + stackScreen.onHeaderConfigurationAttachListener = WeakReference(onHeaderConfigAttach) + handleHeaderConfigAttach(stackScreen.headerConfig, stackScreen.headerConfig) + } + + // endregion + + // region Flag-gated dispatch + + private fun processUpdate( + provider: StackHeaderConfigurationProviding, + flags: StackHeaderUpdateFlags, + ) { + if (flags.needsRebuild) { + resetHeader() + if (!provider.hidden) { + val appBar = applicator.rebuild(this, provider, canNavigateBack, onNavigationIconClick) + appBarLayout = appBar + attachAppBarListeners(appBar) + + applicator.applyTitle(appBar, provider) + applicator.applyBackButton(appBar.toolbar, provider, canNavigateBack, onNavigationIconClick) + applicator.applyScrollFlags(appBar, provider) + + val (fwd, rev) = applicator.rebuildToolbarMenu( + appBar.toolbar, + provider.toolbarMenuItems, + ) { id -> currentDelegate?.onMenuItemClick(id) } + toolbarMenuForwardIdMap = fwd + toolbarMenuReverseIdMap = rev + } else { + removeContentBehavior() + requestLayout() + } + syncShadowState() + return + } + + val appBar = appBarLayout ?: return + + if (flags.containsAny(StackHeaderUpdateFlags.TITLE)) + applicator.applyTitle(appBar, provider) + if (flags.containsAny(StackHeaderUpdateFlags.BACK_BUTTON)) + applicator.applyBackButton(appBar.toolbar, provider, canNavigateBack, onNavigationIconClick) + if (flags.containsAny(StackHeaderUpdateFlags.SCROLL_FLAGS)) + applicator.applyScrollFlags(appBar, provider) + if (flags.containsAny(StackHeaderUpdateFlags.TOOLBAR_MENU)) { + val (fwd, rev) = applicator.rebuildToolbarMenu( + appBar.toolbar, + provider.toolbarMenuItems, + ) { id -> currentDelegate?.onMenuItemClick(id) } + toolbarMenuForwardIdMap = fwd + toolbarMenuReverseIdMap = rev + } } - private fun handleHeaderConfigAttach(config: StackHeaderConfigProviding?) { - // Disconnect old config to prevent spurious updates from a detached config - currentConfig?.removeDelegate(headerConfigDelegate) - currentConfig = config + // endregion - config?.setDelegate(headerConfigDelegate) + // region Header lifecycle - // We run this even if config is null to properly remove the header if config - // is removed in runtime. - headerCoordinator.applyHeaderConfig(this, config) + private fun resetHeader() { + appBarLayout?.let { + detachAppBarListeners(it) + removeView(it) + } + appBarLayout = null + toolbarMenuForwardIdMap = emptyMap() + toolbarMenuReverseIdMap = emptyMap() } + private fun removeHeader() { + resetHeader() + removeContentBehavior() + requestLayout() + } + + // endregion + + // region Content behavior + + internal fun setContentBehavior() { + val params = stackScreenWrapper.layoutParams as LayoutParams + if (params.behavior == null) { + params.behavior = + StackHeaderScrollingViewBehavior { contentTop, _ -> + stackScreen.updateStateIfNeeded(y = contentTop) + } + stackScreenWrapper.layoutParams = params + stackScreenWrapper.requestLayout() + } + } + + internal fun removeContentBehavior() { + val params = stackScreenWrapper.layoutParams as LayoutParams + if (params.behavior != null) { + params.behavior = null + stackScreenWrapper.layoutParams = params + stackScreen.updateStateIfNeeded(y = 0) + stackScreenWrapper.requestLayout() + } + } + + // endregion + + // region Shadow state synchronization + + 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) + } + + private fun syncShadowState() { + val delegate = currentDelegate ?: return + val provider = currentProvider ?: return + val appBar = appBarLayout ?: return + + val configOffset = if (provider.transparent) appBar.top else appBar.top - appBar.bottom + + delegate.updateHeaderFrame( + appBar.width, + appBar.height, + configOffset, + ) + + updateSubviewOffsets(appBar, provider) + } + + private fun updateSubviewOffsets( + appBar: StackHeaderAppBarLayout, + config: StackHeaderConfigurationProviding, + ) { + 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 + + // region Teardown + internal fun tearDown() { - removeCallbacks(headerUpdateRunnable) - isHeaderUpdatePending = false + resetHeader() stackScreenWrapper.removeView(stackScreen) - currentConfig?.removeDelegate(headerConfigDelegate) - currentConfig = null + currentProvider?.setConfigObserver(null) + currentProvider = null + currentDelegate = null - stackScreen.onHeaderConfigAttachListener + stackScreen.onHeaderConfigurationAttachListener ?.get() ?.takeIf { it === onHeaderConfigAttach } - ?.let { stackScreen.onHeaderConfigAttachListener = null } - - headerCoordinator.tearDown(this) + ?.let { + stackScreen.onHeaderConfigurationAttachListener = null + } } + + // endregion } 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 deleted file mode 100644 index 8454161421..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigAttachListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -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/OnHeaderConfigurationAttachListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigurationAttachListener.kt new file mode 100644 index 0000000000..6c3290d75a --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigurationAttachListener.kt @@ -0,0 +1,8 @@ +package com.swmansion.rnscreens.gamma.stack.header.config + +internal fun interface OnHeaderConfigurationAttachListener { + fun onHeaderConfigAttach( + provider: StackHeaderConfigurationProviding?, + delegate: StackHeaderDelegate?, + ) +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index 2a7d8f6458..ba8faf4b07 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -5,7 +5,13 @@ import android.graphics.drawable.Drawable import android.util.LayoutDirection import android.util.Log import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.UIManager +import com.facebook.react.bridge.UIManagerListener +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.views.view.ReactViewGroup +import com.swmansion.rnscreens.gamma.helpers.getFabricUIManagerNotNull import com.swmansion.rnscreens.gamma.common.ShadowStateProxy import com.swmansion.rnscreens.gamma.helpers.IconResolution import com.swmansion.rnscreens.gamma.helpers.IconResolver @@ -17,49 +23,149 @@ import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenu import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemOptions import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarUpdate import java.lang.ref.WeakReference +import kotlin.properties.Delegates +@OptIn(UnstableReactNativeAPI::class) @SuppressLint("ViewConstructor") class StackHeaderConfig( val reactContext: ReactContext, ) : ReactViewGroup(reactContext), - StackHeaderConfigProviding, - OnStackHeaderSubviewChangeListener { - override var type: StackHeaderType = StackHeaderType.SMALL + StackHeaderConfigurationProviding, + StackHeaderDelegate, + OnStackHeaderSubviewChangeListener, + UIManagerListener { + + // region Flag accumulation + + private var pendingFlags = StackHeaderUpdateFlags.NONE + private var isInsideMountTransaction = false + private var configObserver: StackHeaderConfigurationObserver? = null + + override fun setConfigObserver(observer: StackHeaderConfigurationObserver?) { + configObserver = observer + } + + private fun invalidate(flags: StackHeaderUpdateFlags) { + pendingFlags = pendingFlags or flags + } + + private fun flushUpdates() { + val snapshot = pendingFlags + pendingFlags = StackHeaderUpdateFlags.NONE + if (snapshot.isNotEmpty) { + configObserver?.onConfigChanged(this, snapshot) + } + } + + // endregion + + // region UIManagerListener + + init { + UIManagerHelper + .getFabricUIManagerNotNull(reactContext as ThemedReactContext) + .addUIManagerEventListener(this) + } + + override fun willMountItems(uiManager: UIManager) { + isInsideMountTransaction = true + } + + override fun didMountItems(uiManager: UIManager) { + isInsideMountTransaction = false + flushUpdates() + } + + override fun willDispatchViewUpdates(uiManager: UIManager) = Unit + + override fun didDispatchMountItems(uiManager: UIManager) = Unit + + override fun didScheduleMountItems(uiManager: UIManager) = Unit + + // endregion + + // region Properties + + override var type: StackHeaderType by Delegates.observable(StackHeaderType.SMALL) { _, old, new -> + if (old != new) invalidate(StackHeaderUpdateFlags.STRUCTURE) + } internal set - override var title: String = "" + + override var title: String by Delegates.observable("") { _, old, new -> + if (old != new) invalidate(StackHeaderUpdateFlags.TITLE) + } internal set - override var hidden: Boolean = false + + override var hidden: Boolean by Delegates.observable(false) { _, old, new -> + if (old != new) invalidate(StackHeaderUpdateFlags.STRUCTURE) + } internal set - override var transparent: Boolean = false + + override var transparent: Boolean by Delegates.observable(false) { _, old, new -> + if (old != new) invalidate(StackHeaderUpdateFlags.STRUCTURE) + } internal set - override var backButtonHidden: Boolean = false + + override var backButtonHidden: Boolean by Delegates.observable(false) { _, old, new -> + if (old != new) invalidate(StackHeaderUpdateFlags.BACK_BUTTON) + } internal set - override var backButtonTintColorNormal: Int? = null + + override var backButtonTintColorNormal: Int? by Delegates.observable(null) { _, old, new -> + if (old != new) invalidate(StackHeaderUpdateFlags.BACK_BUTTON) + } internal set - override var backButtonTintColorPressed: Int? = null + + override var backButtonTintColorPressed: Int? by Delegates.observable(null) { _, old, new -> + if (old != new) invalidate(StackHeaderUpdateFlags.BACK_BUTTON) + } internal set - override var backButtonTintColorFocused: Int? = null + + override var backButtonTintColorFocused: Int? by Delegates.observable(null) { _, old, new -> + if (old != new) invalidate(StackHeaderUpdateFlags.BACK_BUTTON) + } internal set - override var backButtonIcon: Drawable? = null + + override var backButtonIcon: Drawable? by Delegates.observable(null) { _, old, new -> + if (old !== new) invalidate(StackHeaderUpdateFlags.BACK_BUTTON) + } internal set - override var scrollFlagScroll: Boolean = false + override var scrollFlagScroll: Boolean by Delegates.observable(false) { _, old, new -> + if (old != new) invalidate(StackHeaderUpdateFlags.SCROLL_FLAGS) + } internal set - override var scrollFlagEnterAlways: Boolean = false + + override var scrollFlagEnterAlways: Boolean by Delegates.observable(false) { _, old, new -> + if (old != new) invalidate(StackHeaderUpdateFlags.SCROLL_FLAGS) + } internal set - override var scrollFlagEnterAlwaysCollapsed: Boolean = false + + override var scrollFlagEnterAlwaysCollapsed: Boolean by Delegates.observable(false) { _, old, new -> + if (old != new) invalidate(StackHeaderUpdateFlags.SCROLL_FLAGS) + } internal set - override var scrollFlagExitUntilCollapsed: Boolean = false + + override var scrollFlagExitUntilCollapsed: Boolean by Delegates.observable(false) { _, old, new -> + if (old != new) invalidate(StackHeaderUpdateFlags.SCROLL_FLAGS) + } internal set - override var scrollFlagSnap: Boolean = false + + override var scrollFlagSnap: Boolean by Delegates.observable(false) { _, old, new -> + if (old != new) invalidate(StackHeaderUpdateFlags.SCROLL_FLAGS) + } internal set - override var toolbarMenuItems: List = emptyList() + override var toolbarMenuItems: List + by Delegates.observable(emptyList()) { _, old, new -> + if (old != new) invalidate(StackHeaderUpdateFlags.TOOLBAR_MENU) + } internal set - // Staging fields for back button icon resolution. - // Both props may arrive in any order within a single update batch. - // Resolution happens in resolveBackButtonIconIfNeeded(), called from onAfterUpdateTransaction. + // endregion + + // region Back button icon resolution + internal var backButtonDrawableIconResourceName: String? = null internal var backButtonImageIconUri: String? = null private val backButtonIconResolver = IconResolver() @@ -74,22 +180,22 @@ class StackHeaderConfig( IconResolution.Unchanged -> Unit is IconResolution.Resolved -> { backButtonIcon = result.drawable - notifyConfigChanged() + if (!isInsideMountTransaction) { + flushUpdates() + } } } } } + // endregion + + // region Toolbar menu item icon resolution + internal var toolbarMenuItemIconSourceMap = mapOf() private var toolbarMenuItemIconResolvers = mapOf() - // Last resolved icon per menu item id. Unlike every other field on this - // config — which mirrors a single prop — this cache deliberately merges - // resolved icons from BOTH sources that can set a menu item icon: the - // `toolbarMenuItems` prop array (resolveToolbarMenuItemIconsIfNeeded) and - // the imperative `setToolbarMenuItemOptions` view command - // (dispatchMenuItemUpdate). It is necessary to ensure consistency. private var toolbarMenuItemIcons = mapOf() internal fun resolveToolbarMenuItemIconsIfNeeded() { @@ -133,31 +239,47 @@ class StackHeaderConfig( if (item.icon != icon) { val newItems = currentItems.toMutableList() newItems[itemIndex] = item.copy(icon = icon) - toolbarMenuItems = newItems - notifyConfigChanged() + if (!isInsideMountTransaction) { + flushUpdates() + } } } - override var backgroundSubview: StackHeaderSubview? = null + // endregion + + // region Subviews + + override var backgroundSubview: StackHeaderSubview? by Delegates.observable(null) { _, old, new -> + if (old !== new) invalidate(StackHeaderUpdateFlags.SUBVIEWS) + } private set - override var leadingSubview: StackHeaderSubview? = null + + override var leadingSubview: StackHeaderSubview? by Delegates.observable(null) { _, old, new -> + if (old !== new) invalidate(StackHeaderUpdateFlags.SUBVIEWS) + } private set - override var centerSubview: StackHeaderSubview? = null + + override var centerSubview: StackHeaderSubview? by Delegates.observable(null) { _, old, new -> + if (old !== new) invalidate(StackHeaderUpdateFlags.SUBVIEWS) + } private set - override var trailingSubview: StackHeaderSubview? = null + + override var trailingSubview: StackHeaderSubview? by Delegates.observable(null) { _, old, new -> + if (old !== new) invalidate(StackHeaderUpdateFlags.SUBVIEWS) + } private set override val isRTL: Boolean get() = layoutDirection == LayoutDirection.RTL - private val shadowStateProxy = ShadowStateProxy() + // endregion - internal var stateWrapper by shadowStateProxy::stateWrapper + // region Shadow state (StackHeaderDelegate) - internal lateinit var eventEmitter: StackHeaderConfigEventEmitter + private val shadowStateProxy = ShadowStateProxy() - private var delegate: WeakReference? = null + internal var stateWrapper by shadowStateProxy::stateWrapper override fun updateHeaderFrame( width: Int, @@ -171,69 +293,62 @@ class StackHeaderConfig( ) } - internal fun onViewManagerAddEventEmitters() { - check(id != NO_ID) { "[RNScreens] StackHeaderConfig must have its tag set when registering event emitters" } - eventEmitter = StackHeaderConfigEventEmitter(reactContext, id) - } - override fun onMenuItemClick(id: String) { eventEmitter.emitOnToolbarMenuItemClicked(id) } - override fun setDelegate(delegate: StackHeaderConfigDelegate) { - this.delegate = WeakReference(delegate) - } + // endregion - override fun removeDelegate(delegate: StackHeaderConfigDelegate) { - if (this.delegate?.get() === delegate) { - this.delegate = null - } - } + // region Event emitter + + internal lateinit var eventEmitter: StackHeaderConfigEventEmitter - internal fun notifyConfigChanged() { - delegate?.get()?.onConfigChange(this) + internal fun onViewManagerAddEventEmitters() { + check(id != NO_ID) { "[RNScreens] StackHeaderConfig must have its tag set when registering event emitters" } + eventEmitter = StackHeaderConfigEventEmitter(reactContext, id) } - /** - * Applies a toolbar menu item view command. When the command does not touch - * the icon ([iconSource] is `null`) the options are delivered immediately. - * Otherwise, the icon is resolved first and all options — including the icon — - * are delivered together in a single update, so the change is applied - * atomically once the (possibly async) image has loaded. - */ + // endregion + + // region Imperative menu item commands + internal fun dispatchMenuItemUpdate( id: String, options: StackHeaderToolbarMenuItemOptions, iconSource: StackHeaderToolbarMenuItemIconSource?, ) { if (iconSource == null) { - delegate?.get()?.onMenuItemUpdate(id, options) + configObserver?.onMenuItemUpdate(id, options) return } val resolver = toolbarMenuItemIconResolvers[id] if (resolver == null) { Log.w(TAG, "[RNScreens] Unable to find icon resolver for menu item $id.") - delegate?.get()?.onMenuItemUpdate(id, options) + configObserver?.onMenuItemUpdate(id, options) return } resolver.resolve(reactContext, iconSource.drawableIconResourceName, iconSource.imageIconUri) { result -> val icon = when (result) { - IconResolution.Unchanged -> null // keep the current icon + IconResolution.Unchanged -> null is IconResolution.Resolved -> { - // Keep the cache in sync with the prop-array path: both share this - // id's resolver. toolbarMenuItemIcons = toolbarMenuItemIcons + (id to result.drawable) StackHeaderToolbarUpdate.from(result.drawable) } } - delegate?.get()?.onMenuItemUpdate(id, options.copy(icon = icon)) + configObserver?.onMenuItemUpdate(id, options.copy(icon = icon)) } } - override fun onStackHeaderSubviewChange() = notifyConfigChanged() + // endregion + + // region Subview management + + override fun onStackHeaderSubviewChange() { + invalidate(StackHeaderUpdateFlags.SUBVIEWS) + } internal fun addConfigSubview(headerSubview: StackHeaderSubview) { when (headerSubview.type) { @@ -243,7 +358,6 @@ class StackHeaderConfig( StackHeaderSubviewType.TRAILING -> trailingSubview = headerSubview } headerSubview.onStackHeaderSubviewChangeListener = WeakReference(this) - notifyConfigChanged() } internal fun removeConfigSubview(headerSubview: StackHeaderSubview) { @@ -254,7 +368,6 @@ class StackHeaderConfig( StackHeaderSubviewType.CENTER -> centerSubview = null StackHeaderSubviewType.TRAILING -> trailingSubview = null } - notifyConfigChanged() } internal fun removeConfigSubviewAt(index: Int) { @@ -271,10 +384,23 @@ class StackHeaderConfig( internal val configSubviewsCount: Int get() = listOfNotNull(backgroundSubview, leadingSubview, centerSubview, trailingSubview).size - // The order of the subviews MUST match the order of JS StackHeaderConfig children. internal fun getConfigSubviewAt(index: Int): StackHeaderSubview? = listOfNotNull(backgroundSubview, leadingSubview, centerSubview, trailingSubview).getOrNull(index) + // endregion + + // region Teardown + + internal fun tearDown() { + UIManagerHelper + .getFabricUIManagerNotNull(reactContext as ThemedReactContext) + .removeUIManagerEventListener(this) + pendingFlags = StackHeaderUpdateFlags.NONE + configObserver = null + } + + // endregion + companion object { private const val TAG = "StackHeaderConfig" } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt index 337480cb3b..d37a8b35dd 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt @@ -107,7 +107,11 @@ open class StackHeaderConfigViewManager : super.onAfterUpdateTransaction(view) view.resolveBackButtonIconIfNeeded() view.resolveToolbarMenuItemIconsIfNeeded() - view.notifyConfigChanged() + } + + override fun onDropViewInstance(view: StackHeaderConfig) { + super.onDropViewInstance(view) + view.tearDown() } override fun setType( diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigDelegate.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigurationObserver.kt similarity index 60% rename from android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigDelegate.kt rename to android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigurationObserver.kt index 3c6c79730c..3fa84b0d7f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigDelegate.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigurationObserver.kt @@ -2,8 +2,11 @@ package com.swmansion.rnscreens.gamma.stack.header.config import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemOptions -interface StackHeaderConfigDelegate { - fun onConfigChange(config: StackHeaderConfigProviding) +interface StackHeaderConfigurationObserver { + fun onConfigChanged( + config: StackHeaderConfigurationProviding, + flags: StackHeaderUpdateFlags, + ) fun onMenuItemUpdate( id: String, 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/StackHeaderConfigurationProviding.kt similarity index 78% rename from android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt rename to android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigurationProviding.kt index bf525f347b..f52b389457 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigurationProviding.kt @@ -4,7 +4,7 @@ import android.graphics.drawable.Drawable import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewProviding import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemConfig -interface StackHeaderConfigProviding { +interface StackHeaderConfigurationProviding { val type: StackHeaderType val title: String val hidden: Boolean @@ -24,18 +24,7 @@ interface StackHeaderConfigProviding { val trailingSubview: StackHeaderSubviewProviding? val backgroundSubview: StackHeaderSubviewProviding? val toolbarMenuItems: List - val isRTL: Boolean - fun updateHeaderFrame( - width: Int, - height: Int, - contentOffsetY: Int, - ) - - fun onMenuItemClick(id: String) - - fun setDelegate(delegate: StackHeaderConfigDelegate) - - fun removeDelegate(delegate: StackHeaderConfigDelegate) + fun setConfigObserver(observer: StackHeaderConfigurationObserver?) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderDelegate.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderDelegate.kt new file mode 100644 index 0000000000..976c972fec --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderDelegate.kt @@ -0,0 +1,11 @@ +package com.swmansion.rnscreens.gamma.stack.header.config + +interface StackHeaderDelegate { + fun updateHeaderFrame( + width: Int, + height: Int, + contentOffsetY: Int, + ) + + fun onMenuItemClick(id: String) +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderUpdateFlags.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderUpdateFlags.kt new file mode 100644 index 0000000000..70488942a8 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderUpdateFlags.kt @@ -0,0 +1,25 @@ +package com.swmansion.rnscreens.gamma.stack.header.config + +@JvmInline +value class StackHeaderUpdateFlags(val raw: Int) { + companion object { + val NONE = StackHeaderUpdateFlags(0) + val STRUCTURE = StackHeaderUpdateFlags(1 shl 0) + val SUBVIEWS = StackHeaderUpdateFlags(1 shl 1) + val TITLE = StackHeaderUpdateFlags(1 shl 2) + val BACK_BUTTON = StackHeaderUpdateFlags(1 shl 3) + val SCROLL_FLAGS = StackHeaderUpdateFlags(1 shl 4) + val TOOLBAR_MENU = StackHeaderUpdateFlags(1 shl 5) + + val APPEARANCE = TITLE or BACK_BUTTON + val ALL = STRUCTURE or SUBVIEWS or APPEARANCE or SCROLL_FLAGS or TOOLBAR_MENU + } + + infix fun or(other: StackHeaderUpdateFlags) = StackHeaderUpdateFlags(raw or other.raw) + + fun containsAny(flags: StackHeaderUpdateFlags) = flags.raw != 0 && (raw and flags.raw) != 0 + + val isNotEmpty get() = raw != 0 + + val needsRebuild get() = containsAny(STRUCTURE or SUBVIEWS) +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuCoordinator.kt deleted file mode 100644 index a9bc51d7b0..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuCoordinator.kt +++ /dev/null @@ -1,205 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.header.toolbar - -import android.content.res.ColorStateList -import android.graphics.drawable.Drawable -import android.util.Log -import android.view.Menu -import android.view.MenuItem -import androidx.core.view.MenuItemCompat -import com.google.android.material.appbar.MaterialToolbar -import com.swmansion.rnscreens.gamma.stack.header.getResizedDrawable - -internal class StackHeaderToolbarMenuCoordinator( - private val onItemClicked: (id: String) -> Unit, -) { - private val forwardIdMap = HashMap() - private val reverseIdMap = HashMap() - private var lastMenuItems: List = emptyList() - - internal fun rebuildMenuIfNeeded( - toolbar: MaterialToolbar, - items: List, - ) { - if (items == lastMenuItems) return - - toolbar.menu.clear() - forwardIdMap.clear() - reverseIdMap.clear() - - items.forEachIndexed { index, item -> - // We use IDs > 0 because 0 is Menu.NONE. - val nativeId = index + 1 - forwardIdMap[item.id] = nativeId - reverseIdMap[nativeId] = item.id - val menuItem = toolbar.menu.add(Menu.NONE, nativeId, index, null) - - applyOptions(toolbar, menuItem, item.toOptions()) - } - - toolbar.setOnMenuItemClickListener { menuItem -> - reverseIdMap[menuItem.itemId]?.let(onItemClicked) - true - } - - lastMenuItems = items - } - - internal fun clear() { - forwardIdMap.clear() - reverseIdMap.clear() - lastMenuItems = emptyList() - } - - internal fun updateItem( - toolbar: MaterialToolbar, - id: String, - options: StackHeaderToolbarMenuItemOptions, - ) { - val item = - forwardIdMap[id]?.let { toolbar.menu.findItem(it) } ?: run { - Log.e(TAG, "[RNScreens] Unable to find menu item.") - return - } - - applyOptions(toolbar, item, options) - } - - private fun applyOptions( - toolbar: MaterialToolbar, - menuItem: MenuItem, - options: StackHeaderToolbarMenuItemOptions, - ) { - options.title?.let { menuItem.title = it } - options.hidden?.let { menuItem.isVisible = !it } - options.showAsAction?.let { menuItem.setShowAsAction(it.toNativeShowAsAction()) } - - options.icon?.let { - when (it) { - StackHeaderToolbarUpdate.Reset -> menuItem.icon = null - is StackHeaderToolbarUpdate.Set -> - menuItem.icon = getResizedDrawable(toolbar, it.value) - } - } - - if (options.requiresIconTintColorUpdate || options.icon != null) { - MenuItemCompat.setIconTintList(menuItem, getResolvedIconTintList(menuItem, options)) - } - } - - private fun StackHeaderToolbarMenuItemConfig.toOptions() = - StackHeaderToolbarMenuItemOptions( - title = title, - hidden = hidden, - showAsAction = showAsAction, - icon = StackHeaderToolbarUpdate.from(icon), - iconTintColorNormal = StackHeaderToolbarUpdate.from(iconTintColorNormal), - iconTintColorPressed = StackHeaderToolbarUpdate.from(iconTintColorPressed), - iconTintColorFocused = StackHeaderToolbarUpdate.from(iconTintColorFocused), - iconTintColorDisabled = StackHeaderToolbarUpdate.from(iconTintColorDisabled), - ) - - private fun getResolvedIconTintList( - menuItem: MenuItem, - options: StackHeaderToolbarMenuItemOptions, - ): ColorStateList? { - val currentTintList = MenuItemCompat.getIconTintList(menuItem) - // The currently-applied normal (catch-all) color, if any. Used both as - // the "leave unchanged" value for normal and to dedup read-back - // overrides: when a normal entry exists every override probe also - // matches it, so an override equal to the current normal is the - // catch-all leaking through rather than an explicit override. - val currentNormal = currentTintList?.resolvedColorOrNull(intArrayOf(android.R.attr.state_enabled)) - - val finalNormal = - when (val update = options.iconTintColorNormal) { - StackHeaderToolbarUpdate.Reset -> null - is StackHeaderToolbarUpdate.Set -> update.value - null -> currentNormal - } - - val finalDisabled = - when (val update = options.iconTintColorDisabled) { - StackHeaderToolbarUpdate.Reset -> null - is StackHeaderToolbarUpdate.Set -> update.value - null -> - currentTintList - ?.resolvedColorOrNull(intArrayOf(-android.R.attr.state_enabled)) - ?.takeIf { it != currentNormal } - } - - val finalPressed = - when (val update = options.iconTintColorPressed) { - StackHeaderToolbarUpdate.Reset -> null - is StackHeaderToolbarUpdate.Set -> update.value - null -> - currentTintList - ?.resolvedColorOrNull(intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed)) - ?.takeIf { it != currentNormal } - } - - val finalFocused = - when (val update = options.iconTintColorFocused) { - StackHeaderToolbarUpdate.Reset -> null - is StackHeaderToolbarUpdate.Set -> update.value - null -> - currentTintList - ?.resolvedColorOrNull(intArrayOf(android.R.attr.state_enabled, android.R.attr.state_focused)) - ?.takeIf { it != currentNormal } - } - - val states = mutableListOf() - val colors = mutableListOf() - - finalDisabled?.let { - states.add(intArrayOf(-android.R.attr.state_enabled)) - colors.add(it) - } - - finalPressed?.let { - states.add(intArrayOf(android.R.attr.state_pressed)) - colors.add(it) - } - - finalFocused?.let { - states.add(intArrayOf(android.R.attr.state_focused)) - colors.add(it) - } - - finalNormal?.let { - states.add(intArrayOf()) - colors.add(it) - } - - return if (states.isNotEmpty()) { - ColorStateList(states.toTypedArray(), colors.toIntArray()) - } else { - null - } - } - - /** - * Resolves the color the receiver applies to [stateSet], or `null` when no - * state spec matches it. - * - * `getColorForState` returns the caller-supplied fallback when nothing - * matches, so we probe twice with two distinct sentinels: equal results - * mean a real spec matched (the actual color), differing results mean the - * slot is absent. This is robust for any color value and keeps the read-back - * stateless — see [getResolvedIconTintList]. - */ - private fun ColorStateList.resolvedColorOrNull(stateSet: IntArray): Int? { - val a = getColorForState(stateSet, SENTINEL_A) - val b = getColorForState(stateSet, SENTINEL_B) - return if (a == b) a else null - } - - companion object { - private const val TAG = "StackHeaderToolbarMenuCoordinator" - - // Two distinct sentinel fallbacks used to detect whether a ColorStateList - // actually defines a color for a given state. Their concrete values are - // irrelevant as long as they differ — see resolvedColorOrNull. - private const val SENTINEL_A = 0x00000001 - private const val SENTINEL_B = 0x00000002 - } -} 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 dcf19f4490..97014a250c 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt @@ -8,7 +8,7 @@ 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.OnHeaderConfigurationAttachListener import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfig import com.swmansion.rnscreens.gamma.stack.host.StackHost import java.lang.ref.WeakReference @@ -72,17 +72,17 @@ class StackScreen( internal var headerConfig: StackHeaderConfig? = null private set - internal var onHeaderConfigAttachListener: WeakReference? = null + internal var onHeaderConfigurationAttachListener: WeakReference? = null internal fun attachHeaderConfig(header: StackHeaderConfig) { headerConfig = header - onHeaderConfigAttachListener?.get()?.onHeaderConfigAttach(header) + onHeaderConfigurationAttachListener?.get()?.onHeaderConfigAttach(header, header) } internal fun detachHeaderConfig(header: StackHeaderConfig) { if (headerConfig === header) { headerConfig = null - onHeaderConfigAttachListener?.get()?.onHeaderConfigAttach(null) + onHeaderConfigurationAttachListener?.get()?.onHeaderConfigAttach(null, null) } } From 9e732c6db7df0b4a20edb3788deadb94eab20fff Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 10 Jun 2026 15:57:10 +0200 Subject: [PATCH 02/10] fix crash --- .../stack/header/StackHeaderAppBarLayout.kt | 2 +- .../stack/header/StackHeaderApplicator.kt | 6 +- .../header/StackHeaderCoordinatorLayout.kt | 138 +++++++++--------- .../stack/header/config/StackHeaderConfig.kt | 32 ++-- 4 files changed, 90 insertions(+), 88 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt index f2b1729db9..fff4f187e1 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt @@ -43,7 +43,7 @@ internal sealed class StackHeaderAppBarLayout( layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) } - var managedTitleView: AppCompatTextView? = null + internal var managedTitleView: AppCompatTextView? = null init { addView(toolbar) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt index 8f4b2ea6b0..302a9e618b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt @@ -42,8 +42,6 @@ internal class StackHeaderApplicator( fun rebuild( coordinatorLayout: StackHeaderCoordinatorLayout, config: StackHeaderConfigurationProviding, - canNavigateBack: Boolean, - onNavigationIconClick: () -> Unit, ): StackHeaderAppBarLayout { val appBar = StackHeaderAppBarLayout.create(wrappedContext, config.type) @@ -146,8 +144,8 @@ internal class StackHeaderApplicator( layoutParams = Toolbar .LayoutParams( - Toolbar.LayoutParams.WRAP_CONTENT, - Toolbar.LayoutParams.WRAP_CONTENT, + WRAP_CONTENT, + WRAP_CONTENT, Gravity.START, ).apply { marginStart = toolbar.titleMarginStart + toolbar.contentInsetStart diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index 9209cb1414..ce811530cf 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -96,6 +96,74 @@ internal class StackHeaderCoordinatorLayout( // endregion + // region Shadow state synchronization + + private val appBarOffsetListener = + AppBarLayout.OnOffsetChangedListener { _, _ -> + syncShadowState() + } + + private val appBarLayoutChangeListener = + OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + syncShadowState() + } + + private fun attachAppBarListeners(appBar: StackHeaderAppBarLayout) { + appBar.addOnOffsetChangedListener(appBarOffsetListener) + appBar.addOnLayoutChangeListener(appBarLayoutChangeListener) + } + + private fun detachAppBarListeners(appBar: StackHeaderAppBarLayout) { + appBar.removeOnOffsetChangedListener(appBarOffsetListener) + appBar.removeOnLayoutChangeListener(appBarLayoutChangeListener) + } + + private fun syncShadowState() { + val delegate = currentDelegate ?: return + val provider = currentProvider ?: return + val appBar = appBarLayout ?: return + + val configOffset = if (provider.transparent) appBar.top else appBar.top - appBar.bottom + + delegate.updateHeaderFrame( + appBar.width, + appBar.height, + configOffset, + ) + + updateSubviewOffsets(appBar, provider) + } + + private fun updateSubviewOffsets( + appBar: StackHeaderAppBarLayout, + config: StackHeaderConfigurationProviding, + ) { + 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 + // region Init internal var stackScreenWrapper: FrameLayout @@ -124,7 +192,7 @@ internal class StackHeaderCoordinatorLayout( if (flags.needsRebuild) { resetHeader() if (!provider.hidden) { - val appBar = applicator.rebuild(this, provider, canNavigateBack, onNavigationIconClick) + val appBar = applicator.rebuild(this, provider) appBarLayout = appBar attachAppBarListeners(appBar) @@ -212,74 +280,6 @@ internal class StackHeaderCoordinatorLayout( // endregion - // region Shadow state synchronization - - 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) - } - - private fun syncShadowState() { - val delegate = currentDelegate ?: return - val provider = currentProvider ?: return - val appBar = appBarLayout ?: return - - val configOffset = if (provider.transparent) appBar.top else appBar.top - appBar.bottom - - delegate.updateHeaderFrame( - appBar.width, - appBar.height, - configOffset, - ) - - updateSubviewOffsets(appBar, provider) - } - - private fun updateSubviewOffsets( - appBar: StackHeaderAppBarLayout, - config: StackHeaderConfigurationProviding, - ) { - 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 - // region Teardown internal fun tearDown() { diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index ba8faf4b07..ebcc83eb34 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -28,7 +28,7 @@ import kotlin.properties.Delegates @OptIn(UnstableReactNativeAPI::class) @SuppressLint("ViewConstructor") class StackHeaderConfig( - val reactContext: ReactContext, + val reactContext: ThemedReactContext, ) : ReactViewGroup(reactContext), StackHeaderConfigurationProviding, StackHeaderDelegate, @@ -50,6 +50,10 @@ class StackHeaderConfig( } private fun flushUpdates() { + if (configObserver == null) { + return + } + val snapshot = pendingFlags pendingFlags = StackHeaderUpdateFlags.NONE if (snapshot.isNotEmpty) { @@ -63,7 +67,7 @@ class StackHeaderConfig( init { UIManagerHelper - .getFabricUIManagerNotNull(reactContext as ThemedReactContext) + .getFabricUIManagerNotNull(reactContext) .addUIManagerEventListener(this) } @@ -111,22 +115,22 @@ class StackHeaderConfig( } internal set - override var backButtonTintColorNormal: Int? by Delegates.observable(null) { _, old, new -> + override var backButtonTintColorNormal: Int? by Delegates.observable(null) { _, old, new -> if (old != new) invalidate(StackHeaderUpdateFlags.BACK_BUTTON) } internal set - override var backButtonTintColorPressed: Int? by Delegates.observable(null) { _, old, new -> + override var backButtonTintColorPressed: Int? by Delegates.observable(null) { _, old, new -> if (old != new) invalidate(StackHeaderUpdateFlags.BACK_BUTTON) } internal set - override var backButtonTintColorFocused: Int? by Delegates.observable(null) { _, old, new -> + override var backButtonTintColorFocused: Int? by Delegates.observable(null) { _, old, new -> if (old != new) invalidate(StackHeaderUpdateFlags.BACK_BUTTON) } internal set - override var backButtonIcon: Drawable? by Delegates.observable(null) { _, old, new -> + override var backButtonIcon: Drawable? by Delegates.observable(null) { _, old, new -> if (old !== new) invalidate(StackHeaderUpdateFlags.BACK_BUTTON) } internal set @@ -162,6 +166,9 @@ class StackHeaderConfig( } internal set + override val isRTL: Boolean + get() = layoutDirection == LayoutDirection.RTL + // endregion // region Back button icon resolution @@ -250,29 +257,26 @@ class StackHeaderConfig( // region Subviews - override var backgroundSubview: StackHeaderSubview? by Delegates.observable(null) { _, old, new -> + override var backgroundSubview: StackHeaderSubview? by Delegates.observable(null) { _, old, new -> if (old !== new) invalidate(StackHeaderUpdateFlags.SUBVIEWS) } private set - override var leadingSubview: StackHeaderSubview? by Delegates.observable(null) { _, old, new -> + override var leadingSubview: StackHeaderSubview? by Delegates.observable(null) { _, old, new -> if (old !== new) invalidate(StackHeaderUpdateFlags.SUBVIEWS) } private set - override var centerSubview: StackHeaderSubview? by Delegates.observable(null) { _, old, new -> + override var centerSubview: StackHeaderSubview? by Delegates.observable(null) { _, old, new -> if (old !== new) invalidate(StackHeaderUpdateFlags.SUBVIEWS) } private set - override var trailingSubview: StackHeaderSubview? by Delegates.observable(null) { _, old, new -> + override var trailingSubview: StackHeaderSubview? by Delegates.observable(null) { _, old, new -> if (old !== new) invalidate(StackHeaderUpdateFlags.SUBVIEWS) } private set - override val isRTL: Boolean - get() = layoutDirection == LayoutDirection.RTL - // endregion // region Shadow state (StackHeaderDelegate) @@ -393,7 +397,7 @@ class StackHeaderConfig( internal fun tearDown() { UIManagerHelper - .getFabricUIManagerNotNull(reactContext as ThemedReactContext) + .getFabricUIManagerNotNull(reactContext) .removeUIManagerEventListener(this) pendingFlags = StackHeaderUpdateFlags.NONE configObserver = null From 03d219772163eced0de5c8a2762438c533bff235 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 10 Jun 2026 16:18:02 +0200 Subject: [PATCH 03/10] fix redundant update --- .../header/StackHeaderCoordinatorLayout.kt | 32 ++++++++++--------- .../stack/header/config/StackHeaderConfig.kt | 6 ++-- .../header/config/StackHeaderUpdateFlags.kt | 2 ++ 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index ce811530cf..ba89692e5d 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -2,7 +2,6 @@ package com.swmansion.rnscreens.gamma.stack.header import android.annotation.SuppressLint import android.content.Context -import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.activity.OnBackPressedDispatcherOwner @@ -85,10 +84,8 @@ internal class StackHeaderCoordinatorLayout( currentProvider = provider currentDelegate = delegate - provider?.setConfigObserver(configObserver) - if (provider != null) { - processUpdate(provider, StackHeaderUpdateFlags.ALL) + provider.setConfigObserver(configObserver) } else { removeHeader() } @@ -200,10 +197,11 @@ internal class StackHeaderCoordinatorLayout( applicator.applyBackButton(appBar.toolbar, provider, canNavigateBack, onNavigationIconClick) applicator.applyScrollFlags(appBar, provider) - val (fwd, rev) = applicator.rebuildToolbarMenu( - appBar.toolbar, - provider.toolbarMenuItems, - ) { id -> currentDelegate?.onMenuItemClick(id) } + val (fwd, rev) = + applicator.rebuildToolbarMenu( + appBar.toolbar, + provider.toolbarMenuItems, + ) { id -> currentDelegate?.onMenuItemClick(id) } toolbarMenuForwardIdMap = fwd toolbarMenuReverseIdMap = rev } else { @@ -216,17 +214,21 @@ internal class StackHeaderCoordinatorLayout( val appBar = appBarLayout ?: return - if (flags.containsAny(StackHeaderUpdateFlags.TITLE)) + if (flags.containsAny(StackHeaderUpdateFlags.TITLE)) { applicator.applyTitle(appBar, provider) - if (flags.containsAny(StackHeaderUpdateFlags.BACK_BUTTON)) + } + if (flags.containsAny(StackHeaderUpdateFlags.BACK_BUTTON)) { applicator.applyBackButton(appBar.toolbar, provider, canNavigateBack, onNavigationIconClick) - if (flags.containsAny(StackHeaderUpdateFlags.SCROLL_FLAGS)) + } + if (flags.containsAny(StackHeaderUpdateFlags.SCROLL_FLAGS)) { applicator.applyScrollFlags(appBar, provider) + } if (flags.containsAny(StackHeaderUpdateFlags.TOOLBAR_MENU)) { - val (fwd, rev) = applicator.rebuildToolbarMenu( - appBar.toolbar, - provider.toolbarMenuItems, - ) { id -> currentDelegate?.onMenuItemClick(id) } + val (fwd, rev) = + applicator.rebuildToolbarMenu( + appBar.toolbar, + provider.toolbarMenuItems, + ) { id -> currentDelegate?.onMenuItemClick(id) } toolbarMenuForwardIdMap = fwd toolbarMenuReverseIdMap = rev } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index ebcc83eb34..a3db446106 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -50,15 +50,13 @@ class StackHeaderConfig( } private fun flushUpdates() { - if (configObserver == null) { + if (configObserver == null || pendingFlags.isEmpty) { return } val snapshot = pendingFlags + configObserver?.onConfigChanged(this, snapshot) pendingFlags = StackHeaderUpdateFlags.NONE - if (snapshot.isNotEmpty) { - configObserver?.onConfigChanged(this, snapshot) - } } // endregion diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderUpdateFlags.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderUpdateFlags.kt index 70488942a8..3601328cdb 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderUpdateFlags.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderUpdateFlags.kt @@ -21,5 +21,7 @@ value class StackHeaderUpdateFlags(val raw: Int) { val isNotEmpty get() = raw != 0 + val isEmpty get() = raw == 0 + val needsRebuild get() = containsAny(STRUCTURE or SUBVIEWS) } From 4e9aa98f2bbe567fc3e6315823946044aca7aaf4 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 10 Jun 2026 17:19:46 +0200 Subject: [PATCH 04/10] rename --- .../gamma/stack/header/StackHeaderCoordinatorLayout.kt | 6 +++--- .../gamma/stack/header/config/StackHeaderConfig.kt | 2 +- .../header/config/StackHeaderConfigurationProviding.kt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index ba89692e5d..67bb2a5699 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -79,13 +79,13 @@ internal class StackHeaderCoordinatorLayout( provider: StackHeaderConfigurationProviding?, delegate: StackHeaderDelegate?, ) { - currentProvider?.setConfigObserver(null) + currentProvider?.setConfigurationObserver(null) currentProvider = provider currentDelegate = delegate if (provider != null) { - provider.setConfigObserver(configObserver) + provider.setConfigurationObserver(configObserver) } else { removeHeader() } @@ -289,7 +289,7 @@ internal class StackHeaderCoordinatorLayout( stackScreenWrapper.removeView(stackScreen) - currentProvider?.setConfigObserver(null) + currentProvider?.setConfigurationObserver(null) currentProvider = null currentDelegate = null diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index a3db446106..292e1ea88a 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -41,7 +41,7 @@ class StackHeaderConfig( private var isInsideMountTransaction = false private var configObserver: StackHeaderConfigurationObserver? = null - override fun setConfigObserver(observer: StackHeaderConfigurationObserver?) { + override fun setConfigurationObserver(observer: StackHeaderConfigurationObserver?) { configObserver = observer } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigurationProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigurationProviding.kt index f52b389457..c06fd87afe 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigurationProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigurationProviding.kt @@ -26,5 +26,5 @@ interface StackHeaderConfigurationProviding { val toolbarMenuItems: List val isRTL: Boolean - fun setConfigObserver(observer: StackHeaderConfigurationObserver?) + fun setConfigurationObserver(observer: StackHeaderConfigurationObserver?) } From 7bca17b5ab942dd1d5006cca75b9be7bc24c477b Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 10 Jun 2026 17:40:38 +0200 Subject: [PATCH 05/10] remove shadow state update from subview providing; rename callbacks to use past tense --- .../stack/header/StackHeaderApplicator.kt | 3 +- .../header/StackHeaderCoordinatorLayout.kt | 75 ++++++++----------- .../OnHeaderConfigurationAttachListener.kt | 2 +- .../stack/header/config/StackHeaderConfig.kt | 33 +++++--- .../StackHeaderConfigurationObserver.kt | 2 +- .../header/config/StackHeaderDelegate.kt | 12 ++- .../header/config/StackHeaderUpdateFlags.kt | 6 +- .../OnStackHeaderSubviewChangeListener.kt | 2 +- .../header/subview/StackHeaderSubview.kt | 4 +- .../subview/StackHeaderSubviewProviding.kt | 5 -- .../gamma/stack/screen/StackScreen.kt | 27 ++++--- 11 files changed, 89 insertions(+), 82 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt index 302a9e618b..b85ddd2467 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt @@ -439,8 +439,7 @@ internal class StackHeaderApplicator( } } - private fun resolveDefaultBackButtonIcon(): Drawable? = - resolveDrawableAttr(wrappedContext, androidx.appcompat.R.attr.homeAsUpIndicator) + private fun resolveDefaultBackButtonIcon(): Drawable? = resolveDrawableAttr(wrappedContext, androidx.appcompat.R.attr.homeAsUpIndicator) private fun maybeApplyRTLCollapsingToolbarLayoutWorkaround( coordinatorLayout: StackHeaderCoordinatorLayout, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index 67bb2a5699..294e006817 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -18,7 +18,6 @@ import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderUpdateFlags import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewProviding import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemOptions import com.swmansion.rnscreens.gamma.stack.screen.StackScreen -import java.lang.ref.WeakReference @SuppressLint("ViewConstructor") internal class StackHeaderCoordinatorLayout( @@ -57,7 +56,7 @@ internal class StackHeaderCoordinatorLayout( flags: StackHeaderUpdateFlags, ) = processUpdate(config, flags) - override fun onMenuItemUpdate( + override fun onMenuItemUpdated( id: String, options: StackHeaderToolbarMenuItemOptions, ) { @@ -70,7 +69,7 @@ internal class StackHeaderCoordinatorLayout( // region Config attach / detach - private val onHeaderConfigAttach = + private val onHeaderConfigAttached = OnHeaderConfigurationAttachListener { provider, delegate -> handleHeaderConfigAttach(provider, delegate) } @@ -122,7 +121,7 @@ internal class StackHeaderCoordinatorLayout( val configOffset = if (provider.transparent) appBar.top else appBar.top - appBar.bottom - delegate.updateHeaderFrame( + delegate.onHeaderFrameChanged( appBar.width, appBar.height, configOffset, @@ -153,7 +152,8 @@ internal class StackHeaderCoordinatorLayout( appBar.getLocationInWindow(appBarPos) view.getLocationInWindow(subviewPos) - subview.updateContentOriginOffset( + currentDelegate?.onSubviewOriginChanged( + subview.type, x = subviewPos[0] - appBarPos[0], y = subviewPos[1] - appBarPos[1], ) @@ -174,8 +174,7 @@ internal class StackHeaderCoordinatorLayout( LayoutParams(MATCH_PARENT, MATCH_PARENT), ) - stackScreen.onHeaderConfigurationAttachListener = WeakReference(onHeaderConfigAttach) - handleHeaderConfigAttach(stackScreen.headerConfig, stackScreen.headerConfig) + stackScreen.registerHeaderConfigAttachListener(onHeaderConfigAttached) } // endregion @@ -186,52 +185,47 @@ internal class StackHeaderCoordinatorLayout( provider: StackHeaderConfigurationProviding, flags: StackHeaderUpdateFlags, ) { - if (flags.needsRebuild) { + var activeFlags = flags + + if (activeFlags.needsRebuild) { resetHeader() if (!provider.hidden) { val appBar = applicator.rebuild(this, provider) appBarLayout = appBar attachAppBarListeners(appBar) + } else { + removeContentBehavior() + requestLayout() + } + activeFlags = + StackHeaderUpdateFlags.ALL.clearing( + StackHeaderUpdateFlags.STRUCTURE or StackHeaderUpdateFlags.SUBVIEWS, + ) + } + val appBar = appBarLayout + if (appBar != null) { + if (activeFlags.containsAny(StackHeaderUpdateFlags.TITLE)) { applicator.applyTitle(appBar, provider) + } + if (activeFlags.containsAny(StackHeaderUpdateFlags.BACK_BUTTON)) { applicator.applyBackButton(appBar.toolbar, provider, canNavigateBack, onNavigationIconClick) + } + if (activeFlags.containsAny(StackHeaderUpdateFlags.SCROLL_FLAGS)) { applicator.applyScrollFlags(appBar, provider) - + } + if (activeFlags.containsAny(StackHeaderUpdateFlags.TOOLBAR_MENU)) { val (fwd, rev) = applicator.rebuildToolbarMenu( appBar.toolbar, provider.toolbarMenuItems, - ) { id -> currentDelegate?.onMenuItemClick(id) } + ) { id -> currentDelegate?.onMenuItemClicked(id) } toolbarMenuForwardIdMap = fwd toolbarMenuReverseIdMap = rev - } else { - removeContentBehavior() - requestLayout() } - syncShadowState() - return } - val appBar = appBarLayout ?: return - - if (flags.containsAny(StackHeaderUpdateFlags.TITLE)) { - applicator.applyTitle(appBar, provider) - } - if (flags.containsAny(StackHeaderUpdateFlags.BACK_BUTTON)) { - applicator.applyBackButton(appBar.toolbar, provider, canNavigateBack, onNavigationIconClick) - } - if (flags.containsAny(StackHeaderUpdateFlags.SCROLL_FLAGS)) { - applicator.applyScrollFlags(appBar, provider) - } - if (flags.containsAny(StackHeaderUpdateFlags.TOOLBAR_MENU)) { - val (fwd, rev) = - applicator.rebuildToolbarMenu( - appBar.toolbar, - provider.toolbarMenuItems, - ) { id -> currentDelegate?.onMenuItemClick(id) } - toolbarMenuForwardIdMap = fwd - toolbarMenuReverseIdMap = rev - } + syncShadowState() } // endregion @@ -263,7 +257,7 @@ internal class StackHeaderCoordinatorLayout( if (params.behavior == null) { params.behavior = StackHeaderScrollingViewBehavior { contentTop, _ -> - stackScreen.updateStateIfNeeded(y = contentTop) + stackScreen.onContentYOriginChanged(contentTop) } stackScreenWrapper.layoutParams = params stackScreenWrapper.requestLayout() @@ -275,7 +269,7 @@ internal class StackHeaderCoordinatorLayout( if (params.behavior != null) { params.behavior = null stackScreenWrapper.layoutParams = params - stackScreen.updateStateIfNeeded(y = 0) + stackScreen.onContentYOriginChanged(0) stackScreenWrapper.requestLayout() } } @@ -293,12 +287,7 @@ internal class StackHeaderCoordinatorLayout( currentProvider = null currentDelegate = null - stackScreen.onHeaderConfigurationAttachListener - ?.get() - ?.takeIf { it === onHeaderConfigAttach } - ?.let { - stackScreen.onHeaderConfigurationAttachListener = null - } + stackScreen.registerHeaderConfigAttachListener(null) } // endregion diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigurationAttachListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigurationAttachListener.kt index 6c3290d75a..39b66a9d77 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigurationAttachListener.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigurationAttachListener.kt @@ -1,7 +1,7 @@ package com.swmansion.rnscreens.gamma.stack.header.config internal fun interface OnHeaderConfigurationAttachListener { - fun onHeaderConfigAttach( + fun onHeaderConfigAttached( provider: StackHeaderConfigurationProviding?, delegate: StackHeaderDelegate?, ) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index 292e1ea88a..58a069de0b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -4,17 +4,16 @@ import android.annotation.SuppressLint import android.graphics.drawable.Drawable import android.util.LayoutDirection import android.util.Log -import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.UIManager import com.facebook.react.bridge.UIManagerListener import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.views.view.ReactViewGroup -import com.swmansion.rnscreens.gamma.helpers.getFabricUIManagerNotNull import com.swmansion.rnscreens.gamma.common.ShadowStateProxy import com.swmansion.rnscreens.gamma.helpers.IconResolution import com.swmansion.rnscreens.gamma.helpers.IconResolver +import com.swmansion.rnscreens.gamma.helpers.getFabricUIManagerNotNull 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 @@ -34,7 +33,6 @@ class StackHeaderConfig( StackHeaderDelegate, OnStackHeaderSubviewChangeListener, UIManagerListener { - // region Flag accumulation private var pendingFlags = StackHeaderUpdateFlags.NONE @@ -277,13 +275,13 @@ class StackHeaderConfig( // endregion - // region Shadow state (StackHeaderDelegate) + // region StackHeaderDelegate private val shadowStateProxy = ShadowStateProxy() internal var stateWrapper by shadowStateProxy::stateWrapper - override fun updateHeaderFrame( + override fun onHeaderFrameChanged( width: Int, height: Int, contentOffsetY: Int, @@ -295,10 +293,25 @@ class StackHeaderConfig( ) } - override fun onMenuItemClick(id: String) { + override fun onMenuItemClicked(id: String) { eventEmitter.emitOnToolbarMenuItemClicked(id) } + override fun onSubviewOriginChanged( + type: StackHeaderSubviewType, + x: Int, + y: Int, + ) { + val subview = + when (type) { + StackHeaderSubviewType.BACKGROUND -> backgroundSubview + StackHeaderSubviewType.LEADING -> leadingSubview + StackHeaderSubviewType.CENTER -> centerSubview + StackHeaderSubviewType.TRAILING -> trailingSubview + } + subview?.updateContentOriginOffset(x, y) + } + // endregion // region Event emitter @@ -320,14 +333,14 @@ class StackHeaderConfig( iconSource: StackHeaderToolbarMenuItemIconSource?, ) { if (iconSource == null) { - configObserver?.onMenuItemUpdate(id, options) + configObserver?.onMenuItemUpdated(id, options) return } val resolver = toolbarMenuItemIconResolvers[id] if (resolver == null) { Log.w(TAG, "[RNScreens] Unable to find icon resolver for menu item $id.") - configObserver?.onMenuItemUpdate(id, options) + configObserver?.onMenuItemUpdated(id, options) return } @@ -340,7 +353,7 @@ class StackHeaderConfig( StackHeaderToolbarUpdate.from(result.drawable) } } - configObserver?.onMenuItemUpdate(id, options.copy(icon = icon)) + configObserver?.onMenuItemUpdated(id, options.copy(icon = icon)) } } @@ -348,7 +361,7 @@ class StackHeaderConfig( // region Subview management - override fun onStackHeaderSubviewChange() { + override fun onStackHeaderSubviewChanged() { invalidate(StackHeaderUpdateFlags.SUBVIEWS) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigurationObserver.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigurationObserver.kt index 3fa84b0d7f..ccaa342903 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigurationObserver.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigurationObserver.kt @@ -8,7 +8,7 @@ interface StackHeaderConfigurationObserver { flags: StackHeaderUpdateFlags, ) - fun onMenuItemUpdate( + fun onMenuItemUpdated( id: String, options: StackHeaderToolbarMenuItemOptions, ) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderDelegate.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderDelegate.kt index 976c972fec..7daa0437de 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderDelegate.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderDelegate.kt @@ -1,11 +1,19 @@ package com.swmansion.rnscreens.gamma.stack.header.config +import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewType + interface StackHeaderDelegate { - fun updateHeaderFrame( + fun onHeaderFrameChanged( width: Int, height: Int, contentOffsetY: Int, ) - fun onMenuItemClick(id: String) + fun onMenuItemClicked(id: String) + + fun onSubviewOriginChanged( + type: StackHeaderSubviewType, + x: Int, + y: Int, + ) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderUpdateFlags.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderUpdateFlags.kt index 3601328cdb..2c2fd8804c 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderUpdateFlags.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderUpdateFlags.kt @@ -1,7 +1,9 @@ package com.swmansion.rnscreens.gamma.stack.header.config @JvmInline -value class StackHeaderUpdateFlags(val raw: Int) { +value class StackHeaderUpdateFlags( + val raw: Int, +) { companion object { val NONE = StackHeaderUpdateFlags(0) val STRUCTURE = StackHeaderUpdateFlags(1 shl 0) @@ -19,6 +21,8 @@ value class StackHeaderUpdateFlags(val raw: Int) { fun containsAny(flags: StackHeaderUpdateFlags) = flags.raw != 0 && (raw and flags.raw) != 0 + fun clearing(flags: StackHeaderUpdateFlags) = StackHeaderUpdateFlags(raw and flags.raw.inv()) + val isNotEmpty get() = raw != 0 val isEmpty get() = raw == 0 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 index c9f38e3f1e..8ba4cb7799 100644 --- 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 @@ -1,5 +1,5 @@ package com.swmansion.rnscreens.gamma.stack.header.subview internal fun interface OnStackHeaderSubviewChangeListener { - fun onStackHeaderSubviewChange() + fun onStackHeaderSubviewChanged() } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt index b57f900d22..4f5825466d 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -20,7 +20,7 @@ class StackHeaderSubview( StackHeaderSubviewCollapseMode.OFF, ) { _, oldValue, newValue -> if (oldValue != newValue) { - onStackHeaderSubviewChangeListener?.get()?.onStackHeaderSubviewChange() + onStackHeaderSubviewChangeListener?.get()?.onStackHeaderSubviewChanged() } } internal set @@ -31,7 +31,7 @@ class StackHeaderSubview( internal var stateWrapper by shadowStateProxy::stateWrapper - override fun updateContentOriginOffset( + fun updateContentOriginOffset( x: Int, y: Int, ) { diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt index 02bccf151d..0f8c0cb987 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubviewProviding.kt @@ -6,9 +6,4 @@ 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/screen/StackScreen.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt index 97014a250c..2e36029a35 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 @@ -57,32 +57,31 @@ class StackScreen( internal var stateWrapper by shadowStateProxy::stateWrapper - fun updateStateIfNeeded( - x: Int? = null, - y: Int? = null, - width: Int? = null, - height: Int? = null, - ) = shadowStateProxy.updateStateIfNeeded( - contentOffsetX = x, - contentOffsetY = y, - frameWidth = width, - frameHeight = height, - ) + internal fun onContentYOriginChanged(y: Int) { + shadowStateProxy.updateStateIfNeeded(contentOffsetY = y) + } internal var headerConfig: StackHeaderConfig? = null private set - internal var onHeaderConfigurationAttachListener: WeakReference? = null + private var onHeaderConfigurationAttachListener: WeakReference? = null + + internal fun registerHeaderConfigAttachListener(listener: OnHeaderConfigurationAttachListener?) { + onHeaderConfigurationAttachListener = listener?.let { WeakReference(it) } + if (listener != null) { + headerConfig?.let { listener.onHeaderConfigAttached(it, it) } + } + } internal fun attachHeaderConfig(header: StackHeaderConfig) { headerConfig = header - onHeaderConfigurationAttachListener?.get()?.onHeaderConfigAttach(header, header) + onHeaderConfigurationAttachListener?.get()?.onHeaderConfigAttached(header, header) } internal fun detachHeaderConfig(header: StackHeaderConfig) { if (headerConfig === header) { headerConfig = null - onHeaderConfigurationAttachListener?.get()?.onHeaderConfigAttach(null, null) + onHeaderConfigurationAttachListener?.get()?.onHeaderConfigAttached(null, null) } } From 1a7851bd2deae23bd1a24893447bf0544a1ce0e2 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 10 Jun 2026 18:00:29 +0200 Subject: [PATCH 06/10] small cleanup --- .../header/StackHeaderCoordinatorLayout.kt | 2 +- .../gamma/stack/screen/StackScreen.kt | 41 ++++++++++++------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index 294e006817..002adae3ac 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -287,7 +287,7 @@ internal class StackHeaderCoordinatorLayout( currentProvider = null currentDelegate = null - stackScreen.registerHeaderConfigAttachListener(null) + stackScreen.clearHeaderConfigAttachListener() } // endregion 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 2e36029a35..498f675e9c 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 @@ -53,6 +53,8 @@ class StackScreen( field = value } + // region Shadow State synchronization + private val shadowStateProxy = ShadowStateProxy() internal var stateWrapper by shadowStateProxy::stateWrapper @@ -61,16 +63,35 @@ class StackScreen( shadowStateProxy.updateStateIfNeeded(contentOffsetY = y) } + override fun onLayout( + changed: Boolean, + l: Int, + t: Int, + r: Int, + b: Int, + ) { + shadowStateProxy.updateStateIfNeeded(frameWidth = r - l, frameHeight = b - t) + } + + // endregion + + // region Header config + internal var headerConfig: StackHeaderConfig? = null private set private var onHeaderConfigurationAttachListener: WeakReference? = null - internal fun registerHeaderConfigAttachListener(listener: OnHeaderConfigurationAttachListener?) { - onHeaderConfigurationAttachListener = listener?.let { WeakReference(it) } - if (listener != null) { - headerConfig?.let { listener.onHeaderConfigAttached(it, it) } + internal fun registerHeaderConfigAttachListener(listener: OnHeaderConfigurationAttachListener) { + check(onHeaderConfigurationAttachListener?.get() == null) { + "[RNScreens] Attempted to register header config attach listener before previous listener was cleared." } + onHeaderConfigurationAttachListener = WeakReference(listener) + headerConfig?.let { listener.onHeaderConfigAttached(it, it) } + } + + internal fun clearHeaderConfigAttachListener() { + onHeaderConfigurationAttachListener = null } internal fun attachHeaderConfig(header: StackHeaderConfig) { @@ -85,6 +106,8 @@ class StackScreen( } } + // endregion + internal lateinit var eventEmitter: StackScreenEventEmitter /** @@ -112,16 +135,6 @@ class StackScreen( eventEmitter.emitOnNativeDismissPrevented() } - override fun onLayout( - changed: Boolean, - l: Int, - t: Int, - r: Int, - b: Int, - ) { - shadowStateProxy.updateStateIfNeeded(frameWidth = r - l, frameHeight = b - t) - } - override fun getAssociatedFragment(): Fragment? = this.findFragmentOrNull()?.also { check(it is StackScreenFragment) { "[RNScreens] Unexpected fragment type: ${it.javaClass.simpleName}" } From b8effac123dc43f9f5f31eb569b79a8cedfa4d14 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 10 Jun 2026 18:23:11 +0200 Subject: [PATCH 07/10] remove StackHost from UIManagerEventListeners on teardown --- .../rnscreens/gamma/stack/host/StackHost.kt | 16 ++++++++++------ .../gamma/stack/host/StackHostViewManager.kt | 2 ++ 2 files changed, 12 insertions(+), 6 deletions(-) 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 d925b41dc2..452a244169 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 @@ -8,7 +8,7 @@ import com.facebook.react.bridge.UIManagerListener import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.UIManagerHelper -import com.facebook.react.uimanager.common.UIManagerType +import com.swmansion.rnscreens.gamma.helpers.getFabricUIManagerNotNull import com.swmansion.rnscreens.gamma.stack.screen.StackScreen import com.swmansion.rnscreens.utils.RNSLog import java.lang.ref.WeakReference @@ -30,11 +30,9 @@ class StackHost( addView(container) // We're adding ourselves during a batch, therefore we expect to receive its finalization callbacks - val uiManager = - checkNotNull(UIManagerHelper.getUIManager(reactContext, UIManagerType.FABRIC)) { - "[RNScreens] UIManager must not be null." - } - uiManager.addUIManagerEventListener(this) + UIManagerHelper + .getFabricUIManagerNotNull(reactContext) + .addUIManagerEventListener(this) } override fun onAttachedToWindow() { @@ -162,6 +160,12 @@ class StackHost( override fun didScheduleMountItems(uiManager: UIManager) = Unit + internal fun tearDown() { + UIManagerHelper + .getFabricUIManagerNotNull(reactContext) + .removeUIManagerEventListener(this) + } + companion object { const val TAG = "StackHost" } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackHostViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackHostViewManager.kt index a7aa06e7e2..fd675639e4 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackHostViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackHostViewManager.kt @@ -56,6 +56,8 @@ class StackHostViewManager : override fun getChildCount(parent: StackHost): Int = parent.renderedScreens.size + override fun onDropViewInstance(view: StackHost) = view.tearDown() + companion object { const val REACT_CLASS = "RNSStackHost" } From 798a5ebd6f3a4ca3bfdbac567cad271fe1ae950a Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 10 Jun 2026 18:44:36 +0200 Subject: [PATCH 08/10] clean up 2 --- .../stack/header/StackHeaderAppBarLayout.kt | 1 + .../stack/header/StackHeaderApplicator.kt | 164 +++++++++--------- .../header/StackHeaderCoordinatorLayout.kt | 119 ++++++------- .../stack/header/config/StackHeaderConfig.kt | 125 +++++++------ .../header/subview/StackHeaderSubview.kt | 18 +- 5 files changed, 222 insertions(+), 205 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt index fff4f187e1..1e35215a05 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt @@ -43,6 +43,7 @@ internal sealed class StackHeaderAppBarLayout( layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) } + // We need to manually manage the title to handle leading subview's positioning. internal var managedTitleView: AppCompatTextView? = null init { diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt index b85ddd2467..2ec42f2fde 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderApplicator.kt @@ -293,6 +293,62 @@ internal class StackHeaderApplicator( } } + // endregion + + // region Helpers + + private fun computeScrollFlags(config: StackHeaderConfigurationProviding): Int { + var flags = 0 + if (config.scrollFlagScroll) flags = flags or SCROLL_FLAG_SCROLL + if (config.scrollFlagEnterAlways) flags = flags or SCROLL_FLAG_ENTER_ALWAYS + if (config.scrollFlagEnterAlwaysCollapsed) flags = flags or SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED + if (config.scrollFlagExitUntilCollapsed) flags = flags or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED + if (config.scrollFlagSnap) flags = flags or SCROLL_FLAG_SNAP + return flags + } + + private fun warnInvalidScrollFlagCombinations(config: StackHeaderConfigurationProviding) { + val anyDependentFlag = + config.scrollFlagEnterAlways || + config.scrollFlagEnterAlwaysCollapsed || + config.scrollFlagExitUntilCollapsed || + config.scrollFlagSnap + if (anyDependentFlag && !config.scrollFlagScroll) { + Log.e(TAG, "[RNScreens] scrollFlag* requires scrollFlagScroll to take effect.") + } + if (config.scrollFlagEnterAlwaysCollapsed && !config.scrollFlagEnterAlways) { + Log.e(TAG, "[RNScreens] scrollFlagEnterAlwaysCollapsed requires scrollFlagEnterAlways to take effect.") + } + } + + private fun resolveDefaultBackButtonIcon(): Drawable? = resolveDrawableAttr(wrappedContext, androidx.appcompat.R.attr.homeAsUpIndicator) + + private fun maybeApplyRTLCollapsingToolbarLayoutWorkaround( + coordinatorLayout: StackHeaderCoordinatorLayout, + config: StackHeaderConfigurationProviding, + appBar: StackHeaderAppBarLayout, + ) { + if (appBar is StackHeaderAppBarLayout.Collapsing && config.isRTL) { + appBar.measure( + View.MeasureSpec.makeMeasureSpec(coordinatorLayout.width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + ) + moveDummyViewToFront(appBar.toolbar) + } + } + + private fun moveDummyViewToFront(toolbar: Toolbar) { + for (i in 0 until toolbar.childCount) { + val child = toolbar.getChildAt(i) + if (child !is StackHeaderSubview) { + val lp = child.layoutParams + toolbar.removeViewAt(i) + toolbar.addView(child, 0, lp) + return + } + } + } + private fun StackHeaderToolbarMenuItemConfig.toOptions() = StackHeaderToolbarMenuItemOptions( title = title, @@ -305,6 +361,32 @@ internal class StackHeaderApplicator( iconTintColorDisabled = StackHeaderToolbarUpdate.from(iconTintColorDisabled), ) + private fun resolveBackButtonTintList(config: StackHeaderConfigurationProviding): ColorStateList? { + val normal = config.backButtonTintColorNormal + val pressed = config.backButtonTintColorPressed + val focused = config.backButtonTintColorFocused + + if (normal == null && pressed == null && focused == null) return null + + val states = mutableListOf() + val colors = mutableListOf() + + pressed?.let { + states.add(intArrayOf(android.R.attr.state_pressed)) + colors.add(it) + } + focused?.let { + states.add(intArrayOf(android.R.attr.state_focused)) + colors.add(it) + } + normal?.let { + states.add(intArrayOf()) + colors.add(it) + } + + return ColorStateList(states.toTypedArray(), colors.toIntArray()) + } + private fun getResolvedIconTintList( menuItem: MenuItem, options: StackHeaderToolbarMenuItemOptions, @@ -387,88 +469,6 @@ internal class StackHeaderApplicator( // endregion - // region Helpers - - private fun resolveBackButtonTintList(config: StackHeaderConfigurationProviding): ColorStateList? { - val normal = config.backButtonTintColorNormal - val pressed = config.backButtonTintColorPressed - val focused = config.backButtonTintColorFocused - - if (normal == null && pressed == null && focused == null) return null - - val states = mutableListOf() - val colors = mutableListOf() - - pressed?.let { - states.add(intArrayOf(android.R.attr.state_pressed)) - colors.add(it) - } - focused?.let { - states.add(intArrayOf(android.R.attr.state_focused)) - colors.add(it) - } - normal?.let { - states.add(intArrayOf()) - colors.add(it) - } - - return ColorStateList(states.toTypedArray(), colors.toIntArray()) - } - - private fun computeScrollFlags(config: StackHeaderConfigurationProviding): Int { - var flags = 0 - if (config.scrollFlagScroll) flags = flags or SCROLL_FLAG_SCROLL - if (config.scrollFlagEnterAlways) flags = flags or SCROLL_FLAG_ENTER_ALWAYS - if (config.scrollFlagEnterAlwaysCollapsed) flags = flags or SCROLL_FLAG_ENTER_ALWAYS_COLLAPSED - if (config.scrollFlagExitUntilCollapsed) flags = flags or SCROLL_FLAG_EXIT_UNTIL_COLLAPSED - if (config.scrollFlagSnap) flags = flags or SCROLL_FLAG_SNAP - return flags - } - - private fun warnInvalidScrollFlagCombinations(config: StackHeaderConfigurationProviding) { - val anyDependentFlag = - config.scrollFlagEnterAlways || - config.scrollFlagEnterAlwaysCollapsed || - config.scrollFlagExitUntilCollapsed || - config.scrollFlagSnap - if (anyDependentFlag && !config.scrollFlagScroll) { - Log.e(TAG, "[RNScreens] scrollFlag* requires scrollFlagScroll to take effect.") - } - if (config.scrollFlagEnterAlwaysCollapsed && !config.scrollFlagEnterAlways) { - Log.e(TAG, "[RNScreens] scrollFlagEnterAlwaysCollapsed requires scrollFlagEnterAlways to take effect.") - } - } - - private fun resolveDefaultBackButtonIcon(): Drawable? = resolveDrawableAttr(wrappedContext, androidx.appcompat.R.attr.homeAsUpIndicator) - - private fun maybeApplyRTLCollapsingToolbarLayoutWorkaround( - coordinatorLayout: StackHeaderCoordinatorLayout, - config: StackHeaderConfigurationProviding, - appBar: StackHeaderAppBarLayout, - ) { - if (appBar is StackHeaderAppBarLayout.Collapsing && config.isRTL) { - appBar.measure( - View.MeasureSpec.makeMeasureSpec(coordinatorLayout.width, View.MeasureSpec.EXACTLY), - View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), - ) - moveDummyViewToFront(appBar.toolbar) - } - } - - private fun moveDummyViewToFront(toolbar: Toolbar) { - for (i in 0 until toolbar.childCount) { - val child = toolbar.getChildAt(i) - if (child !is StackHeaderSubview) { - val lp = child.layoutParams - toolbar.removeViewAt(i) - toolbar.addView(child, 0, lp) - return - } - } - } - - // endregion - companion object { private const val TAG = "StackHeaderApplicator" diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index 002adae3ac..c263a8d069 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -25,49 +25,10 @@ internal class StackHeaderCoordinatorLayout( internal val stackScreen: StackScreen, private val canNavigateBack: Boolean, ) : CoordinatorLayout(context) { - private val wrappedContext = - ContextThemeWrapper( - context, - R.style.Theme_Material3_DayNight_NoActionBar, - ) - - private val applicator = StackHeaderApplicator(wrappedContext) + // region Config attach / detach private var currentProvider: StackHeaderConfigurationProviding? = null private var currentDelegate: StackHeaderDelegate? = null - private var appBarLayout: StackHeaderAppBarLayout? = null - - private var toolbarMenuForwardIdMap = emptyMap() - private var toolbarMenuReverseIdMap = emptyMap() - - private val onNavigationIconClick: () -> Unit = { - val activity = - (stackScreen.context as? ReactContext)?.currentActivity - as? OnBackPressedDispatcherOwner - activity?.onBackPressedDispatcher?.onBackPressed() - } - - // region Configuration observer - - private val configObserver = - object : StackHeaderConfigurationObserver { - override fun onConfigChanged( - config: StackHeaderConfigurationProviding, - flags: StackHeaderUpdateFlags, - ) = processUpdate(config, flags) - - override fun onMenuItemUpdated( - id: String, - options: StackHeaderToolbarMenuItemOptions, - ) { - val toolbar = appBarLayout?.toolbar ?: return - applicator.updateToolbarMenuItem(toolbar, toolbarMenuForwardIdMap, id, options) - } - } - - // endregion - - // region Config attach / detach private val onHeaderConfigAttached = OnHeaderConfigurationAttachListener { provider, delegate -> @@ -92,16 +53,54 @@ internal class StackHeaderCoordinatorLayout( // endregion - // region Shadow state synchronization + // region Init + + internal var stackScreenWrapper: FrameLayout + + init { + isTransitionGroup = true + + stackScreenWrapper = FrameLayout(context).apply { addView(stackScreen) } + addView( + stackScreenWrapper, + LayoutParams(MATCH_PARENT, MATCH_PARENT), + ) + + stackScreen.registerHeaderConfigAttachListener(onHeaderConfigAttached) + } + + // endregion + + // region Configuration observer + + private val configObserver = + object : StackHeaderConfigurationObserver { + override fun onConfigChanged( + config: StackHeaderConfigurationProviding, + flags: StackHeaderUpdateFlags, + ) = processUpdate(config, flags) + + override fun onMenuItemUpdated( + id: String, + options: StackHeaderToolbarMenuItemOptions, + ) { + val toolbar = appBarLayout?.toolbar ?: return + applicator.updateToolbarMenuItem(toolbar, toolbarMenuForwardIdMap, id, options) + } + } + + // endregion + + // region Layout callbacks private val appBarOffsetListener = AppBarLayout.OnOffsetChangedListener { _, _ -> - syncShadowState() + onMaybeHeaderLayoutChanged() } private val appBarLayoutChangeListener = OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> - syncShadowState() + onMaybeHeaderLayoutChanged() } private fun attachAppBarListeners(appBar: StackHeaderAppBarLayout) { @@ -114,7 +113,7 @@ internal class StackHeaderCoordinatorLayout( appBar.removeOnLayoutChangeListener(appBarLayoutChangeListener) } - private fun syncShadowState() { + private fun onMaybeHeaderLayoutChanged() { val delegate = currentDelegate ?: return val provider = currentProvider ?: return val appBar = appBarLayout ?: return @@ -161,25 +160,27 @@ internal class StackHeaderCoordinatorLayout( // endregion - // region Init - - internal var stackScreenWrapper: FrameLayout + // region Header updates - init { - isTransitionGroup = true - - stackScreenWrapper = FrameLayout(context).apply { addView(stackScreen) } - addView( - stackScreenWrapper, - LayoutParams(MATCH_PARENT, MATCH_PARENT), + private val wrappedContext = + ContextThemeWrapper( + context, + R.style.Theme_Material3_DayNight_NoActionBar, ) - stackScreen.registerHeaderConfigAttachListener(onHeaderConfigAttached) - } + private val applicator = StackHeaderApplicator(wrappedContext) - // endregion + private var appBarLayout: StackHeaderAppBarLayout? = null - // region Flag-gated dispatch + private var toolbarMenuForwardIdMap = emptyMap() + private var toolbarMenuReverseIdMap = emptyMap() + + private val onNavigationIconClick: () -> Unit = { + val activity = + (stackScreen.context as? ReactContext)?.currentActivity + as? OnBackPressedDispatcherOwner + activity?.onBackPressedDispatcher?.onBackPressed() + } private fun processUpdate( provider: StackHeaderConfigurationProviding, @@ -225,7 +226,7 @@ internal class StackHeaderCoordinatorLayout( } } - syncShadowState() + onMaybeHeaderLayoutChanged() } // endregion diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index 58a069de0b..19413808c9 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -33,16 +33,23 @@ class StackHeaderConfig( StackHeaderDelegate, OnStackHeaderSubviewChangeListener, UIManagerListener { - // region Flag accumulation - private var pendingFlags = StackHeaderUpdateFlags.NONE - private var isInsideMountTransaction = false + init { + UIManagerHelper + .getFabricUIManagerNotNull(reactContext) + .addUIManagerEventListener(this) + } + + // region Handling configuration changes + private var configObserver: StackHeaderConfigurationObserver? = null override fun setConfigurationObserver(observer: StackHeaderConfigurationObserver?) { configObserver = observer } + private var pendingFlags = StackHeaderUpdateFlags.NONE + private fun invalidate(flags: StackHeaderUpdateFlags) { pendingFlags = pendingFlags or flags } @@ -59,31 +66,6 @@ class StackHeaderConfig( // endregion - // region UIManagerListener - - init { - UIManagerHelper - .getFabricUIManagerNotNull(reactContext) - .addUIManagerEventListener(this) - } - - override fun willMountItems(uiManager: UIManager) { - isInsideMountTransaction = true - } - - override fun didMountItems(uiManager: UIManager) { - isInsideMountTransaction = false - flushUpdates() - } - - override fun willDispatchViewUpdates(uiManager: UIManager) = Unit - - override fun didDispatchMountItems(uiManager: UIManager) = Unit - - override fun didScheduleMountItems(uiManager: UIManager) = Unit - - // endregion - // region Properties override var type: StackHeaderType by Delegates.observable(StackHeaderType.SMALL) { _, old, new -> @@ -273,9 +255,50 @@ class StackHeaderConfig( } private set + override fun onStackHeaderSubviewChanged() { + invalidate(StackHeaderUpdateFlags.SUBVIEWS) + } + + 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) + } + + 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 + } + } + + 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 + + internal fun getConfigSubviewAt(index: Int): StackHeaderSubview? = + listOfNotNull(backgroundSubview, leadingSubview, centerSubview, trailingSubview).getOrNull(index) + // endregion - // region StackHeaderDelegate + // region StackHeaderDelegate & Shadow state synchronization private val shadowStateProxy = ShadowStateProxy() @@ -359,48 +382,24 @@ class StackHeaderConfig( // endregion - // region Subview management - - override fun onStackHeaderSubviewChanged() { - invalidate(StackHeaderUpdateFlags.SUBVIEWS) - } + // region UIManagerListener - 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) - } + private var isInsideMountTransaction = false - 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 - } + override fun willMountItems(uiManager: UIManager) { + isInsideMountTransaction = true } - internal fun removeConfigSubviewAt(index: Int) { - getConfigSubviewAt(index)?.let { removeConfigSubview(it) } + override fun didMountItems(uiManager: UIManager) { + isInsideMountTransaction = false + flushUpdates() } - internal fun removeAllConfigSubviews() { - backgroundSubview?.let { removeConfigSubview(it) } - leadingSubview?.let { removeConfigSubview(it) } - centerSubview?.let { removeConfigSubview(it) } - trailingSubview?.let { removeConfigSubview(it) } - } + override fun willDispatchViewUpdates(uiManager: UIManager) = Unit - internal val configSubviewsCount: Int - get() = listOfNotNull(backgroundSubview, leadingSubview, centerSubview, trailingSubview).size + override fun didDispatchMountItems(uiManager: UIManager) = Unit - internal fun getConfigSubviewAt(index: Int): StackHeaderSubview? = - listOfNotNull(backgroundSubview, leadingSubview, centerSubview, trailingSubview).getOrNull(index) + override fun didScheduleMountItems(uiManager: UIManager) = Unit // endregion diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt index 4f5825466d..05d249559b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -13,6 +13,10 @@ class StackHeaderSubview( val reactContext: ReactContext, ) : ReactViewGroup(reactContext), StackHeaderSubviewProviding { + internal var onStackHeaderSubviewChangeListener: WeakReference? = null + + // region Properties + override var type: StackHeaderSubviewType = StackHeaderSubviewType.CENTER internal set @@ -25,8 +29,16 @@ class StackHeaderSubview( } internal set + // endregion + + // region StackHeaderSubviewProviding + override val view = this + // endregion + + // region Shadow state synchronization (origin) + private val shadowStateProxy = ShadowStateProxy(includesFrameSize = false) internal var stateWrapper by shadowStateProxy::stateWrapper @@ -38,7 +50,9 @@ class StackHeaderSubview( shadowStateProxy.updateStateIfNeeded(contentOffsetX = x, contentOffsetY = y) } - internal var onStackHeaderSubviewChangeListener: WeakReference? = null + // endregion + + // region Layout (frame size) private var yogaWidth: Int = 0 private var yogaHeight: Int = 0 @@ -84,4 +98,6 @@ class StackHeaderSubview( // Rely on parent to request the layout. parentAsView()?.requestLayout() } + + // endregion } From d0dc4aa8e31ee3f853f942234c3102bb911b1a15 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 10 Jun 2026 19:15:42 +0200 Subject: [PATCH 09/10] fix no header on first render bug --- .../header/StackHeaderCoordinatorLayout.kt | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt index c263a8d069..c39ac4220b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -46,6 +46,7 @@ internal class StackHeaderCoordinatorLayout( if (provider != null) { provider.setConfigurationObserver(configObserver) + processUpdate(provider, StackHeaderUpdateFlags.ALL) } else { removeHeader() } @@ -53,24 +54,6 @@ internal class StackHeaderCoordinatorLayout( // endregion - // region Init - - internal var stackScreenWrapper: FrameLayout - - init { - isTransitionGroup = true - - stackScreenWrapper = FrameLayout(context).apply { addView(stackScreen) } - addView( - stackScreenWrapper, - LayoutParams(MATCH_PARENT, MATCH_PARENT), - ) - - stackScreen.registerHeaderConfigAttachListener(onHeaderConfigAttached) - } - - // endregion - // region Configuration observer private val configObserver = @@ -178,7 +161,7 @@ internal class StackHeaderCoordinatorLayout( private val onNavigationIconClick: () -> Unit = { val activity = (stackScreen.context as? ReactContext)?.currentActivity - as? OnBackPressedDispatcherOwner + as? OnBackPressedDispatcherOwner activity?.onBackPressedDispatcher?.onBackPressed() } @@ -277,6 +260,24 @@ internal class StackHeaderCoordinatorLayout( // endregion + // region Init + + internal var stackScreenWrapper: FrameLayout + + init { + isTransitionGroup = true + + stackScreenWrapper = FrameLayout(context).apply { addView(stackScreen) } + addView( + stackScreenWrapper, + LayoutParams(MATCH_PARENT, MATCH_PARENT), + ) + + stackScreen.registerHeaderConfigAttachListener(onHeaderConfigAttached) + } + + // endregion + // region Teardown internal fun tearDown() { From e927adc0c1d475cbd4fdf9cb4e9536cba90e5ea1 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 10 Jun 2026 19:16:01 +0200 Subject: [PATCH 10/10] format Android --- .../rnscreens/gamma/stack/header/config/StackHeaderConfig.kt | 1 - .../rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index 19413808c9..5393bc2838 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -33,7 +33,6 @@ class StackHeaderConfig( StackHeaderDelegate, OnStackHeaderSubviewChangeListener, UIManagerListener { - init { UIManagerHelper .getFabricUIManagerNotNull(reactContext) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt index 05d249559b..498f292a97 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt @@ -38,7 +38,7 @@ class StackHeaderSubview( // endregion // region Shadow state synchronization (origin) - + private val shadowStateProxy = ShadowStateProxy(includesFrameSize = false) internal var stateWrapper by shadowStateProxy::stateWrapper