From 9de7aed11f7bb147d5243d31c9d7fe96657eef3f Mon Sep 17 00:00:00 2001 From: marius-ck Date: Thu, 17 Sep 2026 12:15:08 +0300 Subject: [PATCH 1/2] feat: Support remaining SF Symbols --- .changeset/sf-symbol-configuration.md | 10 + docs/docs/docs/guides/_meta.json | 2 +- docs/docs/docs/guides/sf-symbols.mdx | 173 ++++++++ docs/docs/docs/guides/standalone-usage.mdx | 2 +- .../docs/guides/usage-with-expo-router.mdx | 2 + .../guides/usage-with-react-navigation.mdx | 13 + .../example-shared/src/Examples/SFSymbols.tsx | 33 +- .../ios/Bridge/BottomTabsBridge.h | 2 + .../ios/Bridge/BottomTabsBridge.m | 7 +- .../ios/RCTTabViewComponentView.mm | 85 ++++ .../ios/SFSymbolOptions.swift | 409 ++++++++++++++++++ .../ios/TabItem.swift | 40 +- .../ios/TabView/LegacyTabView.swift | 1 + .../ios/TabView/NewTabView.swift | 1 + .../ios/TabViewImpl.swift | 24 +- .../ios/TabViewProvider.swift | 6 + .../react-native-bottom-tabs/src/TabView.tsx | 57 ++- .../src/TabViewNativeComponent.ts | 23 + .../react-native-bottom-tabs/src/index.tsx | 6 + .../react-native-bottom-tabs/src/types.ts | 162 ++++++- 20 files changed, 1039 insertions(+), 19 deletions(-) create mode 100644 .changeset/sf-symbol-configuration.md create mode 100644 docs/docs/docs/guides/sf-symbols.mdx create mode 100644 packages/react-native-bottom-tabs/ios/SFSymbolOptions.swift diff --git a/.changeset/sf-symbol-configuration.md b/.changeset/sf-symbol-configuration.md new file mode 100644 index 00000000..4f643f75 --- /dev/null +++ b/.changeset/sf-symbol-configuration.md @@ -0,0 +1,10 @@ +--- +"react-native-bottom-tabs": minor +"@bottom-tabs/react-navigation": minor +--- + +Support the full set of SF Symbol configuration options on tab icons. + +`AppleIcon` now accepts `size`, `color`, `weight`, `scale`, `variableValue`, `variableValueMode`, `renderingMode`, `colors` and `colorRenderingMode` alongside `sfSymbol`, matching the options React Navigation exposes for SF Symbols. Symbol effects and content transitions are not included, because a tab bar item renders a still image and never runs symbol animations. + +Existing icons are unaffected: with no options set, symbols render exactly as before. diff --git a/docs/docs/docs/guides/_meta.json b/docs/docs/docs/guides/_meta.json index fa7dd987..23638eef 100644 --- a/docs/docs/docs/guides/_meta.json +++ b/docs/docs/docs/guides/_meta.json @@ -1 +1 @@ -["usage-with-react-navigation", "usage-with-expo-router", "usage-with-one", "standalone-usage", {"type": "divider"}, "web-platform-support", "handling-scrollview-insets", "usage-with-vector-icons", {"type": "divider"}, "android-native-styling", "edge-to-edge-support"] +["usage-with-react-navigation", "usage-with-expo-router", "usage-with-one", "standalone-usage", {"type": "divider"}, "web-platform-support", "handling-scrollview-insets", "usage-with-vector-icons", "sf-symbols", {"type": "divider"}, "android-native-styling", "edge-to-edge-support"] diff --git a/docs/docs/docs/guides/sf-symbols.mdx b/docs/docs/docs/guides/sf-symbols.mdx new file mode 100644 index 00000000..98702413 --- /dev/null +++ b/docs/docs/docs/guides/sf-symbols.mdx @@ -0,0 +1,173 @@ +import { Badge } from '@theme'; + +# SF Symbols + +Tab icons on Apple platforms can be [SF Symbols](https://developer.apple.com/sf-symbols/) instead of images. Pass an object with an `sfSymbol` key wherever an icon is accepted, and add any of the configuration options below alongside it. + +```tsx +focusedIcon: { sfSymbol: 'house.fill' } +``` + +:::note +SF Symbols are only available on Apple platforms. On Android and web, pass an image with `require()` or a `{ uri }` object instead. +::: + +The options mirror the ones [React Navigation exposes for SF Symbols](https://reactnavigation.org/docs/8.x/icons/#sf-symbols), so an icon configured for one works the same here. + +## Options + +Only `sfSymbol` is required. Leave the rest out to keep the system defaults, which is what you want for most tabs. + +### `sfSymbol` + +Name of the symbol to display, for example `house` or `house.fill`. Browse the full set in Apple's [SF Symbols app](https://developer.apple.com/sf-symbols/). + +- Type: `SFSymbol` + +### `size` + +Point size of the symbol. + +- Type: `number` +- Default: the size the tab bar picks for the current platform + +### `color` + +Color of the symbol. Used as the tint in `monochrome` mode, and as the fallback for `colors.primary` in `hierarchical` and `palette` modes. + +Setting it opts the icon out of `tabBarActiveTintColor` and `tabBarInactiveTintColor`, since the symbol then carries a color of its own. + +- Type: `ColorValue` + +### `weight` + +Stroke weight of the symbol. Accepts a name or its numeric equivalent. + +- Type: `'thin' | 'ultralight' | 'light' | 'regular' | 'medium' | 'semibold' | 'bold' | 'extrabold' | 'black' | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900` +- Default: `'regular'` + +```tsx +focusedIcon: { sfSymbol: 'star.fill', weight: 'semibold' } +``` + +### `scale` + +Scale variant of the symbol, relative to the surrounding text. + +- Type: `'small' | 'medium' | 'large'` +- Default: `'medium'` + +### `variableValue` + +Value between `0` and `1` used to customize variable symbols. + +Variable symbols such as `wifi` or `speaker.wave.3` have layers that activate progressively to represent a magnitude. `0` renders the fewest layers, `1` the full symbol. It has no effect on symbols that are not variable. + +- Type: `number` +- Requires iOS 16 or later + +```tsx +focusedIcon: { sfSymbol: 'wifi', variableValue: 0.6 } +``` + +### `variableValueMode` + +How the partial state described by `variableValue` is rendered. + +- `automatic`: the system chooses based on the symbol. +- `color`: fades inactive layers using opacity. +- `draw`: partially draws layers instead of fading them. + +- Type: `'automatic' | 'color' | 'draw'` +- Default: `'automatic'` + +Ignored on earlier versions. + +### `renderingMode` + +How the symbol's layers are colored. + +- `monochrome`: single color tint. +- `hierarchical`: a hierarchy derived from a single color. +- `palette`: explicit colors per layer, taken from `colors`. +- `multicolor`: the symbol's built-in multicolor scheme. + +- Type: `'monochrome' | 'hierarchical' | 'palette' | 'multicolor'` +- Default: `'monochrome'` + +Anything other than `monochrome` opts the icon out of `tabBarActiveTintColor` and `tabBarInactiveTintColor`, since the symbol then carries colors of its own. + +```tsx +focusedIcon: { + sfSymbol: 'person.crop.circle.badge.plus', + renderingMode: 'hierarchical', + color: '#AF52DE', +} +``` + +### `colors` + +Colors used by the non-monochrome rendering modes. + +- `hierarchical` uses `primary` as the base color. +- `palette` uses `primary`, `secondary` and `tertiary` for each layer. +- `multicolor` ignores them. + +Falls back to `color` for `primary` when unset. + +- Type: `{ primary?: ColorValue; secondary?: ColorValue; tertiary?: ColorValue }` + +```tsx +focusedIcon: { + sfSymbol: 'square.grid.3x2.fill', + renderingMode: 'palette', + colors: { primary: '#FF3B30', secondary: '#34C759' }, +} +``` + +### `colorRenderingMode` + +How color is applied across the symbol's layers. + +- `automatic`: the system chooses based on the symbol. +- `flat`: a solid color per layer. +- `gradient`: a gradient derived from each layer's color. + +- Type: `'automatic' | 'flat' | 'gradient'` +- Default: `'automatic'` + +Ignored on earlier versions. + +## Configuring focused and unfocused states separately + +Each state takes its own icon object, so the options can differ between them. + +```tsx +{ + key: 'home', + title: 'Home', + focusedIcon: { sfSymbol: 'house.fill', weight: 'bold' }, + unfocusedIcon: { sfSymbol: 'house', weight: 'light' }, +} +``` + +With React Navigation, return a different object per state from `tabBarIcon`: + +```tsx + + focused + ? { sfSymbol: 'house.fill', weight: 'bold' } + : { sfSymbol: 'house', weight: 'light' }, + }} +/> +``` + +## Unsupported options + +React Navigation's `SFSymbol` component also accepts `effect` and `contentTransition`, which animate a symbol as it changes. Neither applies here: a tab bar item renders a still image, and the system never runs symbol animations on one. Both are omitted rather than silently ignored. + +`style` is likewise absent, since a tab item is laid out by the tab bar rather than by your styles. diff --git a/docs/docs/docs/guides/standalone-usage.mdx b/docs/docs/docs/guides/standalone-usage.mdx index d9f02c43..ef63043a 100644 --- a/docs/docs/docs/guides/standalone-usage.mdx +++ b/docs/docs/docs/guides/standalone-usage.mdx @@ -234,7 +234,7 @@ Each route in the `routes` array can have the following properties: - `key`: Unique identifier for the route - `title`: Display title for the tab -- `focusedIcon`: Icon to show when tab is active +- `focusedIcon`: Icon to show when tab is active. Either an image or an SF Symbol, which takes its own [configuration options](/docs/guides/sf-symbols) - `unfocusedIcon`: Icon to show when tab is inactive (optional) - `iconRenderingMode`: Rendering mode for icons. Use `'original'` to preserve multicolor icons instead of applying the native tab tint. - `badge`: Badge text to display on the tab diff --git a/docs/docs/docs/guides/usage-with-expo-router.mdx b/docs/docs/docs/guides/usage-with-expo-router.mdx index 111025c3..3e4f78bc 100644 --- a/docs/docs/docs/guides/usage-with-expo-router.mdx +++ b/docs/docs/docs/guides/usage-with-expo-router.mdx @@ -66,6 +66,8 @@ export default function TabLayout() { > [!NOTE] SF Symbols are only available on Apple platforms. On Android and web, pass an image with `require()` or a `{ uri }` object instead. +SF Symbols accept further configuration, such as `weight`, `scale`, `renderingMode` and per-layer `colors`. See the [SF Symbols guide](/docs/guides/sf-symbols) for every supported option. + For props and more information, see the [React Navigation integration guide](/docs/guides/usage-with-react-navigation), which documents every accepted `tabBarIcon` source. Example: [okwasniewski/ExpoNativeTabs](https://github.com/okwasniewski/ExpoNativeTabs) diff --git a/docs/docs/docs/guides/usage-with-react-navigation.mdx b/docs/docs/docs/guides/usage-with-react-navigation.mdx index 78e49d55..643a0c61 100644 --- a/docs/docs/docs/guides/usage-with-react-navigation.mdx +++ b/docs/docs/docs/guides/usage-with-react-navigation.mdx @@ -282,6 +282,19 @@ Function that given `{ focused: boolean }` returns `ImageSource` or `AppleIcon` /> ``` +SF Symbols accept further configuration, such as `weight`, `scale`, `renderingMode` and per-layer `colors`: + +```tsx +tabBarIcon: () => ({ + sfSymbol: 'person', + weight: 'semibold', + renderingMode: 'hierarchical', + color: '#AF52DE', +}), +``` + +See the [SF Symbols guide](/docs/guides/sf-symbols) for every supported option. + :::note SF Symbols are only supported on Apple platforms. ::: diff --git a/packages/example-shared/src/Examples/SFSymbols.tsx b/packages/example-shared/src/Examples/SFSymbols.tsx index f9490fbb..0c646e9d 100644 --- a/packages/example-shared/src/Examples/SFSymbols.tsx +++ b/packages/example-shared/src/Examples/SFSymbols.tsx @@ -3,12 +3,14 @@ import { useState } from 'react'; import { Article } from '../Screens/Article'; import { Albums } from '../Screens/Albums'; import { Contacts } from '../Screens/Contacts'; +import { Chat } from '../Screens/Chat'; import { Platform } from 'react-native'; const renderScene = SceneMap({ article: Article, albums: Albums, contacts: Contacts, + chat: Chat, }); const isAndroid = Platform.OS === 'android'; @@ -21,27 +23,48 @@ export default function SFSymbols() { title: 'Article', focusedIcon: isAndroid ? require('../../assets/icons/article_dark.png') - : { sfSymbol: 'document.fill' }, + : { sfSymbol: 'document.fill' as const, weight: 'bold' as const }, unfocusedIcon: isAndroid ? require('../../assets/icons/chat_dark.png') - : { sfSymbol: 'document' }, + : { sfSymbol: 'document' as const, weight: 'light' as const }, badge: '!', }, { key: 'albums', title: 'Albums', + // A palette symbol keeps its own per-layer colors instead of the tab tint. focusedIcon: isAndroid ? require('../../assets/icons/grid_dark.png') - : { sfSymbol: 'square.grid.3x2.fill' }, + : { + sfSymbol: 'square.grid.3x2.fill' as const, + renderingMode: 'palette' as const, + colors: { primary: '#FF3B30', secondary: '#34C759' }, + }, badge: '5', }, { key: 'contacts', focusedIcon: isAndroid ? require('../../assets/icons/person_dark.png') - : { sfSymbol: 'person.fill' }, + : { + sfSymbol: 'person.fill' as const, + renderingMode: 'hierarchical' as const, + color: '#AF52DE', + }, title: 'Contacts', - role: 'search', + role: 'search' as const, + }, + { + key: 'chat', + title: 'Signal', + // A variable symbol rendered at 60% of its layers. + focusedIcon: isAndroid + ? require('../../assets/icons/chat_dark.png') + : { + sfSymbol: 'wifi' as const, + variableValue: 0.6, + scale: 'large' as const, + }, }, ]); diff --git a/packages/react-native-bottom-tabs/ios/Bridge/BottomTabsBridge.h b/packages/react-native-bottom-tabs/ios/Bridge/BottomTabsBridge.h index 798e2cf0..a8099524 100644 --- a/packages/react-native-bottom-tabs/ios/Bridge/BottomTabsBridge.h +++ b/packages/react-native-bottom-tabs/ios/Bridge/BottomTabsBridge.h @@ -51,7 +51,9 @@ FOUNDATION_EXPORT NSObject *RNCCreateBottomAccessoryProvider(id +bool sfSymbolOptionsEqual(const SymbolOptions& lhs, const SymbolOptions& rhs) { + return lhs.size == rhs.size && + lhs.weight == rhs.weight && + lhs.scale == rhs.scale && + lhs.color == rhs.color && + lhs.primaryColor == rhs.primaryColor && + lhs.secondaryColor == rhs.secondaryColor && + lhs.tertiaryColor == rhs.tertiaryColor && + lhs.renderingMode == rhs.renderingMode && + lhs.variableValue == rhs.variableValue && + lhs.variableValueMode == rhs.variableValueMode && + lhs.colorRenderingMode == rhs.colorRenderingMode; +} + bool operator==(const RNCTabViewItemsStruct& lhs, const RNCTabViewItemsStruct& rhs) { return lhs.key == rhs.key && lhs.title == rhs.title && lhs.sfSymbol == rhs.sfSymbol && + sfSymbolOptionsEqual(lhs.sfSymbolOptions, rhs.sfSymbolOptions) && lhs.focusedSfSymbol == rhs.focusedSfSymbol && + sfSymbolOptionsEqual(lhs.focusedSfSymbolOptions, rhs.focusedSfSymbolOptions) && lhs.badge == rhs.badge && lhs.activeTintColor == rhs.activeTintColor && lhs.iconRenderingMode == rhs.iconRenderingMode && @@ -223,10 +243,73 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & [super updateProps:props oldProps:oldProps]; } +// Converts the flattened SF Symbol options into a dictionary the Swift side +// parses. Sentinel values (`0` for size and weight, `-1` for variableValue, +// empty strings) mean "not configured" and are dropped, so Swift sees `nil` and +// leaves the corresponding platform default alone. +template +static NSDictionary* convertSymbolOptions(const SymbolOptions& options) { + NSMutableDictionary *result = [NSMutableDictionary dictionary]; + + if (options.size > 0) { + result[@"size"] = @(options.size); + } + + if (options.weight > 0) { + result[@"weight"] = @(options.weight); + } + + if (!options.scale.empty()) { + result[@"scale"] = RCTNSStringFromString(options.scale); + } + + if (UIColor *color = RCTUIColorFromSharedColor(options.color)) { + result[@"color"] = color; + } + + if (UIColor *color = RCTUIColorFromSharedColor(options.primaryColor)) { + result[@"primaryColor"] = color; + } + + if (UIColor *color = RCTUIColorFromSharedColor(options.secondaryColor)) { + result[@"secondaryColor"] = color; + } + + if (UIColor *color = RCTUIColorFromSharedColor(options.tertiaryColor)) { + result[@"tertiaryColor"] = color; + } + + if (!options.renderingMode.empty()) { + result[@"renderingMode"] = RCTNSStringFromString(options.renderingMode); + } + + if (options.variableValue >= 0) { + result[@"variableValue"] = @(options.variableValue); + } + + if (!options.variableValueMode.empty()) { + result[@"variableValueMode"] = RCTNSStringFromString(options.variableValueMode); + } + + if (!options.colorRenderingMode.empty()) { + result[@"colorRenderingMode"] = RCTNSStringFromString(options.colorRenderingMode); + } + + return result.count > 0 ? result : nil; +} + NSArray* convertItemsToArray(const std::vector& items) { NSMutableArray *result = [NSMutableArray array]; for (const auto& item : items) { + // Options only ever apply alongside a symbol name, and the generated struct + // cannot be null, so an item without a symbol carries default values that + // must not be mistaken for configuration. + NSDictionary *symbolOptions = + item.sfSymbol.empty() ? nil : convertSymbolOptions(item.sfSymbolOptions); + NSDictionary *focusedSymbolOptions = + item.focusedSfSymbol.empty() ? nil : convertSymbolOptions(item.focusedSfSymbolOptions); + #if SWIFT_PACKAGE auto tabInfo = [RNCTabInfo createWithKey:RCTNSStringFromString(item.key) #else @@ -235,7 +318,9 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & title:RCTNSStringFromString(item.title) badge:RCTNSStringFromStringNilIfEmpty(item.badge) sfSymbol:RCTNSStringFromStringNilIfEmpty(item.sfSymbol) + sfSymbolOptions:symbolOptions focusedSfSymbol:RCTNSStringFromStringNilIfEmpty(item.focusedSfSymbol) + focusedSfSymbolOptions:focusedSymbolOptions activeTintColor:RCTUIColorFromSharedColor(item.activeTintColor) iconRenderingMode:RCTNSStringFromStringNilIfEmpty(item.iconRenderingMode) hidden:item.hidden diff --git a/packages/react-native-bottom-tabs/ios/SFSymbolOptions.swift b/packages/react-native-bottom-tabs/ios/SFSymbolOptions.swift new file mode 100644 index 00000000..54038107 --- /dev/null +++ b/packages/react-native-bottom-tabs/ios/SFSymbolOptions.swift @@ -0,0 +1,409 @@ +import Foundation + +#if os(macOS) + import AppKit +#else + import UIKit +#endif + +/// SF Symbol configuration for a single tab item icon. +/// +/// Mirrors the SF Symbol options React Navigation exposes, limited to the ones +/// that describe a static symbol image. Symbol effects and content transitions +/// are intentionally absent: a tab bar item renders a still image and never +/// runs symbol animations. +/// +/// Every value is optional. When one is `nil` the platform default is left +/// untouched, so an icon without options renders exactly as it did before any +/// of this configuration existed. +@objcMembers +public final class SFSymbolOptions: NSObject { + /// Point size of the symbol. + public var size: NSNumber? + /// Symbol weight as a numeric value between `100` and `900`. + public var weight: NSNumber? + /// `small`, `medium` or `large`. + public var scale: String? + /// Tint color, and the fallback for `primaryColor`. + public var color: PlatformColor? + /// Color of the first layer. + public var primaryColor: PlatformColor? + /// Color of the second layer, `palette` mode only. + public var secondaryColor: PlatformColor? + /// Color of the third layer, `palette` mode only. + public var tertiaryColor: PlatformColor? + /// `monochrome`, `hierarchical`, `palette` or `multicolor`. + public var renderingMode: String? + /// Value between `0` and `1` for variable symbols. + public var variableValue: NSNumber? + /// `automatic`, `color` or `draw`. iOS 26+. + public var variableValueMode: String? + /// `automatic`, `flat` or `gradient`. iOS 26+. + public var colorRenderingMode: String? + + public override init() { + super.init() + } + + /// Builds options from the dictionary the Fabric component view assembles. + /// + /// Returns `nil` for a missing dictionary so callers can keep the untouched + /// default rendering path. + public convenience init?(dictionary: NSDictionary?) { + guard let dictionary else { return nil } + + self.init() + + size = dictionary["size"] as? NSNumber + weight = dictionary["weight"] as? NSNumber + scale = dictionary["scale"] as? String + color = dictionary["color"] as? PlatformColor + primaryColor = dictionary["primaryColor"] as? PlatformColor + secondaryColor = dictionary["secondaryColor"] as? PlatformColor + tertiaryColor = dictionary["tertiaryColor"] as? PlatformColor + renderingMode = dictionary["renderingMode"] as? String + variableValue = dictionary["variableValue"] as? NSNumber + variableValueMode = dictionary["variableValueMode"] as? String + colorRenderingMode = dictionary["colorRenderingMode"] as? String + } +} + +extension SFSymbolOptions { + /// The color the first layer should use, falling back to the plain tint. + var effectivePrimaryColor: PlatformColor? { + primaryColor ?? color + } + + /// Whether any color at all was configured. + var hasExplicitColors: Bool { + color != nil || primaryColor != nil || secondaryColor != nil || tertiaryColor != nil + } + + /// Whether the resulting image carries its own colors and must therefore + /// bypass the tab bar's active/inactive tint. + /// + /// A layered rendering mode without any color of its own stays tintable, so + /// picking `hierarchical` alone still follows the tab bar's active and + /// inactive colors rather than falling back to black. + var preservesOwnColors: Bool { + switch renderingMode { + case "multicolor": + return true + case "hierarchical", "palette": + return hasExplicitColors + default: + return effectivePrimaryColor != nil + } + } + + /// Whether anything at all was configured. + var isEmpty: Bool { + size == nil + && weight == nil + && scale == nil + && color == nil + && primaryColor == nil + && secondaryColor == nil + && tertiaryColor == nil + && renderingMode == nil + && variableValue == nil + && variableValueMode == nil + && colorRenderingMode == nil + } + + /// The variable value clamped to the `0...1` range the symbol APIs accept. + var clampedVariableValue: Double? { + guard let variableValue else { return nil } + return min(max(variableValue.doubleValue, 0), 1) + } +} + +// MARK: - Image building + +#if os(macOS) + extension SFSymbolOptions { + /// Builds a configured symbol image, or `nil` when the symbol is unknown. + static func makeImage(named name: String, options: SFSymbolOptions?) -> NSImage? { + guard !name.isEmpty else { return nil } + + var image: NSImage? + + if #available(macOS 13.0, *), let variableValue = options?.clampedVariableValue { + image = NSImage( + systemSymbolName: name, + variableValue: variableValue, + accessibilityDescription: nil + ) + } + + if image == nil { + image = NSImage(systemSymbolName: name, accessibilityDescription: nil) + } + + guard let image else { return nil } + guard let options, !options.isEmpty else { return image } + // `NSImage.SymbolConfiguration.applying(_:)` landed in macOS 12, and + // combining configurations is what makes the options composable. On + // macOS 11 the symbol renders with the platform defaults instead. + guard #available(macOS 12.0, *) else { return image } + + guard let configuration = options.symbolConfiguration() else { return image } + + return image.withSymbolConfiguration(configuration) ?? image + } + + @available(macOS 12.0, *) + private func symbolConfiguration() -> NSImage.SymbolConfiguration? { + var configuration: NSImage.SymbolConfiguration? + + func apply(_ next: NSImage.SymbolConfiguration) { + configuration = configuration?.applying(next) ?? next + } + + if let size = size?.doubleValue, size > 0 { + apply( + NSImage.SymbolConfiguration( + pointSize: CGFloat(size), + weight: Self.symbolWeight(from: weight) + ) + ) + } else if weight != nil { + apply(NSImage.SymbolConfiguration(pointSize: NSFont.systemFontSize, weight: Self.symbolWeight(from: weight))) + } + + if let scale, let symbolScale = Self.symbolScale(from: scale) { + apply(NSImage.SymbolConfiguration(scale: symbolScale)) + } + + switch renderingMode { + case "hierarchical": + if let primary = effectivePrimaryColor { + apply(NSImage.SymbolConfiguration(hierarchicalColor: primary)) + } + case "palette": + let paletteColors = [effectivePrimaryColor, secondaryColor, tertiaryColor] + .compactMap { $0 } + if !paletteColors.isEmpty { + apply(NSImage.SymbolConfiguration(paletteColors: paletteColors)) + } + case "multicolor": + apply(NSImage.SymbolConfiguration.preferringMulticolor()) + default: + // A tab item cannot carry a tint of its own, so a monochrome symbol + // with an explicit color is colored through a single-color palette. + if let primary = effectivePrimaryColor { + apply(NSImage.SymbolConfiguration(paletteColors: [primary])) + } + } + + return configuration + } + + @available(macOS 12.0, *) + private static func symbolWeight(from value: NSNumber?) -> NSFont.Weight { + switch value?.intValue { + case 100: return .thin + case 200: return .ultraLight + case 300: return .light + case 400: return .regular + case 500: return .medium + case 600: return .semibold + case 700: return .bold + case 800: return .heavy + case 900: return .black + default: return .regular + } + } + + @available(macOS 12.0, *) + private static func symbolScale(from value: String) -> NSImage.SymbolScale? { + switch value { + case "small": return .small + case "medium": return .medium + case "large": return .large + default: return nil + } + } + } +#else + extension SFSymbolOptions { + /// Builds a configured symbol image, or `nil` when the symbol is unknown. + /// + /// Falls back to a symbol from the app's asset catalog, so custom symbols + /// shipped alongside SF Symbols keep working. + static func makeImage(named name: String, options: SFSymbolOptions?) -> UIImage? { + guard !name.isEmpty else { return nil } + + let configuration = options?.symbolConfiguration() + + guard + let image = baseImage( + named: name, + variableValue: options?.clampedVariableValue, + configuration: configuration + ) + else { return nil } + + guard let options else { return image } + + return options.tinted(image) + } + + private static func baseImage( + named name: String, + variableValue: Double?, + configuration: UIImage.SymbolConfiguration? + ) -> UIImage? { + if let variableValue, #available(iOS 16.0, tvOS 16.0, *) { + if let image = UIImage( + systemName: name, + variableValue: variableValue, + configuration: configuration + ) { + return image + } + } + + guard let configuration else { + return UIImage(systemName: name) ?? UIImage(named: name) + } + + return UIImage(systemName: name, withConfiguration: configuration) + ?? UIImage(named: name)?.applyingSymbolConfiguration(configuration) + } + + /// Bakes the requested color into the image. + /// + /// A tab bar item cannot carry a tint of its own, so a monochrome symbol + /// with an explicit color has to be tinted up front. The multi-layer modes + /// get their colors from the symbol configuration instead, except before + /// iOS 15 where those APIs do not exist and a flat tint is the best + /// available approximation. + private func tinted(_ image: UIImage) -> UIImage { + guard let color = effectivePrimaryColor else { return image } + + switch renderingMode { + case "multicolor": + return image + case "hierarchical", "palette": + if #available(iOS 15.0, tvOS 15.0, *) { + return image + } + return image.withTintColor(color, renderingMode: .alwaysOriginal) + default: + return image.withTintColor(color, renderingMode: .alwaysOriginal) + } + } + + /// Translates the options into a `UIImage.SymbolConfiguration`. + /// + /// Returns `nil` when nothing was configured, so the caller can request an + /// unconfigured image and keep the tab bar's own sizing. + func symbolConfiguration() -> UIImage.SymbolConfiguration? { + guard !isEmpty else { return nil } + + var configuration: UIImage.SymbolConfiguration? + + func apply(_ next: UIImage.SymbolConfiguration) { + configuration = configuration?.applying(next) ?? next + } + + if let size = size?.doubleValue, size > 0 { + apply(UIImage.SymbolConfiguration(pointSize: CGFloat(size))) + } + + if let weight { + apply(UIImage.SymbolConfiguration(weight: Self.symbolWeight(from: weight))) + } + + if let scale, let symbolScale = Self.symbolScale(from: scale) { + apply(UIImage.SymbolConfiguration(scale: symbolScale)) + } + + #if compiler(>=6.2) + if #available(iOS 26.0, tvOS 26.0, *) { + if let variableValueMode, + let mode = Self.symbolVariableValueMode(from: variableValueMode) { + apply(UIImage.SymbolConfiguration(variableValueMode: mode)) + } + + if let colorRenderingMode, + let mode = Self.symbolColorRenderingMode(from: colorRenderingMode) { + apply(UIImage.SymbolConfiguration(colorRenderingMode: mode)) + } + } + #endif + + switch renderingMode { + case "hierarchical": + if let primary = effectivePrimaryColor { + if #available(iOS 15.0, tvOS 15.0, *) { + apply(UIImage.SymbolConfiguration(hierarchicalColor: primary)) + } + } + case "palette": + let paletteColors = [effectivePrimaryColor, secondaryColor, tertiaryColor] + .compactMap { $0 } + if !paletteColors.isEmpty, #available(iOS 15.0, tvOS 15.0, *) { + apply(UIImage.SymbolConfiguration(paletteColors: paletteColors)) + } + case "multicolor": + if #available(iOS 15.0, tvOS 15.0, *) { + apply(UIImage.SymbolConfiguration.preferringMulticolor()) + } + default: + break + } + + return configuration + } + + private static func symbolWeight(from value: NSNumber) -> UIImage.SymbolWeight { + switch value.intValue { + case 100: return .thin + case 200: return .ultraLight + case 300: return .light + case 400: return .regular + case 500: return .medium + case 600: return .semibold + case 700: return .bold + case 800: return .heavy + case 900: return .black + default: return .unspecified + } + } + + private static func symbolScale(from value: String) -> UIImage.SymbolScale? { + switch value { + case "small": return .small + case "medium": return .medium + case "large": return .large + default: return nil + } + } + + #if compiler(>=6.2) + @available(iOS 26.0, tvOS 26.0, *) + private static func symbolVariableValueMode(from value: String) + -> UIImage.SymbolVariableValueMode? { + switch value { + case "automatic": return .automatic + case "color": return .color + case "draw": return .draw + default: return nil + } + } + + @available(iOS 26.0, tvOS 26.0, *) + private static func symbolColorRenderingMode(from value: String) + -> UIImage.SymbolColorRenderingMode? { + switch value { + case "automatic": return .automatic + case "flat": return .flat + case "gradient": return .gradient + default: return nil + } + } + #endif + } +#endif diff --git a/packages/react-native-bottom-tabs/ios/TabItem.swift b/packages/react-native-bottom-tabs/ios/TabItem.swift index 3992f27d..520598cf 100644 --- a/packages/react-native-bottom-tabs/ios/TabItem.swift +++ b/packages/react-native-bottom-tabs/ios/TabItem.swift @@ -4,6 +4,7 @@ struct TabItem: View { var title: String? var icon: PlatformImage? var sfSymbol: String? + var sfSymbolOptions: SFSymbolOptions? var labeled: Bool? var iconRenderingMode: String? @@ -15,14 +16,36 @@ struct TabItem: View { Image(uiImage: renderedIcon(icon)) #endif } else if let sfSymbol, !sfSymbol.isEmpty { - Image(systemName: sfSymbol) - .noneSymbolVariant() + symbolImage(sfSymbol) } if labeled != false { Text(title ?? "") } } + /// Renders the SF Symbol. + /// + /// Without any configuration this stays on `Image(systemName:)` so the + /// symbol keeps the tab bar's own sizing and tinting. Once options are set, + /// the symbol is built as a configured image instead, and opts out of the + /// tab bar tint when it carries colors of its own. + @ViewBuilder + private func symbolImage(_ sfSymbol: String) -> some View { + if let sfSymbolOptions, !sfSymbolOptions.isEmpty, + let image = SFSymbolOptions.makeImage(named: sfSymbol, options: sfSymbolOptions) { +#if os(macOS) + Image(nsImage: image) + .symbolRenderingModeIfNeeded(preservesOwnColors: sfSymbolOptions.preservesOwnColors) +#else + Image(uiImage: image) + .symbolRenderingModeIfNeeded(preservesOwnColors: sfSymbolOptions.preservesOwnColors) +#endif + } else { + Image(systemName: sfSymbol) + .noneSymbolVariant() + } + } + #if !os(macOS) private var preservesOriginalIconColors: Bool { iconRenderingMode == "original" @@ -33,3 +56,16 @@ struct TabItem: View { } #endif } + +extension Image { + /// Keeps the symbol's own colors when a non-monochrome rendering mode or an + /// explicit color was configured, otherwise leaves templating to the tab bar. + @ViewBuilder + func symbolRenderingModeIfNeeded(preservesOwnColors: Bool) -> some View { + if preservesOwnColors { + self.renderingMode(.original) + } else { + self + } + } +} diff --git a/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift b/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift index 188ddfde..986c1c25 100644 --- a/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift +++ b/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift @@ -56,6 +56,7 @@ struct LegacyTabView: AnyTabView { title: tabData.title, icon: icon, sfSymbol: tabData.sfSymbol, + sfSymbolOptions: tabData.sfSymbolOptions, labeled: props.labeled, iconRenderingMode: tabData.iconRenderingMode ) diff --git a/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift index 2f18f210..01477f08 100644 --- a/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift +++ b/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift @@ -48,6 +48,7 @@ struct NewTabView: AnyTabView { title: tabData.title, icon: icon, sfSymbol: tabData.sfSymbol, + sfSymbolOptions: tabData.sfSymbolOptions, labeled: props.labeled, iconRenderingMode: tabData.iconRenderingMode ) diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift index 68053e1f..ce84015a 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift @@ -227,9 +227,15 @@ struct TabViewImpl: View { let tabActiveColor = tabData.activeTintColor ?? props.activeTintColor let assetIcon = props.icons[itemIndex] - let icon = assetIcon ?? makeSFSymbolImage(named: tabData.sfSymbol) + let icon = + assetIcon + ?? makeSFSymbolImage(named: tabData.sfSymbol, options: tabData.sfSymbolOptions) let focusedIcon = - props.focusedIcons[itemIndex] ?? makeSFSymbolImage(named: tabData.focusedSfSymbol) ?? icon + props.focusedIcons[itemIndex] + ?? makeSFSymbolImage( + named: tabData.focusedSfSymbol, + options: tabData.focusedSfSymbolOptions + ) ?? icon let preservesOriginalIconColors = preservesOriginalIconColors(tabData: tabData) let useBakedTintColors = shouldUseExperimentalBakedTintColors(props: props) let shouldRenderLabelIntoImage = @@ -290,7 +296,15 @@ struct TabViewImpl: View { } private func preservesOriginalIconColors(tabData: TabInfo) -> Bool { - tabData.iconRenderingMode == "original" + if tabData.iconRenderingMode == "original" { + return true + } + + // A symbol configured with its own colors, or with a non-monochrome + // rendering mode, would be flattened by the tab bar tint. Treat it the + // same way as an image icon that opts out of tinting. + return tabData.sfSymbolOptions?.preservesOwnColors == true + || tabData.focusedSfSymbolOptions?.preservesOwnColors == true } private func renderTabBarIcon( @@ -310,10 +324,10 @@ struct TabViewImpl: View { return icon.withTintColor(color, renderingMode: .alwaysOriginal) } - private func makeSFSymbolImage(named sfSymbol: String?) -> UIImage? { + private func makeSFSymbolImage(named sfSymbol: String?, options: SFSymbolOptions?) -> UIImage? { guard let sfSymbol, !sfSymbol.isEmpty else { return nil } - return UIImage(systemName: sfSymbol) + return SFSymbolOptions.makeImage(named: sfSymbol, options: options) } private func selectedAttributes(props: TabViewProps) -> [NSAttributedString.Key: Any] { diff --git a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift index e5663453..b19fa1eb 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift @@ -8,7 +8,9 @@ public final class TabInfo: NSObject { public let title: String public let badge: String? public let sfSymbol: String + public let sfSymbolOptions: SFSymbolOptions? public let focusedSfSymbol: String? + public let focusedSfSymbolOptions: SFSymbolOptions? public let activeTintColor: PlatformColor? public let iconRenderingMode: String? public let hidden: Bool @@ -21,7 +23,9 @@ public final class TabInfo: NSObject { title: String, badge: String?, sfSymbol: String, + sfSymbolOptions: NSDictionary?, focusedSfSymbol: String?, + focusedSfSymbolOptions: NSDictionary?, activeTintColor: PlatformColor?, iconRenderingMode: String?, hidden: Bool, @@ -33,7 +37,9 @@ public final class TabInfo: NSObject { self.title = title self.badge = badge self.sfSymbol = sfSymbol + self.sfSymbolOptions = SFSymbolOptions(dictionary: sfSymbolOptions) self.focusedSfSymbol = focusedSfSymbol + self.focusedSfSymbolOptions = SFSymbolOptions(dictionary: focusedSfSymbolOptions) self.activeTintColor = activeTintColor self.iconRenderingMode = iconRenderingMode self.hidden = hidden diff --git a/packages/react-native-bottom-tabs/src/TabView.tsx b/packages/react-native-bottom-tabs/src/TabView.tsx index 0acfb842..1409b43c 100644 --- a/packages/react-native-bottom-tabs/src/TabView.tsx +++ b/packages/react-native-bottom-tabs/src/TabView.tsx @@ -3,6 +3,7 @@ import type { OnNativeLayout, OnPageSelectedEventData, OnTabBarMeasured, + SFSymbolOptions, TabViewItems, } from './TabViewNativeComponent'; import { @@ -36,8 +37,56 @@ import { type BottomAccessoryViewProps, } from './BottomAccessoryView'; -const isAppleSymbol = (icon: any): icon is { sfSymbol: string } => - icon?.sfSymbol; +const isAppleSymbol = (icon: any): icon is AppleIcon => icon?.sfSymbol; + +const SF_SYMBOL_WEIGHTS = { + thin: 100, + ultralight: 200, + light: 300, + regular: 400, + medium: 500, + semibold: 600, + bold: 700, + extrabold: 800, + black: 900, +} as const; + +/** + * `variableValue` accepts `0`, so it needs a sentinel outside its `0...1` + * range to mean "not configured". + */ +const UNSET_VARIABLE_VALUE = -1; + +/** + * Flattens an `AppleIcon` into the scalar fields the native side reads. + * + * Values left undefined by the user are sent as sentinels so native keeps + * using the platform default instead of overriding it with a zero. + */ +const createSfSymbolOptions = ( + icon: AppleIcon | undefined +): SFSymbolOptions | undefined => { + if (!icon) { + return undefined; + } + + return { + size: icon.size ?? 0, + weight: + typeof icon.weight === 'string' + ? SF_SYMBOL_WEIGHTS[icon.weight] + : (icon.weight ?? 0), + scale: icon.scale ?? '', + color: processColor(icon.color), + primaryColor: processColor(icon.colors?.primary), + secondaryColor: processColor(icon.colors?.secondary), + tertiaryColor: processColor(icon.colors?.tertiary), + renderingMode: icon.renderingMode ?? '', + variableValue: icon.variableValue ?? UNSET_VARIABLE_VALUE, + variableValueMode: icon.variableValueMode ?? '', + colorRenderingMode: icon.colorRenderingMode ?? '', + }; +}; interface Props { /* @@ -352,7 +401,11 @@ const TabView = ({ key: route.key, title: getLabelText({ route }) ?? route.key, sfSymbol: isSfSymbol ? icon.sfSymbol : undefined, + sfSymbolOptions: isSfSymbol ? createSfSymbolOptions(icon) : undefined, focusedSfSymbol: isFocusedSfSymbol ? focusedIcon.sfSymbol : undefined, + focusedSfSymbolOptions: isFocusedSfSymbol + ? createSfSymbolOptions(focusedIcon) + : undefined, badge: getBadge?.({ route }), badgeBackgroundColor: processColor( getBadgeBackgroundColor?.({ route }) diff --git a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts index 50ecf7c4..0969a574 100644 --- a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts +++ b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts @@ -22,11 +22,34 @@ export type OnNativeLayout = Readonly<{ height: Double; }>; +/** + * Flattened SF Symbol configuration sent to the native side. + * + * Unset values are encoded with sentinels rather than omitted, so the native + * side can tell "not configured" apart from a legitimate zero: + * `0` for `size` and `weight`, `-1` for `variableValue`, `''` for strings. + */ +export type SFSymbolOptions = Readonly<{ + size: Double; + weight: Int32; + scale: string; + color?: ProcessedColorValue | null; + primaryColor?: ProcessedColorValue | null; + secondaryColor?: ProcessedColorValue | null; + tertiaryColor?: ProcessedColorValue | null; + renderingMode: string; + variableValue: Double; + variableValueMode: string; + colorRenderingMode: string; +}>; + export type TabViewItems = ReadonlyArray<{ key: string; title: string; sfSymbol?: string; + sfSymbolOptions?: SFSymbolOptions; focusedSfSymbol?: string; + focusedSfSymbolOptions?: SFSymbolOptions; badge?: string; badgeBackgroundColor?: ProcessedColorValue | null; badgeTextColor?: ProcessedColorValue | null; diff --git a/packages/react-native-bottom-tabs/src/index.tsx b/packages/react-native-bottom-tabs/src/index.tsx index 19544834..90a8d51e 100644 --- a/packages/react-native-bottom-tabs/src/index.tsx +++ b/packages/react-native-bottom-tabs/src/index.tsx @@ -19,5 +19,11 @@ export type { AppleIcon, IconRenderingMode, LayoutDirection, + SFSymbolColorRenderingMode, + SFSymbolColors, + SFSymbolRenderingMode, + SFSymbolScale, + SFSymbolVariableValueMode, + SFSymbolWeight, TabRole, } from './types'; diff --git a/packages/react-native-bottom-tabs/src/types.ts b/packages/react-native-bottom-tabs/src/types.ts index 3f4eda80..2c4ee19d 100644 --- a/packages/react-native-bottom-tabs/src/types.ts +++ b/packages/react-native-bottom-tabs/src/types.ts @@ -1,9 +1,167 @@ -import type { ImageSourcePropType, StyleProp, ViewStyle } from 'react-native'; +import type { + ColorValue, + ImageSourcePropType, + StyleProp, + ViewStyle, +} from 'react-native'; import type { SFSymbol } from 'sf-symbols-typescript'; export type IconSource = string | ImageSourcePropType; -export type AppleIcon = { sfSymbol: SFSymbol }; +/** + * Weight of an SF Symbol. Accepts either a name or its numeric equivalent. + */ +export type SFSymbolWeight = + | 'thin' + | 'ultralight' + | 'light' + | 'regular' + | 'medium' + | 'semibold' + | 'bold' + | 'extrabold' + | 'black' + | 100 + | 200 + | 300 + | 400 + | 500 + | 600 + | 700 + | 800 + | 900; + +/** + * Scale variant of an SF Symbol. + */ +export type SFSymbolScale = 'small' | 'medium' | 'large'; + +/** + * Rendering mode of an SF Symbol. + */ +export type SFSymbolRenderingMode = + | 'monochrome' + | 'hierarchical' + | 'palette' + | 'multicolor'; + +/** + * How the partial state described by `variableValue` is rendered. + */ +export type SFSymbolVariableValueMode = 'automatic' | 'color' | 'draw'; + +/** + * How color is applied across the layers of an SF Symbol. + */ +export type SFSymbolColorRenderingMode = 'automatic' | 'flat' | 'gradient'; + +/** + * Per-layer colors of an SF Symbol. + */ +export type SFSymbolColors = { + primary?: ColorValue; + secondary?: ColorValue; + tertiary?: ColorValue; +}; + +/** + * An SF Symbol icon and its configuration. + * + * Only `sfSymbol` is required. Every other option falls back to the system + * default for a tab bar item, which is what you want in most cases. + * + * @platform ios, macOS, tvOS, visionOS + */ +export type AppleIcon = { + /** + * The name of the SF Symbol to display, e.g. `house.fill`. + */ + sfSymbol: SFSymbol; + /** + * Point size of the symbol. + * + * Defaults to the size the tab bar picks for the current platform. + */ + size?: number; + /** + * Color of the symbol. + * + * Used as the tint in `monochrome` mode, and as the fallback for + * `colors.primary` in `hierarchical` and `palette` modes. Setting it opts + * the icon out of the tab bar's active/inactive tint colors. + */ + color?: ColorValue; + /** + * Weight of the symbol. + * + * @default 'regular' + */ + weight?: SFSymbolWeight; + /** + * Scale variant of the symbol. + * + * @default 'medium' + */ + scale?: SFSymbolScale; + /** + * Value used to customize variable symbols, between `0` and `1`. + * + * Variable symbols such as `wifi` or `speaker.wave.3` have layers that + * activate progressively to represent a magnitude. `0` renders the fewest + * layers, `1` the full symbol. Has no effect on non-variable symbols. + * + * Requires iOS 16+. + */ + variableValue?: number; + /** + * How the partial state described by `variableValue` is rendered. + * + * - `automatic`: the system chooses based on the symbol. + * - `color`: fades inactive layers using opacity. + * - `draw`: partially draws layers instead of fading them. + * + * Requires iOS 26+. Ignored on earlier versions. + * + * @default 'automatic' + */ + variableValueMode?: SFSymbolVariableValueMode; + /** + * Rendering mode of the symbol. + * + * - `monochrome`: single color tint. + * - `hierarchical`: a hierarchy derived from a single color. + * - `palette`: explicit colors per layer, taken from `colors`. + * - `multicolor`: the symbol's built-in multicolor scheme. + * + * Anything other than `monochrome` opts the icon out of the tab bar's + * active/inactive tint colors. + * + * @default 'monochrome' + */ + renderingMode?: SFSymbolRenderingMode; + /** + * Colors used by the non-monochrome rendering modes. + * + * - `hierarchical`: uses `primary` as the base color. + * - `palette`: uses `primary`, `secondary` and `tertiary` per layer. + * - `multicolor`: ignored. + * + * Falls back to `color` for `primary` when unset. + */ + colors?: SFSymbolColors; + /** + * How color is applied across the symbol's layers. + * + * - `automatic`: the system chooses based on the symbol. + * - `flat`: a solid color per layer. + * - `gradient`: a gradient derived from each layer's color. + * + * Requires iOS 26+. Ignored on earlier versions. + * + * @default 'automatic' + */ + colorRenderingMode?: SFSymbolColorRenderingMode; +}; export type TabRole = 'search'; From ad8e11ffa9b772eb3b1f38a34c4864b77e46488f Mon Sep 17 00:00:00 2001 From: marius-ck Date: Thu, 17 Sep 2026 13:06:56 +0300 Subject: [PATCH 2/2] fix: template image color --- .../ios/SFSymbolOptions.swift | 54 ++++++++++++++----- .../ios/TabItem.swift | 17 +----- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/packages/react-native-bottom-tabs/ios/SFSymbolOptions.swift b/packages/react-native-bottom-tabs/ios/SFSymbolOptions.swift index 54038107..cc5d09c9 100644 --- a/packages/react-native-bottom-tabs/ios/SFSymbolOptions.swift +++ b/packages/react-native-bottom-tabs/ios/SFSymbolOptions.swift @@ -149,7 +149,12 @@ extension SFSymbolOptions { guard let configuration = options.symbolConfiguration() else { return image } - return image.withSymbolConfiguration(configuration) ?? image + let configured = image.withSymbolConfiguration(configuration) ?? image + // A template image is recolored by the tab bar, discarding the symbol's + // own colors, so a colored symbol has to opt out of templating. + configured.isTemplate = !options.preservesOwnColors + + return configured } @available(macOS 12.0, *) @@ -272,29 +277,54 @@ extension SFSymbolOptions { ?? UIImage(named: name)?.applyingSymbolConfiguration(configuration) } - /// Bakes the requested color into the image. + /// Settles the final colors and rendering mode of the image. /// - /// A tab bar item cannot carry a tint of its own, so a monochrome symbol - /// with an explicit color has to be tinted up front. The multi-layer modes - /// get their colors from the symbol configuration instead, except before - /// iOS 15 where those APIs do not exist and a flat tint is the best - /// available approximation. + /// A tab bar templates whatever image it is handed, which throws away the + /// symbol's own colors, so a colored symbol has to opt out with + /// `.alwaysOriginal` the same way an `original` image icon does. A + /// monochrome symbol has no layer colors to keep, so its color is baked in + /// as a flat tint instead. private func tinted(_ image: UIImage) -> UIImage { - guard let color = effectivePrimaryColor else { return image } + guard preservesOwnColors else { return image } switch renderingMode { case "multicolor": - return image + return Self.flattened(image) case "hierarchical", "palette": if #available(iOS 15.0, tvOS 15.0, *) { - return image + return Self.flattened(image) } - return image.withTintColor(color, renderingMode: .alwaysOriginal) + // Layered color APIs do not exist before iOS 15, so the primary color + // is applied as a flat tint as the closest approximation. + guard let color = effectivePrimaryColor else { return image } + return Self.flattened(image.withTintColor(color, renderingMode: .alwaysOriginal)) default: - return image.withTintColor(color, renderingMode: .alwaysOriginal) + guard let color = effectivePrimaryColor else { return image } + return Self.flattened(image.withTintColor(color, renderingMode: .alwaysOriginal)) } } + /// Draws the symbol into a plain bitmap that keeps the colors it was + /// configured with. + /// + /// Colors have to survive two separate attempts to recolor them. A tab bar + /// templates the image it is handed, and SwiftUI applies its own + /// `symbolRenderingMode` to anything it recognises as a symbol, which + /// flattens the layered modes back to a single tint. Rasterizing drops the + /// symbol identity, so neither applies, and `.alwaysOriginal` keeps the + /// result untinted. + private static func flattened(_ image: UIImage) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = image.scale + format.opaque = false + + let rendered = UIGraphicsImageRenderer(size: image.size, format: format).image { _ in + image.draw(in: CGRect(origin: .zero, size: image.size)) + } + + return rendered.withRenderingMode(.alwaysOriginal) + } + /// Translates the options into a `UIImage.SymbolConfiguration`. /// /// Returns `nil` when nothing was configured, so the caller can request an diff --git a/packages/react-native-bottom-tabs/ios/TabItem.swift b/packages/react-native-bottom-tabs/ios/TabItem.swift index 520598cf..b810e61e 100644 --- a/packages/react-native-bottom-tabs/ios/TabItem.swift +++ b/packages/react-native-bottom-tabs/ios/TabItem.swift @@ -33,12 +33,12 @@ struct TabItem: View { private func symbolImage(_ sfSymbol: String) -> some View { if let sfSymbolOptions, !sfSymbolOptions.isEmpty, let image = SFSymbolOptions.makeImage(named: sfSymbol, options: sfSymbolOptions) { + // The image already carries its own rendering mode, so a colored symbol + // keeps its colors here without any further SwiftUI modifier. #if os(macOS) Image(nsImage: image) - .symbolRenderingModeIfNeeded(preservesOwnColors: sfSymbolOptions.preservesOwnColors) #else Image(uiImage: image) - .symbolRenderingModeIfNeeded(preservesOwnColors: sfSymbolOptions.preservesOwnColors) #endif } else { Image(systemName: sfSymbol) @@ -56,16 +56,3 @@ struct TabItem: View { } #endif } - -extension Image { - /// Keeps the symbol's own colors when a non-monochrome rendering mode or an - /// explicit color was configured, otherwise leaves templating to the tab bar. - @ViewBuilder - func symbolRenderingModeIfNeeded(preservesOwnColors: Bool) -> some View { - if preservesOwnColors { - self.renderingMode(.original) - } else { - self - } - } -}