diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/helpers/IconResolver.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/helpers/IconResolver.kt new file mode 100644 index 0000000000..f55f3a0ce2 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/helpers/IconResolver.kt @@ -0,0 +1,63 @@ +package com.swmansion.rnscreens.gamma.helpers + +import android.content.Context +import android.graphics.drawable.Drawable + +/** + * Outcome of [IconResolver.resolve]. + * + * [Unchanged] is reported when the requested source matches the one the resolver + * last emitted, so the caller should keep whatever icon it already has (no reload + * happens). [Resolved] carries a freshly resolved drawable, or `null` when the + * source resolves to no icon (i.e. the icon should be cleared). + */ +internal sealed interface IconResolution { + object Unchanged : IconResolution + + data class Resolved( + val drawable: Drawable?, + ) : IconResolution +} + +internal class IconResolver { + private var lastDrawableName: String? = null + private var lastImageUri: String? = null + private var lastEmittedDrawableName: String? = null + private var lastEmittedImageUri: String? = null + + /** + * Resolves an icon from a drawable resource name or an image uri. + * + * The result is delivered to [onResult] synchronously for drawable resources and empty sources, + * and asynchronously for image uris. For image uris, the callback is only invoked if the + * resolved uri is still the latest requested source (stale requests are dropped). + */ + fun resolve( + context: Context, + drawableIconResourceName: String?, + imageIconUri: String?, + onResult: (IconResolution) -> Unit, + ) { + lastDrawableName = drawableIconResourceName + lastImageUri = imageIconUri + if (drawableIconResourceName == lastEmittedDrawableName && + imageIconUri == lastEmittedImageUri + ) { + onResult(IconResolution.Unchanged) + return + } + lastEmittedDrawableName = drawableIconResourceName + lastEmittedImageUri = imageIconUri + when { + drawableIconResourceName != null -> + onResult(IconResolution.Resolved(getSystemDrawableResource(context, drawableIconResourceName))) + imageIconUri != null -> + loadImage(context, imageIconUri) { drawable -> + if (imageIconUri == lastImageUri && lastDrawableName == null) { + onResult(IconResolution.Resolved(drawable)) + } + } + else -> onResult(IconResolution.Resolved(null)) + } + } +} 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 8fcc1072ed..e592eab0c3 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 @@ -607,7 +607,7 @@ internal class StackHeaderCoordinator( options: StackHeaderToolbarMenuItemOptions, ) { appBarLayout?.toolbar?.let { - toolbarMenuCoordinator.updateItem(it.menu, id, options) + toolbarMenuCoordinator.updateItem(it, id, options) } } 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 fe52ecf58c..58e1cfa4a2 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 @@ -3,16 +3,19 @@ package com.swmansion.rnscreens.gamma.stack.header.config 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.views.view.ReactViewGroup import com.swmansion.rnscreens.gamma.common.ShadowStateProxy -import com.swmansion.rnscreens.gamma.helpers.getSystemDrawableResource -import com.swmansion.rnscreens.gamma.helpers.loadImage +import com.swmansion.rnscreens.gamma.helpers.IconResolution +import com.swmansion.rnscreens.gamma.helpers.IconResolver 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.StackHeaderToolbarMenuItemIconSource import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemOptions +import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarUpdate import java.lang.ref.WeakReference @SuppressLint("ViewConstructor") @@ -55,34 +58,80 @@ class StackHeaderConfig( // Resolution happens in resolveBackButtonIconIfNeeded(), called from onAfterUpdateTransaction. internal var backButtonDrawableIconResourceName: String? = null internal var backButtonImageIconUri: String? = null - - private var lastResolvedDrawableIconResourceName: String? = null - private var lastResolvedImageIconUri: String? = null + private val backButtonIconResolver = IconResolver() internal fun resolveBackButtonIconIfNeeded() { - val name = backButtonDrawableIconResourceName - val uri = backButtonImageIconUri - - if (name == lastResolvedDrawableIconResourceName && uri == lastResolvedImageIconUri) { - return - } - - lastResolvedDrawableIconResourceName = name - lastResolvedImageIconUri = uri - - if (name != null) { - backButtonIcon = getSystemDrawableResource(context, name) - } else if (uri != null) { - loadImage(context, uri) { drawable -> - if (uri == lastResolvedImageIconUri) { - backButtonIcon = drawable - // We need to call notifyConfigChanged because icons are loaded asynchronously - // and regular update path might execute too early. + backButtonIconResolver.resolve( + reactContext, + backButtonDrawableIconResourceName, + backButtonImageIconUri, + ) { result -> + when (result) { + IconResolution.Unchanged -> Unit + is IconResolution.Resolved -> { + backButtonIcon = result.drawable notifyConfigChanged() } } - } else { - backButtonIcon = null + } + } + + 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() { + val nextResolvers = mutableMapOf() + + toolbarMenuItemIconSourceMap.forEach { (id, source) -> + val resolver = toolbarMenuItemIconResolvers[id] ?: IconResolver() + nextResolvers[id] = resolver + + resolver.resolve( + context = reactContext, + drawableIconResourceName = source.drawableIconResourceName, + imageIconUri = source.imageIconUri, + ) { result -> + val icon = + when (result) { + IconResolution.Unchanged -> toolbarMenuItemIcons[id] + is IconResolution.Resolved -> { + toolbarMenuItemIcons = toolbarMenuItemIcons + (id to result.drawable) + result.drawable + } + } + + applyToolbarMenuItemIcon(id, icon) + } + } + + toolbarMenuItemIconResolvers = nextResolvers + toolbarMenuItemIcons = toolbarMenuItemIcons.filterKeys { it in toolbarMenuItemIconSourceMap } + } + + private fun applyToolbarMenuItemIcon( + id: String, + icon: Drawable?, + ) { + val currentItems = toolbarMenuItems + val itemIndex = currentItems.indexOfFirst { it.id == id } + if (itemIndex == -1) return + + val item = currentItems[itemIndex] + if (item.icon != icon) { + val newItems = currentItems.toMutableList() + newItems[itemIndex] = item.copy(icon = icon) + + toolbarMenuItems = newItems + notifyConfigChanged() } } @@ -141,11 +190,43 @@ class StackHeaderConfig( delegate?.get()?.onConfigChange(this) } + /** + * 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 dispatchMenuItemUpdate( id: String, options: StackHeaderToolbarMenuItemOptions, + iconSource: StackHeaderToolbarMenuItemIconSource?, ) { - delegate?.get()?.onMenuItemUpdate(id, options) + if (iconSource == null) { + delegate?.get()?.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) + return + } + + resolver.resolve(reactContext, iconSource.drawableIconResourceName, iconSource.imageIconUri) { result -> + val icon = + when (result) { + IconResolution.Unchanged -> null // keep the current icon + 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)) + } } override fun onStackHeaderSubviewChange() = notifyConfigChanged() @@ -189,4 +270,8 @@ class StackHeaderConfig( // The order of the subviews MUST match the order of JS StackHeaderConfig children. internal fun getConfigSubviewAt(index: Int): StackHeaderSubview? = listOfNotNull(backgroundSubview, leadingSubview, centerSubview, trailingSubview).getOrNull(index) + + 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 1eca331e88..9e4c0b09db 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 @@ -1,9 +1,12 @@ package com.swmansion.rnscreens.gamma.stack.header.config +import android.util.Log import android.view.View +import androidx.core.graphics.toColorInt import com.facebook.react.bridge.JSApplicationIllegalArgumentException import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.ReactStylesDiffMap import com.facebook.react.uimanager.StateWrapper @@ -15,8 +18,12 @@ import com.facebook.react.viewmanagers.RNSStackHeaderConfigAndroidManagerInterfa 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.StackHeaderToolbarMenuItemDefaults +import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemIconSource import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemOptions import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemShowAsAction +import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarUpdate + +private const val TAG = "StackHeaderConfigViewManager" @ReactModule(name = StackHeaderConfigViewManager.REACT_CLASS) open class StackHeaderConfigViewManager : @@ -99,6 +106,7 @@ open class StackHeaderConfigViewManager : override fun onAfterUpdateTransaction(view: StackHeaderConfig) { super.onAfterUpdateTransaction(view) view.resolveBackButtonIconIfNeeded() + view.resolveToolbarMenuItemIconsIfNeeded() view.notifyConfigChanged() } @@ -203,12 +211,22 @@ open class StackHeaderConfigViewManager : view: StackHeaderConfig, value: ReadableArray?, ) { + val sourceMap = mutableMapOf() + view.toolbarMenuItems = value?.let { array -> (0 until array.size()).map { i -> val item = requireNotNull(array.getMap(i)) + val id = item.requireNotNullString("id") + + sourceMap[id] = + StackHeaderToolbarMenuItemIconSource( + item.getString("drawableIconResourceName") ?: StackHeaderToolbarMenuItemDefaults.DRAWABLE_ICON_RESOURCE_NAME, + item.readImageUri("imageIconResource", StackHeaderToolbarMenuItemDefaults.IMAGE_ICON_URI), + ) + StackHeaderToolbarMenuItemConfig( - id = item.requireNotNullString("id"), + id = id, title = item.readString("title", StackHeaderToolbarMenuItemDefaults.TITLE), hidden = item.readBoolean("hidden", StackHeaderToolbarMenuItemDefaults.HIDDEN), showAsAction = @@ -216,9 +234,31 @@ open class StackHeaderConfigViewManager : "showAsAction", StackHeaderToolbarMenuItemDefaults.SHOW_AS_ACTION, ), + icon = null, + iconTintColorNormal = + item.readColor( + "iconTintColorNormal", + StackHeaderToolbarMenuItemDefaults.ICON_TINT_COLOR_NORMAL, + ), + iconTintColorPressed = + item.readColor( + "iconTintColorPressed", + StackHeaderToolbarMenuItemDefaults.ICON_TINT_COLOR_PRESSED, + ), + iconTintColorFocused = + item.readColor( + "iconTintColorFocused", + StackHeaderToolbarMenuItemDefaults.ICON_TINT_COLOR_FOCUSED, + ), + iconTintColorDisabled = + item.readColor( + "iconTintColorDisabled", + StackHeaderToolbarMenuItemDefaults.ICON_TINT_COLOR_DISABLED, + ), ) } } ?: emptyList() + view.toolbarMenuItemIconSourceMap = sourceMap } override fun setToolbarMenuItemOptions( @@ -227,6 +267,7 @@ open class StackHeaderConfigViewManager : options: ReadableArray, ) { val map = options.getMap(0) ?: return + view.dispatchMenuItemUpdate( id, StackHeaderToolbarMenuItemOptions( @@ -237,7 +278,14 @@ open class StackHeaderConfigViewManager : "showAsAction", StackHeaderToolbarMenuItemDefaults.SHOW_AS_ACTION, ), + // The icon is resolved asynchronously and filled in by dispatchMenuItemUpdate. + icon = null, + iconTintColorNormal = map.readNullableColorUpdate("iconTintColorNormal"), + iconTintColorPressed = map.readNullableColorUpdate("iconTintColorPressed"), + iconTintColorFocused = map.readNullableColorUpdate("iconTintColorFocused"), + iconTintColorDisabled = map.readNullableColorUpdate("iconTintColorDisabled"), ), + map.readIconSource(), ) } @@ -275,9 +323,44 @@ private fun ReadableMap.readShowAsActionEnum( return toMenuItemShowAsActionEnum(stringValue) } -// Helpers for view commands: -// - not defined -> null (means "no update") -// - null -> default (means "reset to default value") +private fun ReadableMap.readColor( + key: String, + default: Int?, +): Int? = if (!this.hasKey(key) || this.isNull(key)) default else parseColor(key) + +private fun ReadableMap.parseColor(key: String): Int? = + try { + when (getType(key)) { + ReadableType.Number -> getInt(key) + ReadableType.String -> getString(key)?.toColorInt() + else -> null + } + } catch (e: Exception) { + Log.w(TAG, "[RNScreens] Could not parse color for key '$key': ${e.message}") + null + } + +private fun ReadableMap.readImageUri( + key: String, + default: String?, +): String? { + if (!this.hasKey(key) || this.getType(key) != ReadableType.Map) { + return default + } + + val imageMap = getMap(key) + return imageMap?.getString("uri") ?: default +} + +// Helpers for view commands. Each key has three states: +// - not defined -> null (no change) +// - null -> default (reset to default) +// - value -> value +// +// A plain `T?` return can encode this only when the field's default is non-null, +// so `null` unambiguously means "no change". Fields whose default is null (the +// tint colors) must return `StackHeaderToolbarUpdate?` instead, to tell "no +// change" (null) apart from "reset" (Reset). private fun ReadableMap.readNullableStringUpdate( key: String, default: String, @@ -311,6 +394,27 @@ private fun ReadableMap.readNullableShowAsActionEnumUpdate( } ?: default } +// Assumes null is the default color. +private fun ReadableMap.readNullableColorUpdate(key: String): StackHeaderToolbarUpdate? = + when { + !this.hasKey(key) -> null + this.isNull(key) -> StackHeaderToolbarUpdate.Reset + else -> StackHeaderToolbarUpdate.from(parseColor(key)) + } + +// The icon is composed of two JS keys that together form one source. It is +// "mentioned" iff at least one key is present; mentioned but empty (both null) +// means "clear", which the resolver turns into a Reset. +private fun ReadableMap.readIconSource(): StackHeaderToolbarMenuItemIconSource? { + if (!this.hasKey("drawableIconResourceName") && !this.hasKey("imageIconResource")) { + return null + } + return StackHeaderToolbarMenuItemIconSource( + drawableIconResourceName = this.getString("drawableIconResourceName"), + imageIconUri = this.readImageUri("imageIconResource", null), + ) +} + private fun toMenuItemShowAsActionEnum(value: String): StackHeaderToolbarMenuItemShowAsAction = when (value) { "always" -> StackHeaderToolbarMenuItemShowAsAction.ALWAYS 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 index 2520884872..b1698a59a5 100644 --- 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 @@ -1,8 +1,14 @@ package com.swmansion.rnscreens.gamma.stack.header.toolbar +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable import android.util.Log +import android.util.TypedValue import android.view.Menu import android.view.MenuItem +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.drawable.toDrawable +import androidx.core.view.MenuItemCompat import com.google.android.material.appbar.MaterialToolbar internal class StackHeaderToolbarMenuCoordinator( @@ -29,7 +35,7 @@ internal class StackHeaderToolbarMenuCoordinator( reverseIdMap[nativeId] = item.id val menuItem = toolbar.menu.add(Menu.NONE, nativeId, index, null) - applyOptions(menuItem, item.toOptions()) + applyOptions(toolbar, menuItem, item.toOptions()) } toolbar.setOnMenuItemClickListener { menuItem -> @@ -47,26 +53,39 @@ internal class StackHeaderToolbarMenuCoordinator( } internal fun updateItem( - menu: Menu, + toolbar: MaterialToolbar, id: String, options: StackHeaderToolbarMenuItemOptions, ) { val item = - forwardIdMap[id]?.let { menu.findItem(it) } ?: run { + forwardIdMap[id]?.let { toolbar.menu.findItem(it) } ?: run { Log.e(TAG, "[RNScreens] Unable to find menu item.") return } - applyOptions(item, options) + 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() = @@ -74,9 +93,149 @@ internal class StackHeaderToolbarMenuCoordinator( 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), ) + /** + * Returns drawable resized to 24 dp height. Width is scaled proportionally to keep the aspect + * ratio. + * + * Icon size source: https://m3.material.io/components/app-bars/specs - App bar icon size + */ + private fun getResizedDrawable( + toolbar: MaterialToolbar, + drawable: Drawable, + ): Drawable { + val targetHeightPx = + TypedValue + .applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 24f, + toolbar.resources.displayMetrics, + ).toInt() + + val intrinsicWidth = drawable.intrinsicWidth + val intrinsicHeight = drawable.intrinsicHeight + + val targetWidthPx = + if (intrinsicWidth > 0 && intrinsicHeight > 0) { + val aspectRatio = intrinsicWidth.toFloat() / intrinsicHeight.toFloat() + (targetHeightPx * aspectRatio).toInt() + } else { + targetHeightPx + } + + return drawable + .toBitmap(width = targetWidthPx, height = targetHeightPx) + .toDrawable(toolbar.resources) + } + + 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/header/toolbar/StackHeaderToolbarMenuItemConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemConfig.kt index 8a67938ac7..9b491fafda 100644 --- 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 @@ -1,8 +1,15 @@ package com.swmansion.rnscreens.gamma.stack.header.toolbar +import android.graphics.drawable.Drawable + data class StackHeaderToolbarMenuItemConfig( val id: String, val title: String, val hidden: Boolean, val showAsAction: StackHeaderToolbarMenuItemShowAsAction, + val icon: Drawable?, + val iconTintColorNormal: Int?, + val iconTintColorPressed: Int?, + val iconTintColorFocused: Int?, + val iconTintColorDisabled: Int?, ) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemDefaults.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemDefaults.kt index 8402ff71bd..837d07c202 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemDefaults.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemDefaults.kt @@ -11,4 +11,10 @@ internal object StackHeaderToolbarMenuItemDefaults { const val TITLE: String = "" const val HIDDEN: Boolean = false val SHOW_AS_ACTION: StackHeaderToolbarMenuItemShowAsAction = StackHeaderToolbarMenuItemShowAsAction.NEVER + val ICON_TINT_COLOR_NORMAL: Int? = null + val ICON_TINT_COLOR_PRESSED: Int? = null + val ICON_TINT_COLOR_FOCUSED: Int? = null + val ICON_TINT_COLOR_DISABLED: Int? = null + val DRAWABLE_ICON_RESOURCE_NAME: String? = null + val IMAGE_ICON_URI: String? = null } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemIconSource.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemIconSource.kt new file mode 100644 index 0000000000..b28787dff0 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarMenuItemIconSource.kt @@ -0,0 +1,6 @@ +package com.swmansion.rnscreens.gamma.stack.header.toolbar + +data class StackHeaderToolbarMenuItemIconSource( + val drawableIconResourceName: String?, + val imageIconUri: String?, +) 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 index a8dc68012f..c36d2f2730 100644 --- 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 @@ -1,13 +1,26 @@ package com.swmansion.rnscreens.gamma.stack.header.toolbar +import android.graphics.drawable.Drawable + /** * Partial update for a toolbar menu item. * - * A `null` field means "leave the current value unchanged". A non-null field - * replaces the current value on the underlying `MenuItem`. + * A `null` field means "leave the current value unchanged". */ data class StackHeaderToolbarMenuItemOptions( val title: String? = null, val hidden: Boolean? = null, val showAsAction: StackHeaderToolbarMenuItemShowAsAction? = null, -) + val icon: StackHeaderToolbarUpdate?, + val iconTintColorNormal: StackHeaderToolbarUpdate?, + val iconTintColorPressed: StackHeaderToolbarUpdate?, + val iconTintColorFocused: StackHeaderToolbarUpdate?, + val iconTintColorDisabled: StackHeaderToolbarUpdate?, +) { + val requiresIconTintColorUpdate: Boolean + get() = + iconTintColorNormal != null || + iconTintColorPressed != null || + iconTintColorFocused != null || + iconTintColorDisabled != null +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarUpdate.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarUpdate.kt new file mode 100644 index 0000000000..45fbea7e0e --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/toolbar/StackHeaderToolbarUpdate.kt @@ -0,0 +1,18 @@ +package com.swmansion.rnscreens.gamma.stack.header.toolbar + +sealed interface StackHeaderToolbarUpdate { + object Reset : StackHeaderToolbarUpdate + + data class Set( + val value: T, + ) : StackHeaderToolbarUpdate + + companion object { + fun from(value: T?): StackHeaderToolbarUpdate = + if (value != null) { + Set(value) + } else { + Reset + } + } +} 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 d10ba2b8e9..a5cfa317ab 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/index.ts +++ b/apps/src/tests/single-feature-tests/stack-v5/index.ts @@ -7,6 +7,7 @@ import TestStackSubviews from './test-stack-subviews-android'; import TestStackBackButton from './test-stack-back-button-android'; import TestStackToolbarMenuCommands from './test-stack-toolbar-menu-commands-android'; import TestStackToolbarMenuShowAsAction from './test-stack-toolbar-menu-show-as-action-android'; +import TestStackToolbarMenuIcon from './test-stack-toolbar-menu-icon-android'; const scenarios = { PreventNativeDismissSingleStack, @@ -17,6 +18,7 @@ const scenarios = { TestStackBackButton, TestStackToolbarMenuCommands, TestStackToolbarMenuShowAsAction, + TestStackToolbarMenuIcon, }; const StackScenarioGroup: ScenarioGroup = { diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/index.tsx new file mode 100644 index 0000000000..49d3ef0d6b --- /dev/null +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/index.tsx @@ -0,0 +1,375 @@ +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { Button, ScrollView, StyleSheet, Text } from 'react-native'; +import type { ColorValue } from 'react-native'; +import { createScenario } from '@apps/tests/shared/helpers'; +import { + StackContainer, + useStackNavigationContext, +} from '@apps/shared/gamma/containers/stack'; +import { SettingsPicker, SettingsSwitch } from '@apps/shared'; +import { Colors } from '@apps/shared/styling'; +import { + type StackHeaderConfigRef, + type StackHeaderToolbarMenuItemAndroid, + type StackHeaderToolbarMenuItemOptionsAndroid, +} from 'react-native-screens/experimental'; +import type { PlatformIconAndroid } from 'react-native-screens'; +import { scenarioDescription } from './scenario-descriptions'; + +type IdOption = 'item-1' | 'item-2' | 'item-3'; +type IconOption = 'none' | 'imageSource' | 'drawableResource'; +type TintColorOption = 'default' | 'purple' | 'red' | 'green'; +type ShowAsActionOption = 'always' | 'never' | 'ifRoom'; + +type CmdIconOption = 'no change' | IconOption; +type CmdTintColorOption = 'no change' | TintColorOption; + +const ID_OPTIONS: IdOption[] = ['item-1', 'item-2', 'item-3']; +const ICON_OPTIONS: IconOption[] = ['none', 'imageSource', 'drawableResource']; +const TINT_COLOR_OPTIONS: TintColorOption[] = [ + 'default', + 'purple', + 'red', + 'green', +]; +const SHOW_AS_ACTION_OPTIONS: ShowAsActionOption[] = [ + 'always', + 'never', + 'ifRoom', +]; + +const CMD_ICON_OPTIONS: CmdIconOption[] = ['no change', ...ICON_OPTIONS]; +const CMD_TINT_COLOR_OPTIONS: CmdTintColorOption[] = [ + 'no change', + ...TINT_COLOR_OPTIONS, +]; + +interface SlotConfig { + include: boolean; + id: IdOption; + icon: IconOption; + showAsAction: ShowAsActionOption; + tintColorNormal: TintColorOption; + tintColorPressed: TintColorOption; + tintColorFocused: TintColorOption; + tintColorDisabled: TintColorOption; +} + +type Slots = [SlotConfig, SlotConfig, SlotConfig]; + +const SLOT_DEFAULTS: Omit = { + include: true, + icon: 'imageSource', + showAsAction: 'always', + tintColorNormal: 'default', + tintColorPressed: 'default', + tintColorFocused: 'default', + tintColorDisabled: 'default', +}; + +const DEFAULT_SLOTS: Slots = [ + { ...SLOT_DEFAULTS, id: 'item-1' }, + { ...SLOT_DEFAULTS, id: 'item-2' }, + { ...SLOT_DEFAULTS, id: 'item-3' }, +]; + +const ITEM_TITLES: Record = { + 'item-1': 'Item 1', + 'item-2': 'Item 2', + 'item-3': 'Item 3', +}; + +function resolveIcon(option: IconOption): PlatformIconAndroid | undefined { + switch (option) { + case 'imageSource': + return { + type: 'imageSource', + imageSource: require('@assets/search_black.png'), + }; + case 'drawableResource': + return { + type: 'drawableResource', + name: 'sym_call_missed', + }; + default: + return undefined; + } +} + +function resolveTintColor(option: TintColorOption): ColorValue | undefined { + switch (option) { + case 'purple': + return Colors.PurpleLight100; + case 'red': + return Colors.RedLight100; + case 'green': + return Colors.GreenLight100; + default: + return undefined; + } +} + +function buildItems(slots: Slots): StackHeaderToolbarMenuItemAndroid[] { + return slots + .filter(s => s.include) + .map(s => ({ + id: s.id, + title: ITEM_TITLES[s.id], + showAsAction: s.showAsAction, + icon: resolveIcon(s.icon), + iconTintColorNormal: resolveTintColor(s.tintColorNormal), + iconTintColorPressed: resolveTintColor(s.tintColorPressed), + iconTintColorFocused: resolveTintColor(s.tintColorFocused), + iconTintColorDisabled: resolveTintColor(s.tintColorDisabled), + })); +} + +function updateSlotAt( + slots: Slots, + index: number, + patch: Partial, +): Slots { + return slots.map((s, i) => (i === index ? { ...s, ...patch } : s)) as Slots; +} + +const HEADER_TITLE = 'Toolbar Menu Icon Test'; + +export function App() { + return ( + + ); +} + +function MainScreen() { + const [slots, setSlots] = useState(DEFAULT_SLOTS); + const [lastClicked, setLastClicked] = useState(null); + + const [cmdTargetId, setCmdTargetId] = useState('item-1'); + const [cmdIcon, setCmdIcon] = useState('no change'); + const [cmdTintColorNormal, setCmdTintColorNormal] = + useState('no change'); + const [cmdTintColorPressed, setCmdTintColorPressed] = + useState('no change'); + const [cmdTintColorFocused, setCmdTintColorFocused] = + useState('no change'); + const [cmdTintColorDisabled, setCmdTintColorDisabled] = + useState('no change'); + + const headerConfigRef = useRef(null); + const { setRouteOptions, routeKey } = useStackNavigationContext(); + + useLayoutEffect(() => { + setRouteOptions(routeKey, { + headerConfig: { + title: HEADER_TITLE, + android: { + toolbarMenuItems: buildItems(DEFAULT_SLOTS), + onToolbarMenuItemClicked: event => + setLastClicked(event.nativeEvent.id), + }, + }, + headerConfigRef, + }); + }, [setRouteOptions, routeKey]); + + const applySlots = useCallback( + (next: Slots) => { + setSlots(next); + setRouteOptions(routeKey, { + headerConfig: { + title: HEADER_TITLE, + android: { + toolbarMenuItems: buildItems(next), + onToolbarMenuItemClicked: event => + setLastClicked(event.nativeEvent.id), + }, + }, + }); + }, + [setRouteOptions, routeKey], + ); + + const sendCommand = useCallback(() => { + const options: StackHeaderToolbarMenuItemOptionsAndroid = { + ...(cmdIcon !== 'no change' && { + icon: cmdIcon === 'none' ? undefined : resolveIcon(cmdIcon), + }), + ...(cmdTintColorNormal !== 'no change' && { + iconTintColorNormal: resolveTintColor(cmdTintColorNormal), + }), + ...(cmdTintColorPressed !== 'no change' && { + iconTintColorPressed: resolveTintColor(cmdTintColorPressed), + }), + ...(cmdTintColorFocused !== 'no change' && { + iconTintColorFocused: resolveTintColor(cmdTintColorFocused), + }), + ...(cmdTintColorDisabled !== 'no change' && { + iconTintColorDisabled: resolveTintColor(cmdTintColorDisabled), + }), + }; + headerConfigRef.current?.android?.setToolbarMenuItemOptions( + cmdTargetId, + options, + ); + }, [ + cmdTargetId, + cmdIcon, + cmdTintColorNormal, + cmdTintColorPressed, + cmdTintColorFocused, + cmdTintColorDisabled, + ]); + + return ( + + Send Command + + label="target id" + value={cmdTargetId} + items={ID_OPTIONS} + onValueChange={setCmdTargetId} + /> + + label="icon" + value={cmdIcon} + items={CMD_ICON_OPTIONS} + onValueChange={setCmdIcon} + /> + + label="tintColorNormal" + value={cmdTintColorNormal} + items={CMD_TINT_COLOR_OPTIONS} + onValueChange={setCmdTintColorNormal} + /> + + label="tintColorPressed" + value={cmdTintColorPressed} + items={CMD_TINT_COLOR_OPTIONS} + onValueChange={setCmdTintColorPressed} + /> + + label="tintColorFocused" + value={cmdTintColorFocused} + items={CMD_TINT_COLOR_OPTIONS} + onValueChange={setCmdTintColorFocused} + /> + + label="tintColorDisabled" + value={cmdTintColorDisabled} + items={CMD_TINT_COLOR_OPTIONS} + onValueChange={setCmdTintColorDisabled} + /> +