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 e592eab0c3..c4dfe7781d 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 @@ -1,6 +1,7 @@ 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 @@ -13,6 +14,7 @@ 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 @@ -65,7 +67,9 @@ internal class StackHeaderCoordinator( private var lastBackgroundSubviewCollapseMode: StackHeaderSubviewCollapseMode? = null private var lastBackButtonVisible: Boolean? = null - private var lastBackButtonTintColor: Int? = 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 @@ -171,7 +175,9 @@ internal class StackHeaderCoordinator( appBarLayout = null managedTitleView = null lastBackButtonVisible = null - lastBackButtonTintColor = null + lastBackButtonTintColorNormal = null + lastBackButtonTintColorPressed = null + lastBackButtonTintColorFocused = null lastBackButtonIcon = null lastScrollFlags = null clearCachedRebuildTriggers() @@ -424,13 +430,18 @@ internal class StackHeaderCoordinator( val visible = canNavigateBack && !config.backButtonHidden val visibilityChanged = visible != lastBackButtonVisible val iconChanged = config.backButtonIcon !== lastBackButtonIcon - val tintChanged = config.backButtonTintColor != lastBackButtonTintColor + val tintChanged = + config.backButtonTintColorNormal != lastBackButtonTintColorNormal || + config.backButtonTintColorPressed != lastBackButtonTintColorPressed || + config.backButtonTintColorFocused != lastBackButtonTintColorFocused if (!visibilityChanged && !iconChanged && !tintChanged) return lastBackButtonVisible = visible lastBackButtonIcon = config.backButtonIcon - lastBackButtonTintColor = config.backButtonTintColor + lastBackButtonTintColorNormal = config.backButtonTintColorNormal + lastBackButtonTintColorPressed = config.backButtonTintColorPressed + lastBackButtonTintColorFocused = config.backButtonTintColorFocused if (!visible) { toolbar.navigationIcon = null @@ -438,18 +449,52 @@ internal class StackHeaderCoordinator( return } - // Clear previous tint before setting icon to ensure clean state toolbar.clearNavigationIconTint() - toolbar.navigationIcon = config.backButtonIcon ?: resolveDefaultBackButtonIcon() + val baseDrawable = + config.backButtonIcon + ?.let { getResizedDrawable(toolbar, it) } + ?: resolveDefaultBackButtonIcon() - config.backButtonTintColor?.let { - toolbar.setNavigationIconTint(it) - } + 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 diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderDrawableUtils.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderDrawableUtils.kt new file mode 100644 index 0000000000..615d602772 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderDrawableUtils.kt @@ -0,0 +1,41 @@ +package com.swmansion.rnscreens.gamma.stack.header + +import android.graphics.drawable.Drawable +import android.util.TypedValue +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.drawable.toDrawable +import com.google.android.material.appbar.MaterialToolbar + +/** + * 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 + */ +internal 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 + } + + val bitmap = drawable.toBitmap(width = targetWidthPx, height = targetHeightPx) + bitmap.density = toolbar.resources.displayMetrics.densityDpi + return bitmap.toDrawable(toolbar.resources) +} 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 58e1cfa4a2..2a7d8f6458 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 @@ -34,7 +34,11 @@ class StackHeaderConfig( internal set override var backButtonHidden: Boolean = false internal set - override var backButtonTintColor: Int? = null + override var backButtonTintColorNormal: Int? = null + internal set + override var backButtonTintColorPressed: Int? = null + internal set + override var backButtonTintColorFocused: Int? = null internal set override var backButtonIcon: Drawable? = null internal set 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 e28817b2f2..bf525f347b 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 @@ -10,7 +10,9 @@ interface StackHeaderConfigProviding { val hidden: Boolean val transparent: Boolean val backButtonHidden: Boolean - val backButtonTintColor: Int? + val backButtonTintColorNormal: Int? + val backButtonTintColorPressed: Int? + val backButtonTintColorFocused: Int? val backButtonIcon: Drawable? val scrollFlagScroll: Boolean val scrollFlagEnterAlways: Boolean 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 9e4c0b09db..337480cb3b 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 @@ -151,11 +151,25 @@ open class StackHeaderConfigViewManager : view.backButtonHidden = value } - override fun setBackButtonTintColor( + override fun setBackButtonTintColorNormal( view: StackHeaderConfig, value: Int?, ) { - view.backButtonTintColor = value + view.backButtonTintColorNormal = value + } + + override fun setBackButtonTintColorPressed( + view: StackHeaderConfig, + value: Int?, + ) { + view.backButtonTintColorPressed = value + } + + override fun setBackButtonTintColorFocused( + view: StackHeaderConfig, + value: Int?, + ) { + view.backButtonTintColorFocused = value } override fun setBackButtonDrawableIconResourceName( 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 b1698a59a5..a9bc51d7b0 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 @@ -3,13 +3,11 @@ 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 +import com.swmansion.rnscreens.gamma.stack.header.getResizedDrawable internal class StackHeaderToolbarMenuCoordinator( private val onItemClicked: (id: String) -> Unit, @@ -100,40 +98,6 @@ internal class StackHeaderToolbarMenuCoordinator( 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, diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-back-button-android/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-back-button-android/index.tsx index 98dd043652..44b260ecce 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-back-button-android/index.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-back-button-android/index.tsx @@ -31,13 +31,17 @@ const ICON_OPTIONS: IconOption[] = [ interface Config { backButtonHidden: boolean; - tintColor: TintColorOption; + tintColorNormal: TintColorOption; + tintColorPressed: TintColorOption; + tintColorFocused: TintColorOption; icon: IconOption; } const DEFAULT_CONFIG: Config = { backButtonHidden: false, - tintColor: 'default', + tintColorNormal: 'default', + tintColorPressed: 'default', + tintColorFocused: 'default', icon: 'default', }; @@ -51,7 +55,7 @@ const ConfigContext = React.createContext<{ function resolveTintColor( option: TintColorOption, -): StackHeaderConfigPropsAndroid['backButtonTintColor'] { +): StackHeaderConfigPropsAndroid['backButtonTintColorNormal'] { switch (option) { case 'purple': return Colors.PurpleLight100; @@ -88,7 +92,9 @@ function buildHeaderConfig(config: Config): StackHeaderConfigProps { title: 'Back Button Test', backButtonHidden: config.backButtonHidden, android: { - backButtonTintColor: resolveTintColor(config.tintColor), + backButtonTintColorNormal: resolveTintColor(config.tintColorNormal), + backButtonTintColorPressed: resolveTintColor(config.tintColorPressed), + backButtonTintColorFocused: resolveTintColor(config.tintColorFocused), backButtonIcon: resolveIcon(config.icon), }, }; @@ -136,9 +142,21 @@ function ConfigControls() { onValueChange={v => updateConfig('backButtonHidden', v)} /> - label="tintColor" - value={config.tintColor} - onValueChange={v => updateConfig('tintColor', v)} + label="tintColorNormal" + value={config.tintColorNormal} + onValueChange={v => updateConfig('tintColorNormal', v)} + items={TINT_COLOR_OPTIONS} + /> + + label="tintColorPressed" + value={config.tintColorPressed} + onValueChange={v => updateConfig('tintColorPressed', v)} + items={TINT_COLOR_OPTIONS} + /> + + label="tintColorFocused" + value={config.tintColorFocused} + onValueChange={v => updateConfig('tintColorFocused', v)} items={TINT_COLOR_OPTIONS} /> diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-back-button-android/scenario.md b/apps/src/tests/single-feature-tests/stack-v5/test-stack-back-button-android/scenario.md index 7e1093c832..b6171e259d 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-back-button-android/scenario.md +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-back-button-android/scenario.md @@ -2,25 +2,43 @@ ## Details -**Description:** This test focuses on back button in the header on Android. Back button should be visible on screens that are not a root screen of the stack, unless it's explicitly disabled. Back button allows customization via custom icon and tint color. Focus on changing props in runtime and ensure consistent behavior. +**Description:** This test focuses on back button in the header on Android. +Back button should be visible on screens that are not a root screen of the +stack, unless it's explicitly disabled. Back button allows customization via +custom icon and tint color (normal, pressed, and focused states). Focus on +changing props in runtime and ensure consistent behavior. **OS test creation version:** API 36 ## E2E test -Other - the automation is planned (in limited scope) but not implemented yet. +TBD: Automation is planned in limited scope but not yet implemented. ## Prerequisites - Android emulator +- To test `backButtonTintColorFocused`: enable **Hardware Input** in the + emulator settings, then use arrow keys to enable keyboard focus and press + **Ctrl+Tab** to move keyboard focus into the header toolbar. ## Note (Optional) -Interaction with prevent native dismiss mechanism is tested in separate tests (`prevent-native-dismiss-single-stack` and `prevent-native-dismiss-nested-stack`). +Interaction with prevent native dismiss mechanism is tested in separate tests +(`prevent-native-dismiss-single-stack` and +`prevent-native-dismiss-nested-stack`). -Applying tint color to non-transparent image results in the entire image being covered in tint color. +Applying tint color to non-transparent image results in the entire image being +covered in tint color. -When support for color scheme is added, we should check if default back arrow adapts to color scheme change. +**Native platform limitation:** if `backButtonTintColorNormal` is left at its +default (undefined) but `backButtonTintColorPressed` or +`backButtonTintColorFocused` is explicitly set, the icon becomes invisible in +the normal state. This is Android platform behavior, not a library bug. Always +set `backButtonTintColorNormal` alongside other state tints if you want the +icon visible in the normal state. + +When support for color scheme is added, we should check if default back arrow +adapts to color scheme change. ## Steps @@ -28,92 +46,124 @@ When support for color scheme is added, we should check if default back arrow ad 1. Launch the app and navigate to the **Stack Back Button** screen. -- [ ] Expected: Root screen is shown. No back button is visible in the header (root screen has no predecessor). +- [ ] Root screen is shown. No back button is visible in the header + (root screen has no predecessor). 2. Tap **Push screen**. -- [ ] Expected: Pushed screen is shown. A default back button (default arrow icon, default tint) is visible in the header. +- [ ] Pushed screen is shown. A default back button (default arrow + icon, default tint) is visible in the header. --- ### Icon: `default` -3. Set tintColor = `purple`. +3. Set tintColorNormal = `purple`. + +- [ ] Back arrow changes to purple immediately. + +4. Set tintColorNormal = `default`. + +- [ ] Back arrow returns to default tint. + +5. Set tintColorPressed = `red`. + +- [ ] Back arrow appears transparent (native limitation) but turns red when + held down. + +6. Set tintColorNormal = `purple`. + +- [ ] Back arrow changes to purple immediately. When pressed, it turns red. + +7. Set tintColorFocused = `green`. -- [ ] Expected: Back arrow changes to purple immediately. +- [ ] Enable keyboard navigation using arrow keys. Use Ctrl+Tab to move keyboard + focus to the toolbar and focus the back button — it turns green while + focused. -4. Set tintColor = `default`. +8. Set tintColorPressed = `default`, set tintColorFocused = `default`. -- [ ] Expected: Back arrow returns to default tint. +- [ ] Pressed and focused states return to the normal purple tint. -5. Toggle backButtonHidden = `true`. +9. Toggle backButtonHidden = `true`. -- [ ] Expected: Back button disappears from the header. +- [ ] Back button disappears from the header. -6. Toggle backButtonHidden = `false`. +10. Toggle backButtonHidden = `false`. -- [ ] Expected: Back button reappears with default icon and default tint. +- [ ] Back button reappears with default icon and purple tint. --- ### Icon: `imageSource` and changes when hidden -7. Set icon = `imageSource`. +11. Set tintColorNormal = `default` and icon = `imageSource`. -- [ ] Expected: Back button changes to the custom image — white background with a black arrow, no tint applied. +- [ ] Back button changes to the custom image — white background + with a black arrow, no tint applied. +- [ ] The custom image is scaled to approximately 24 dp height, + visually similar in size to the default back arrow. -8. Set tintColor = `red`. +12. Set tintColorNormal = `red`. -- [ ] Expected: The entire image is covered in red (non-transparent image is fully tinted). +- [ ] The entire image is covered in red (non-transparent image is + fully tinted). -9. Set tintColor = `default`. +13. Set tintColorNormal = `default`. -- [ ] Expected: Custom image returns to its original appearance (white background, black arrow). +- [ ] Custom image returns to its original appearance (white + background, black arrow). -10. Toggle backButtonHidden = `true`. +14. Toggle backButtonHidden = `true`. -- [ ] Expected: Back button disappears. +- [ ] Back button disappears. -11. Set tintColor = `green`. +15. Set tintColorNormal = `green`. -- [ ] Expected: No visible change (back button is hidden). +- [ ] No visible change (back button is hidden). -12. Toggle backButtonHidden = `false`. +16. Toggle backButtonHidden = `false`. -- [ ] Expected: Back button reappears with `imageSource` icon and green tint already applied. +- [ ] Back button reappears with `imageSource` icon and green tint + already applied. --- ### Icon: `drawableResource` -13. Set tintColor = `default`, set icon = `drawableResource`. +17. Set tintColorNormal = `default`, set icon = `drawableResource`. -- [ ] Expected: Back button changes to `sym_call_missed` drawable — white and red (its native colors). +- [ ] Back button changes to `sym_call_missed` drawable — white + and red (its native colors). +- [ ] The drawable icon is scaled to approximately 24 dp height. -14. Set tintColor = `purple`. +18. Set tintColorNormal = `purple`. -- [ ] Expected: Drawable icon changes to purple. +- [ ] Drawable icon changes to purple. -15. Set tintColor = `default`. +19. Set tintColorNormal = `default`. -- [ ] Expected: Drawable icon returns to its native white and red appearance. +- [ ] Drawable icon returns to its native white and red appearance. -16. Toggle backButtonHidden = `true`. +20. Toggle backButtonHidden = `true`. -- [ ] Expected: Back button disappears. +- [ ] Back button disappears. -17. Toggle backButtonHidden = `false`. +21. Toggle backButtonHidden = `false`. -- [ ] Expected: Back button reappears with `drawableResource` icon and default tint. +- [ ] Back button reappears with `drawableResource` icon and + default tint. --- ### Config applied before push -18. Go back to the root screen. Set icon = `imageSource`, tintColor = `purple`. +22. Go back to the root screen. Set icon = `imageSource`, + tintColorNormal = `purple`. -- [ ] Expected: Root screen is shown. No back button visible (root screen). +- [ ] Root screen is shown. No back button visible (root screen). -19. Tap **Push screen**. +23. Tap **Push screen**. -- [ ] Expected: Pushed screen appears with the `imageSource` icon and purple tint already applied. +- [ ] Pushed screen appears with the `imageSource` icon and purple + tint already applied. diff --git a/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts b/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts index 378b5743a3..848f0b7e4c 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts +++ b/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts @@ -250,7 +250,7 @@ export interface StackHeaderConfigPropsAndroid { */ trailingSubview?: StackHeaderToolbarSubviewAndroid | undefined; /** - * @summary Tint color applied to the back button icon. + * @summary Tint color applied to the back button icon in its normal state. * * When `undefined`, the default tint color is used. This applies to the * native back arrow and `drawableResource` icons that have an associated @@ -258,7 +258,30 @@ export interface StackHeaderConfigPropsAndroid { * * @platform android */ - backButtonTintColor?: ColorValue | undefined; + backButtonTintColorNormal?: ColorValue | undefined; + /** + * @summary Tint color applied to the back button icon when it is pressed. + * + * @remarks + * Due to native platform limitations, if you set this prop, you must also + * provide `backButtonTintColorNormal`. Otherwise, the icon will become + * transparent. + * + * @platform android + */ + backButtonTintColorPressed?: ColorValue | undefined; + /** + * @summary Tint color applied to the back button icon when it is focused + * (e.g. by keyboard navigation). + * + * @remarks + * Due to native platform limitations, if you set this prop, you must also + * provide `backButtonTintColorNormal`. Otherwise, the icon will become + * transparent. + * + * @platform android + */ + backButtonTintColorFocused?: ColorValue | undefined; /** * @summary Custom icon for the back button. * diff --git a/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts index 3f1307e7bd..945a75bebe 100644 --- a/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts +++ b/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts @@ -48,7 +48,9 @@ export interface NativeProps extends ViewProps { // Android-specific props type?: CT.WithDefault; - backButtonTintColor?: ColorValue | undefined; + backButtonTintColorNormal?: ColorValue | undefined; + backButtonTintColorPressed?: ColorValue | undefined; + backButtonTintColorFocused?: ColorValue | undefined; backButtonDrawableIconResourceName?: string | undefined; backButtonImageIconResource?: ImageSource | undefined;