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 180d9f2ca4..126f7c9790 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 @@ -193,7 +193,7 @@ internal class StackHeaderConfig( // 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 `toolbarMenu` prop - // (resolveToolbarMenuItemIconsIfNeeded) and the imperative `setToolbarMenuElementOptions` + // (resolveToolbarMenuItemIconsIfNeeded) and the imperative `updateToolbarMenuElements` // view command (dispatchMenuElementUpdate). It is necessary to ensure consistency. private var toolbarMenuItemIcons = mapOf() @@ -368,13 +368,26 @@ internal class StackHeaderConfig( // region Imperative menu item commands + /** + * Applies a batch of toolbar menu item view commands. Each update follows the same semantics + * as [dispatchMenuElementUpdate]: updates without icon changes are delivered immediately, + * updates with icon changes wait for (possibly async) image resolution. + */ + internal fun dispatchMenuElementUpdates( + updates: List>, + ) { + for ((id, options, iconSource) in updates) { + dispatchMenuElementUpdate(id, options, iconSource) + } + } + /** * 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. */ - internal fun dispatchMenuElementUpdate( + private fun dispatchMenuElementUpdate( id: String, options: StackHeaderToolbarMenuElementOptions, iconSource: StackHeaderToolbarMenuItemIconSource?, 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 d4147e99c6..34c752f019 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 @@ -232,17 +232,25 @@ internal open class StackHeaderConfigViewManager : view.toolbarMenuItemIconSourceMap = iconSources } - override fun setToolbarMenuElementOptions( + override fun updateToolbarMenuElements( view: StackHeaderConfig, - id: String, - options: ReadableArray, + updates: ReadableArray, ) { - val map = options.getMap(0) ?: return - view.dispatchMenuElementUpdate( - id, - StackHeaderToolbarMenuMapper.parseMenuElementOptions(map), - StackHeaderToolbarMenuMapper.parseMenuElementIconSource(map), - ) + val parsedUpdates = + buildList { + for (i in 0 until updates.size()) { + val map = updates.getMap(i) ?: continue + val id = map.getString("id") ?: continue + add( + Triple( + id, + StackHeaderToolbarMenuMapper.parseMenuElementOptions(map), + StackHeaderToolbarMenuMapper.parseMenuElementIconSource(map), + ), + ) + } + } + view.dispatchMenuElementUpdates(parsedUpdates) } companion object { 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 40a2cec582..4f045583be 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/index.ts +++ b/apps/src/tests/single-feature-tests/stack-v5/index.ts @@ -17,6 +17,7 @@ import TestStackToolbarMenuTitle from './test-stack-toolbar-menu-title-android'; import TestStackToolbarMenuIcon from './test-stack-toolbar-menu-icon-android'; import TestStackToolbarMenuGroups from './test-stack-toolbar-menu-groups-android'; import TestStackToolbarNestedMenu from './test-stack-toolbar-nested-menu-android'; +import TestStackToolbarMenuBatchCommands from './test-stack-toolbar-menu-batch-commands-android'; import TestStackHeaderSubviewOnPress from './test-stack-header-subview-onpress-ios'; // Scenario entry-point components — each scenario's default export re-exported @@ -36,6 +37,7 @@ export { default as TestStackToolbarMenuShowAsAction } from './test-stack-toolba export { default as TestStackToolbarMenuTitle } from './test-stack-toolbar-menu-title-android'; export { default as TestStackToolbarMenuIcon } from './test-stack-toolbar-menu-icon-android'; export { default as TestStackToolbarNestedMenu } from './test-stack-toolbar-nested-menu-android'; +export { default as TestStackToolbarMenuBatchCommands } from './test-stack-toolbar-menu-batch-commands-android'; const scenarios = { TestStackPreventNativeDismissSingleStack, @@ -54,6 +56,7 @@ const scenarios = { TestStackToolbarMenuTitle, TestStackToolbarMenuIcon, TestStackToolbarNestedMenu, + TestStackToolbarMenuBatchCommands, }; const StackScenarioGroup: ScenarioGroup = { diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-batch-commands-android/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-batch-commands-android/index.tsx new file mode 100644 index 0000000000..be90067a1e --- /dev/null +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-batch-commands-android/index.tsx @@ -0,0 +1,178 @@ +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { Button, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { createScenario } from '@apps/tests/shared/helpers'; +import { + StackContainer, + useStackNavigationContext, +} from '@apps/shared/gamma/containers/stack'; +import { ToastProvider, useToast } from '@apps/shared'; +import { Colors } from '@apps/shared/styling'; +import type { + StackHeaderConfigRef, + StackHeaderToolbarMenuBaseAndroid, +} from 'react-native-screens/experimental'; +import { scenarioDescription } from './scenario-description'; + +const ITEM_IDS = ['apple', 'banana', 'cherry', 'date'] as const; + +const ITEMS_CONFIG: { id: string; title: string }[] = [ + { id: 'apple', title: 'Apple' }, + { id: 'banana', title: 'Banana' }, + { id: 'cherry', title: 'Cherry' }, + { id: 'date', title: 'Date' }, +]; + +function buildMenu( + onGroupChange: (groupId: string, selectedIds: string[]) => void, +): StackHeaderToolbarMenuBaseAndroid { + return { + groups: [ + { + groupId: 'fruits', + singleSelection: false, + onSelectionChange: ids => onGroupChange('fruits', ids), + }, + ], + children: ITEMS_CONFIG.map(({ id, title }) => ({ + type: 'menuItem', + id, + title, + groupId: 'fruits', + initialToggleState: id === 'apple', + })), + }; +} + +const HEADER_TITLE = 'Toolbar Menu Batch Commands Test'; + +function TestStackToolbarMenuBatchCommands() { + return ( + + {}) }, + }, + }, + }, + ]} + /> + + ); +} + +function MainScreen() { + const [lastEvent, setLastEvent] = useState(null); + + const headerConfigRef = useRef(null); + const { setRouteOptions, routeKey } = useStackNavigationContext(); + const toast = useToast(); + + const showToast = useCallback( + (text: string) => { + toast.push({ backgroundColor: Colors.GreenDark120, message: text }); + }, + [toast], + ); + + const handleGroupChange = useCallback( + (groupId: string, selectedIds: string[]) => { + const msg = `${groupId}: ${JSON.stringify(selectedIds)}`; + setLastEvent(msg); + showToast(msg); + }, + [showToast], + ); + + useLayoutEffect(() => { + setRouteOptions(routeKey, { + headerConfig: { + title: HEADER_TITLE, + android: { + toolbarMenu: buildMenu(handleGroupChange), + }, + }, + headerConfigRef, + }); + }, [setRouteOptions, routeKey, handleGroupChange]); + + const selectAll = useCallback(() => { + headerConfigRef.current?.android?.updateToolbarMenuElements( + ITEM_IDS.map(id => ({ id, options: { checked: true } })), + ); + }, []); + + const deselectAll = useCallback(() => { + headerConfigRef.current?.android?.updateToolbarMenuElements( + ITEM_IDS.map(id => ({ id, options: { checked: false } })), + ); + }, []); + + const selectAppleAndCherry = useCallback(() => { + headerConfigRef.current?.android?.updateToolbarMenuElements([ + { id: 'apple', options: { checked: true } }, + { id: 'banana', options: { checked: false } }, + { id: 'cherry', options: { checked: true } }, + { id: 'date', options: { checked: false } }, + ]); + }, []); + + const selectBananaSingle = useCallback(() => { + headerConfigRef.current?.android?.updateToolbarMenuElements({ + id: 'banana', + options: { checked: true }, + }); + }, []); + + return ( + + Batch Commands + +