diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt index 03a1f79238..872c5603e4 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt @@ -29,6 +29,8 @@ 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( @@ -46,6 +48,11 @@ internal class StackHeaderCoordinator( 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 @@ -163,6 +170,7 @@ internal class StackHeaderCoordinator( lastBackButtonIcon = null lastScrollFlags = null clearCachedRebuildTriggers() + toolbarMenuCoordinator.clear() } private fun cacheRebuildTriggers(config: StackHeaderConfigProviding) { @@ -333,6 +341,14 @@ internal class StackHeaderCoordinator( applyScrollFlags(appBar, config) applyBackButton(appBar.toolbar, config) + applyToolbarMenu(appBar.toolbar, config) + } + + private fun applyToolbarMenu( + toolbar: MaterialToolbar, + config: StackHeaderConfigProviding, + ) { + toolbarMenuCoordinator.buildMenu(toolbar, config.toolbarMenuItems) } private fun applyBackgroundCollapseMode(config: StackHeaderConfigProviding) { @@ -581,6 +597,13 @@ internal class StackHeaderCoordinator( } } + internal fun handleMenuItemUpdate( + id: String, + options: StackHeaderToolbarMenuItemOptions, + ) { + toolbarMenuCoordinator.updateItem(id, options) + } + private fun resolveDefaultBackButtonIcon(): Drawable? = resolveDrawableAttr(wrappedContext, androidx.appcompat.R.attr.homeAsUpIndicator) companion object { 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 eb5ece9069..21b58fb1d4 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinatorLayout.kt @@ -8,8 +8,9 @@ import androidx.activity.OnBackPressedDispatcherOwner 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.OnHeaderConfigChangeListener +import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigDelegate import com.swmansion.rnscreens.gamma.stack.header.config.StackHeaderConfigProviding +import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemOptions import com.swmansion.rnscreens.gamma.stack.screen.StackScreen import java.lang.ref.WeakReference @@ -36,7 +37,7 @@ internal class StackHeaderCoordinatorLayout( /** * This callback is used to detect when header config is attached. - * This allows us to configure listener for header config changes. + * This allows us to configure the delegate for header config interactions. */ private val onHeaderConfigAttach = OnHeaderConfigAttachListener { config -> @@ -46,20 +47,27 @@ internal class StackHeaderCoordinatorLayout( private var isHeaderUpdatePending = false /** - * This callback is used to listen for header config changes. - * We use [isHeaderUpdatePending] to batch changes and pass them to [headerCoordinator]. + * 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 onHeaderConfigChange = - OnHeaderConfigChangeListener { - if (!isHeaderUpdatePending) { - isHeaderUpdatePending = true - // Read currentConfig when the runnable executes, not when it's posted, - // to avoid applying a stale config that was swapped out in the meantime. - post { - isHeaderUpdatePending = false - headerCoordinator.applyHeaderConfig(this, currentConfig) + private val headerConfigDelegate = + object : StackHeaderConfigDelegate { + override fun onConfigChange(config: StackHeaderConfigProviding) { + if (!isHeaderUpdatePending) { + isHeaderUpdatePending = true + // Read currentConfig when the runnable executes, not when it's posted, + // to avoid applying a stale config that was swapped out in the meantime. + post { + isHeaderUpdatePending = false + headerCoordinator.applyHeaderConfig(this@StackHeaderCoordinatorLayout, currentConfig) + } } } + + override fun onMenuItemUpdate(id: String, options: StackHeaderToolbarMenuItemOptions) { + headerCoordinator.handleMenuItemUpdate(id, options) + } } private var currentConfig: StackHeaderConfigProviding? = null @@ -88,10 +96,10 @@ internal class StackHeaderCoordinatorLayout( private fun handleHeaderConfigAttach(config: StackHeaderConfigProviding?) { // Disconnect old config to prevent spurious updates from a detached config - currentConfig?.setOnConfigChangeListener(null) + currentConfig?.setDelegate(null) currentConfig = config - config?.setOnConfigChangeListener(onHeaderConfigChange) + config?.setDelegate(headerConfigDelegate) // We run this even if config is null to properly remove the header if config // is removed in runtime. diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigChangeListener.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigChangeListener.kt deleted file mode 100644 index c597482ea7..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/OnHeaderConfigChangeListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.swmansion.rnscreens.gamma.stack.header.config - -fun interface OnHeaderConfigChangeListener { - fun onHeaderConfigChange(config: StackHeaderConfigProviding) -} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index 74f3e56533..8ca3f7c08e 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 @@ -11,6 +11,8 @@ import com.swmansion.rnscreens.gamma.helpers.loadImage import com.swmansion.rnscreens.gamma.stack.header.subview.OnStackHeaderSubviewChangeListener import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewType +import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemConfig +import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemOptions import java.lang.ref.WeakReference @SuppressLint("ViewConstructor") @@ -45,6 +47,9 @@ class StackHeaderConfig( override var scrollFlagSnap: Boolean = false internal set + override var toolbarMenuItems: List = emptyList() + 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. @@ -109,14 +114,29 @@ class StackHeaderConfig( ) } - private var onConfigChangeListener: WeakReference? = null + internal lateinit var eventEmitter: StackHeaderConfigEventEmitter - override fun setOnConfigChangeListener(listener: OnHeaderConfigChangeListener?) { - onConfigChangeListener = listener?.let { WeakReference(it) } + 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) + } + + private var delegate: WeakReference? = null + + override fun setDelegate(delegate: StackHeaderConfigDelegate?) { + this.delegate = delegate?.let { WeakReference(it) } } internal fun notifyConfigChanged() { - onConfigChangeListener?.get()?.onHeaderConfigChange(this) + delegate?.get()?.onConfigChange(this) + } + + internal fun dispatchMenuItemUpdate(id: String, options: StackHeaderToolbarMenuItemOptions) { + delegate?.get()?.onMenuItemUpdate(id, options) } override fun onStackHeaderSubviewChange() = notifyConfigChanged() 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/StackHeaderConfigDelegate.kt new file mode 100644 index 0000000000..760bc2fd77 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigDelegate.kt @@ -0,0 +1,8 @@ +package com.swmansion.rnscreens.gamma.stack.header.config + +import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemOptions + +interface StackHeaderConfigDelegate { + fun onConfigChange(config: StackHeaderConfigProviding) + fun onMenuItemUpdate(id: String, options: StackHeaderToolbarMenuItemOptions) +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigEventEmitter.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigEventEmitter.kt new file mode 100644 index 0000000000..4f7dc312cb --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigEventEmitter.kt @@ -0,0 +1,16 @@ +package com.swmansion.rnscreens.gamma.stack.header.config + +import com.facebook.react.bridge.ReactContext +import com.swmansion.rnscreens.gamma.common.event.BaseEventEmitter +import com.swmansion.rnscreens.gamma.stack.header.toolbar.event.StackHeaderToolbarMenuItemClickedEvent + +internal class StackHeaderConfigEventEmitter( + reactContext: ReactContext, + viewTag: Int, +) : BaseEventEmitter(reactContext, viewTag) { + internal fun emitOnToolbarMenuItemClicked(id: String) { + reactEventDispatcher.dispatchEvent( + StackHeaderToolbarMenuItemClickedEvent(surfaceId, viewTag, id), + ) + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt index abda960a8a..e1a6321704 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt @@ -2,6 +2,7 @@ package com.swmansion.rnscreens.gamma.stack.header.config 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 { val type: StackHeaderType @@ -20,6 +21,7 @@ interface StackHeaderConfigProviding { val centerSubview: StackHeaderSubviewProviding? val trailingSubview: StackHeaderSubviewProviding? val backgroundSubview: StackHeaderSubviewProviding? + val toolbarMenuItems: List val isRTL: Boolean @@ -29,5 +31,7 @@ interface StackHeaderConfigProviding { contentOffsetY: Int, ) - fun setOnConfigChangeListener(listener: OnHeaderConfigChangeListener?) + fun onMenuItemClick(id: String) + + fun setDelegate(delegate: StackHeaderConfigDelegate?) } 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 aa7ac413a7..de902f87be 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 @@ -2,6 +2,7 @@ package com.swmansion.rnscreens.gamma.stack.header.config import android.view.View import com.facebook.react.bridge.JSApplicationIllegalArgumentException +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.ReactStylesDiffMap @@ -12,6 +13,9 @@ import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.viewmanagers.RNSStackHeaderConfigAndroidManagerDelegate import com.facebook.react.viewmanagers.RNSStackHeaderConfigAndroidManagerInterface import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview +import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemConfig +import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemFieldUpdate +import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemOptions @ReactModule(name = StackHeaderConfigViewManager.REACT_CLASS) open class StackHeaderConfigViewManager : @@ -27,6 +31,14 @@ open class StackHeaderConfigViewManager : override fun createViewInstance(reactContext: ThemedReactContext) = StackHeaderConfig(reactContext) + override fun addEventEmitters( + reactContext: ThemedReactContext, + view: StackHeaderConfig, + ) { + super.addEventEmitters(reactContext, view) + view.onViewManagerAddEventEmitters() + } + override fun getDelegate(): ViewManagerDelegate = delegate /** @@ -186,7 +198,63 @@ open class StackHeaderConfigViewManager : view.scrollFlagSnap = value } + override fun setToolbarMenuItems( + view: StackHeaderConfig, + value: ReadableArray?, + ) { + view.toolbarMenuItems = + value?.let { array -> + (0 until array.size()).map { i -> + val item = requireNotNull(array.getMap(i)) + StackHeaderToolbarMenuItemConfig( + id = item.requireNotNullString("id"), + title = item.requireNotNullString("title"), + hidden = item.getBoolean("hidden"), + ) + } + } ?: emptyList() + } + + override fun setToolbarMenuItemOptions( + view: StackHeaderConfig, + id: String, + options: ReadableArray, + ) { + val map = options.getMap(0) ?: return + view.dispatchMenuItemUpdate( + id, + StackHeaderToolbarMenuItemOptions( + title = map.readStringFieldUpdate("title"), + hidden = map.readBooleanFieldUpdate("hidden"), + ), + ) + } + companion object { const val REACT_CLASS = "RNSStackHeaderConfigAndroid" } } + +private fun ReadableMap.requireNotNullString(key: String): String { + return requireNotNull(this.getString(key)) { + "[RNScreens] toolbarMenuItem $key property must not be null." + } +} + +private fun ReadableMap.readStringFieldUpdate( + key: String, +): StackHeaderToolbarMenuItemFieldUpdate = + when { + !this.hasKey(key) -> StackHeaderToolbarMenuItemFieldUpdate.Absent + this.isNull(key) -> StackHeaderToolbarMenuItemFieldUpdate.Reset + else -> StackHeaderToolbarMenuItemFieldUpdate.Set(this.getString(key)!!) + } + +private fun ReadableMap.readBooleanFieldUpdate( + key: String, +): StackHeaderToolbarMenuItemFieldUpdate = + when { + !this.hasKey(key) -> StackHeaderToolbarMenuItemFieldUpdate.Absent + this.isNull(key) -> StackHeaderToolbarMenuItemFieldUpdate.Reset + else -> StackHeaderToolbarMenuItemFieldUpdate.Set(this.getBoolean(key)) + } 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 new file mode 100644 index 0000000000..9ef719b248 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuCoordinator.kt @@ -0,0 +1,88 @@ +package com.swmansion.rnscreens.gamma.stack.header.toolbar + +import android.util.Log +import android.view.Menu +import com.google.android.material.appbar.MaterialToolbar + +internal class StackHeaderToolbarMenuCoordinator( + private val onItemClicked: (id: String) -> Unit, +) { + private var currentMenu: Menu? = null + private val forwardIdMap = HashMap() + private val reverseIdMap = HashMap() + private var lastMenuItems: List = emptyList() + + internal fun buildMenu( + 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 + toolbar.menu + .add(Menu.NONE, nativeId, index, item.title) + .apply { + isVisible = !item.hidden + + // This property will be exposed to the user in the future + setShowAsAction(android.view.MenuItem.SHOW_AS_ACTION_NEVER) + } + } + + toolbar.setOnMenuItemClickListener { menuItem -> + reverseIdMap[menuItem.itemId]?.let(onItemClicked) + true + } + + currentMenu = toolbar.menu + lastMenuItems = items + } + + fun clear() { + currentMenu?.clear() + currentMenu = null + forwardIdMap.clear() + reverseIdMap.clear() + lastMenuItems = emptyList() + } + + fun updateItem( + id: String, + options: StackHeaderToolbarMenuItemOptions, + ) { + val menu = currentMenu + if (menu == null) { + Log.e(TAG, "[RNScreens] Attempted to update item in non-existing menu.") + return + } + + val item = forwardIdMap[id]?.let { menu.findItem(it) } ?: run { + Log.e(TAG, "[RNScreens] Unable to find menu item.") + return + } + + when (val title = options.title) { + StackHeaderToolbarMenuItemFieldUpdate.Absent -> Unit + StackHeaderToolbarMenuItemFieldUpdate.Reset -> item.title = "" + is StackHeaderToolbarMenuItemFieldUpdate.Set -> item.title = title.value + } + + when (val hidden = options.hidden) { + StackHeaderToolbarMenuItemFieldUpdate.Absent -> Unit + StackHeaderToolbarMenuItemFieldUpdate.Reset -> item.isVisible = true + is StackHeaderToolbarMenuItemFieldUpdate.Set -> item.isVisible = !hidden.value + } + } + + companion object { + private const val TAG = "StackHeaderToolbarMenuCoordinator" + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemConfig.kt new file mode 100644 index 0000000000..4ab74ee17d --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemConfig.kt @@ -0,0 +1,7 @@ +package com.swmansion.rnscreens.gamma.stack.header.toolbar + +data class StackHeaderToolbarMenuItemConfig( + val id: String, + val title: String, + val hidden: Boolean, +) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemFieldUpdate.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemFieldUpdate.kt new file mode 100644 index 0000000000..bb3532f679 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemFieldUpdate.kt @@ -0,0 +1,7 @@ +package com.swmansion.rnscreens.gamma.stack.header.toolbar + +sealed interface StackHeaderToolbarMenuItemFieldUpdate { + data object Absent : StackHeaderToolbarMenuItemFieldUpdate + data object Reset : StackHeaderToolbarMenuItemFieldUpdate + data class Set(val value: T) : StackHeaderToolbarMenuItemFieldUpdate +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemOptions.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemOptions.kt new file mode 100644 index 0000000000..b52fd538a4 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemOptions.kt @@ -0,0 +1,6 @@ +package com.swmansion.rnscreens.gamma.stack.header.toolbar + +data class StackHeaderToolbarMenuItemOptions( + val title: StackHeaderToolbarMenuItemFieldUpdate, + val hidden: StackHeaderToolbarMenuItemFieldUpdate, +) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/event/StackHeaderToolbarMenuItemClickedEvent.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/event/StackHeaderToolbarMenuItemClickedEvent.kt new file mode 100644 index 0000000000..be71f8fe30 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/event/StackHeaderToolbarMenuItemClickedEvent.kt @@ -0,0 +1,30 @@ +package com.swmansion.rnscreens.gamma.stack.header.toolbar.event + +import com.facebook.react.bridge.Arguments +import com.facebook.react.uimanager.events.Event +import com.swmansion.rnscreens.gamma.common.event.NamingAwareEventType + +class StackHeaderToolbarMenuItemClickedEvent( + surfaceId: Int, + viewTag: Int, + private val id: String, +) : Event(surfaceId, viewTag), NamingAwareEventType { + override fun getEventName() = EVENT_NAME + + override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME + + override fun canCoalesce(): Boolean = false + + override fun getEventData() = Arguments.createMap().apply { putString(EK_ID, id) } + + companion object : NamingAwareEventType { + const val EVENT_NAME = "topToolbarMenuItemClicked" + const val EVENT_REGISTRATION_NAME = "onToolbarMenuItemClicked" + + private const val EK_ID = "id" + + override fun getEventName() = EVENT_NAME + + override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME + } +} \ No newline at end of file diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.tsx b/apps/src/shared/gamma/containers/stack/StackContainer.tsx index 950827ef7d..f0993fb23c 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.tsx +++ b/apps/src/shared/gamma/containers/stack/StackContainer.tsx @@ -65,7 +65,7 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { return ( {stackNavState.stack.map( - ({ options: { headerConfig, ...options }, activityMode, routeKey, name }) => { + ({ options: { headerConfig, headerConfigRef, ...options }, activityMode, routeKey, name }) => { const stackNavigationContext: StackNavigationContextPayload = { routeKey, routeOptions: { ...options }, @@ -94,7 +94,7 @@ export function StackContainer({ routeConfigs }: StackContainerProps) { {headerConfig !== undefined && ( - + )} diff --git a/apps/src/shared/gamma/containers/stack/StackContainer.types.ts b/apps/src/shared/gamma/containers/stack/StackContainer.types.ts index 15cd96e543..459e0eec1a 100644 --- a/apps/src/shared/gamma/containers/stack/StackContainer.types.ts +++ b/apps/src/shared/gamma/containers/stack/StackContainer.types.ts @@ -2,6 +2,7 @@ import React from 'react'; import { StackScreenProps, StackHeaderConfigProps, + StackHeaderConfigRef, } from 'react-native-screens/experimental'; /// Route definition @@ -11,6 +12,7 @@ export type StackRouteOptions = Omit< 'children' | 'activityMode' | 'screenKey' > & { headerConfig?: StackHeaderConfigProps | undefined; + headerConfigRef?: React.Ref | undefined; }; /** diff --git a/apps/src/tests/single-feature-tests/stack-v5/index.ts b/apps/src/tests/single-feature-tests/stack-v5/index.ts index 5c6f505d04..7074bc5ac9 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/index.ts +++ b/apps/src/tests/single-feature-tests/stack-v5/index.ts @@ -5,6 +5,7 @@ import AnimationAndroid from './test-animation-android'; import TestStackSimpleNav from './test-stack-simple-nav'; import TestStackSubviews from './test-stack-subviews-android'; import TestStackBackButton from './test-stack-back-button-android'; +import TestStackToolbarMenuCommands from './test-stack-toolbar-menu-commands-android'; const scenarios = { PreventNativeDismissSingleStack, @@ -13,6 +14,7 @@ const scenarios = { TestStackSimpleNav, TestStackSubviews, TestStackBackButton, + TestStackToolbarMenuCommands, }; const StackScenarioGroup: ScenarioGroup = { diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-commands-android/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-commands-android/index.tsx new file mode 100644 index 0000000000..717d014768 --- /dev/null +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-commands-android/index.tsx @@ -0,0 +1,268 @@ +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { Button, ScrollView, StyleSheet, Text } from 'react-native'; +import { + createScenario, + ScenarioDescription, +} from '@apps/tests/shared/helpers'; +import { + StackContainerWithDynamicRouteConfigs, + useStackNavigationContext, + useStackRouteConfigContext, +} from '@apps/shared/gamma/containers/stack'; +import { SettingsPicker, SettingsSwitch } from '@apps/shared'; +import { Colors } from '@apps/shared/styling'; +import { + type StackHeaderConfigRef, + type ToolbarMenuItemOptionsAndroid, +} from 'react-native-screens/experimental'; + +const scenarioDescription: ScenarioDescription = { + name: 'Stack Toolbar Menu Commands', + key: 'test-stack-toolbar-menu-commands-android', + details: 'Tests toolbar menu items prop config and imperative commands.', + platforms: ['android'], +}; + +type IdOption = 'item-1' | 'item-2' | 'item-3'; +type TitleOption = 'Title A' | 'Title B' | 'Title C' | 'Long Title' | 'Changed'; + +const ID_OPTIONS: IdOption[] = ['item-1', 'item-2', 'item-3']; +const TITLE_OPTIONS: TitleOption[] = [ + 'Title A', + 'Title B', + 'Title C', + 'Long Title', + 'Changed', +]; + +interface SlotConfig { + include: boolean; + id: IdOption; + title: TitleOption; + hidden: boolean; +} + +type Slots = [SlotConfig, SlotConfig, SlotConfig]; + +const DEFAULT_SLOTS: Slots = [ + { include: true, id: 'item-1', title: 'Title A', hidden: false }, + { include: true, id: 'item-2', title: 'Title B', hidden: false }, + { include: true, id: 'item-3', title: 'Title C', hidden: false }, +]; + +function buildItems(slots: Slots) { + return slots + .filter(s => s.include) + .map(({ id, title, hidden }) => ({ id, title, hidden })); +} + +function updateSlotAt( + slots: Slots, + index: number, + patch: Partial, +): Slots { + return slots.map((s, i) => (i === index ? { ...s, ...patch } : s)) as Slots; +} + +const InitialSlotsContext = React.createContext<{ + initialSlots: Slots; + setInitialSlots: React.Dispatch>; +}>({ + initialSlots: DEFAULT_SLOTS, + setInitialSlots: () => {}, +}); + +export function App() { + const [initialSlots, setInitialSlots] = useState(DEFAULT_SLOTS); + + return ( + + + + ); +} + +function RootScreen() { + const { initialSlots, setInitialSlots } = + React.useContext(InitialSlotsContext); + const { updateRouteConfigWithOptions } = useStackRouteConfigContext(); + const { push, setRouteOptions, routeKey } = useStackNavigationContext(); + + useEffect(() => { + setRouteOptions(routeKey, { + headerConfig: { title: 'Menu Commands Test' }, + }); + }, [setRouteOptions, routeKey]); + + useEffect(() => { + updateRouteConfigWithOptions('Pushed', { + headerConfig: { + title: 'Toolbar Menu Commands Test', + android: { toolbarMenuItems: buildItems(initialSlots) }, + }, + }); + }, [initialSlots, updateRouteConfigWithOptions]); + + return ( + + Initial Items for Screen 2 + + setInitialSlots(prev => updateSlotAt(prev, i, patch)) + } + /> + Navigation +