From c10c70756e5c1dff09c47b2eceec4b59aa2ba564 Mon Sep 17 00:00:00 2001 From: Amit Khatkar Date: Sun, 1 Mar 2026 13:32:53 +0530 Subject: [PATCH] feat: Add support for SF Symbols with customizable font size and weight as segment values on iOS. --- README.md | 48 +++++++++++++++++++-- example/App.js | 32 +++++++++++++- index.d.ts | 40 ++++++++++++++++- ios/RNCSegmentedControl.m | 88 ++++++++++++++++++++++++++++++++------ js/SegmentedControl.ios.js | 14 ++++-- js/SegmentedControlTab.js | 9 ++-- js/types.js | 35 ++++++++++++++- 7 files changed, 237 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 10a0255a..1c8c182d 100644 --- a/README.md +++ b/README.md @@ -191,11 +191,51 @@ Background color color of the control. (iOS 13+ only) ### `values` -The labels for the control's segment buttons, in order. +The labels for the control's segment buttons, in order. Supports strings, image sources (`require()`), and SF Symbol objects (iOS 13+ only). -| Type | Required | -| ------- | -------- | -| (string | number | Image)[] | No | +| Type | Required | +| --------------------------------- | -------- | +| `(string \| number \| SFSymbol)[]` | No | + +#### Using SF Symbols (iOS 13+ only) + +You can use [SF Symbols](https://developer.apple.com/sf-symbols/) as segment values by passing an object with a `systemImage` key: + +```javascript + +``` + +SF Symbol objects support the following properties: + +| Property | Type | Default | Description | +| ------------- | -------- | ----------- | ---------------------------------------------------- | +| `systemImage` | string | *required* | The SF Symbol name (e.g., `'star.fill'`, `'heart'`) | +| `fontSize` | number | `19` | The point size of the symbol | +| `weight` | string | `'regular'` | Symbol weight: `'ultraLight'`, `'thin'`, `'light'`, `'regular'`, `'medium'`, `'semibold'`, `'bold'`, `'heavy'`, `'black'` | + +You can also mix text and SF Symbols: + +```javascript + +``` + +> **Note:** SF Symbols are only supported on iOS. On Android and Web, segments with SF Symbol values will render as empty. + +--- ### `appearance` diff --git a/example/App.js b/example/App.js index d1ac48b8..53194438 100644 --- a/example/App.js +++ b/example/App.js @@ -7,7 +7,7 @@ import SegmentedControl from '..'; import React, {useEffect, useState} from 'react'; -import {ScrollView, StyleSheet, Text, View, useColorScheme} from 'react-native'; +import {ScrollView, StyleSheet, Text, View, Platform, useColorScheme} from 'react-native'; const App = () => { const colorScheme = useColorScheme(); @@ -53,6 +53,36 @@ const App = () => { ]} /> + {Platform.OS === 'ios' && ( + + + Segmented controls can have SF Symbols (iOS only) + + + + )} + {Platform.OS === 'ios' && ( + + + SF Symbols can be mixed with text + + + + )} Segmented controls can have pre-selected values diff --git a/index.d.ts b/index.d.ts index aa8d5f54..8d818198 100644 --- a/index.d.ts +++ b/index.d.ts @@ -12,6 +12,41 @@ import { type Constructor = new (...args: any[]) => T; +export type SFSymbolWeight = + | 'ultraLight' + | 'thin' + | 'light' + | 'regular' + | 'medium' + | 'semibold' + | 'bold' + | 'heavy' + | 'black'; + +/** + * Represents an SF Symbol configuration (iOS 13+ only). + * SF Symbols are Apple's built-in icon system providing thousands of + * configurable symbols that automatically align with text and adapt to + * the system appearance. + */ +export type SFSymbol = { + /** + * The name of the SF Symbol (e.g., 'star.fill', 'heart', 'gear'). + * See https://developer.apple.com/sf-symbols/ for the full list. + */ + systemImage: string; + /** + * The point size of the symbol. Default is 19. + */ + fontSize?: number; + /** + * The weight of the symbol. Default is 'regular'. + */ + weight?: SFSymbolWeight; +}; + +export type SegmentValue = string | number | SFSymbol; + export interface NativeSegmentedControlIOSChangeEvent extends TargetedEvent { value: string; selectedSegmentIndex: number; @@ -70,7 +105,7 @@ export interface SegmentedControlProps extends ViewProps { /** * Callback that is called when the user taps a segment; passes the segment's value as an argument */ - onValueChange?: (value: string) => void; + onValueChange?: (value: SegmentValue) => void; /** * The index in props.values of the segment to be (pre)selected. @@ -90,8 +125,9 @@ export interface SegmentedControlProps extends ViewProps { /** * The labels for the control's segment buttons, in order. + * Supports strings, image sources (require()), and SF Symbol objects (iOS 13+ only). */ - values?: string[]; + values?: SegmentValue[]; /** * (iOS 13+ only) diff --git a/ios/RNCSegmentedControl.m b/ios/RNCSegmentedControl.m index 4a1bfea4..c348099c 100644 --- a/ios/RNCSegmentedControl.m +++ b/ios/RNCSegmentedControl.m @@ -24,20 +24,80 @@ - (instancetype)initWithFrame:(CGRect)frame { } - (void)setValues:(NSArray *)values { - [self removeAllSegments]; - for (id segment in values) { - if ([segment isKindOfClass:[NSMutableDictionary class]]){ - UIImage *image = [[RCTConvert UIImage:segment] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal]; - [self insertSegmentWithImage:image - atIndex:self.numberOfSegments - animated:NO]; - } else { - [self insertSegmentWithTitle:(NSString *)segment - atIndex:self.numberOfSegments - animated:NO]; - } - } - super.selectedSegmentIndex = _selectedIndex; + [self removeAllSegments]; + + for (id segment in values) { + if ([segment isKindOfClass:[NSDictionary class]]) { + + NSDictionary *dict = (NSDictionary *)segment; + UIImage *image = nil; + + // 🔹 CASE 1: SF Symbol + if (dict[@"systemImage"]) { + +#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && defined(__IPHONE_13_0) && \ + __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_13_0 + if (@available(iOS 13.0, *)) { + + NSString *systemName = dict[@"systemImage"]; + + CGFloat fontSize = dict[@"fontSize"] ? [dict[@"fontSize"] floatValue] : 19.0; + + UIImageSymbolWeight weight = UIImageSymbolWeightRegular; + + if (dict[@"weight"]) { + NSString *weightString = dict[@"weight"]; + + if ([weightString isEqualToString:@"ultraLight"]) + weight = UIImageSymbolWeightUltraLight; + else if ([weightString isEqualToString:@"thin"]) + weight = UIImageSymbolWeightThin; + else if ([weightString isEqualToString:@"light"]) + weight = UIImageSymbolWeightLight; + else if ([weightString isEqualToString:@"medium"]) + weight = UIImageSymbolWeightMedium; + else if ([weightString isEqualToString:@"semibold"]) + weight = UIImageSymbolWeightSemibold; + else if ([weightString isEqualToString:@"bold"]) + weight = UIImageSymbolWeightBold; + else if ([weightString isEqualToString:@"heavy"]) + weight = UIImageSymbolWeightHeavy; + else if ([weightString isEqualToString:@"black"]) + weight = UIImageSymbolWeightBlack; + } + + UIImageSymbolConfiguration *config = + [UIImageSymbolConfiguration configurationWithPointSize:fontSize + weight:weight]; + + image = [[UIImage systemImageNamed:systemName] + imageByApplyingSymbolConfiguration:config]; + + image = [image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + } +#endif + } + else { + image = [[RCTConvert UIImage:segment] + imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal]; + } + + if (image) { + [self insertSegmentWithImage:image + atIndex:self.numberOfSegments + animated:NO]; + } + } + + else if ([segment isKindOfClass:[NSString class]]) { + + [self insertSegmentWithTitle:(NSString *)segment + atIndex:self.numberOfSegments + animated:NO]; + } + } + + super.selectedSegmentIndex = _selectedIndex; } - (void)setSelectedIndex:(NSInteger)selectedIndex { diff --git a/js/SegmentedControl.ios.js b/js/SegmentedControl.ios.js index 282fb2f9..6b78a3e0 100644 --- a/js/SegmentedControl.ios.js +++ b/js/SegmentedControl.ios.js @@ -84,9 +84,17 @@ class SegmentedControlIOS extends React.Component { } : undefined } - values={values.map((val) => - typeof val === 'string' ? val : Image.resolveAssetSource(val), - )} + values={values.map((val) => { + if (typeof val === 'string') { + return val; + } + // SF Symbol objects have a systemImage key — pass through as-is + if (typeof val === 'object' && val !== null && val.systemImage) { + return val; + } + // Image sources (require() numbers or objects) need resolution + return Image.resolveAssetSource(val); + })} {...props} ref={forwardedRef} style={[styles.segmentedControl, this.props.style]} diff --git a/js/SegmentedControlTab.js b/js/SegmentedControlTab.js index 31f18fa3..fffc966c 100644 --- a/js/SegmentedControlTab.js +++ b/js/SegmentedControlTab.js @@ -16,10 +16,10 @@ import { Platform, } from 'react-native'; -import type {FontStyle, ViewStyle} from './types'; +import type {FontStyle, ViewStyle, SFSymbol} from './types'; type Props = $ReadOnly<{| - value: string | number | Object, + value: string | number | SFSymbol, tintColor?: ?string, onSelect: () => void, selected: boolean, @@ -103,7 +103,10 @@ export const SegmentedControlTab = ({ ios: typeof value === 'string' ? value : testID, })}> - {typeof value === 'number' || typeof value === 'object' ? ( + {typeof value === 'object' && value !== null && value.systemImage ? ( + // SF Symbols are iOS-only; on Android/Web render nothing + null + ) : typeof value === 'number' || typeof value === 'object' ? ( ) : isBase64(value) ? ( diff --git a/js/types.js b/js/types.js index 93426055..fd2f36d2 100644 --- a/js/types.js +++ b/js/types.js @@ -16,6 +16,37 @@ export type Event = SyntheticEvent< export type ViewStyle = ViewStyleProp; +export type SFSymbolWeight = + | 'ultraLight' + | 'thin' + | 'light' + | 'regular' + | 'medium' + | 'semibold' + | 'bold' + | 'heavy' + | 'black'; + +/** + * Represents an SF Symbol configuration (iOS 13+ only). + */ +export type SFSymbol = $ReadOnly<{| + /** + * The name of the SF Symbol (e.g., 'star.fill', 'heart', 'gear'). + */ + systemImage: string, + /** + * The point size of the symbol. Default is 19. + */ + fontSize?: number, + /** + * The weight of the symbol. Default is 'regular'. + */ + weight?: SFSymbolWeight, +|}>; + +export type SegmentValue = string | number | SFSymbol; + export type FontStyle = $ReadOnly<{| /** * Font Color of Segmented Control @@ -51,7 +82,7 @@ export type SegmentedControlProps = $ReadOnly<{| /** * The labels for the control's segment buttons, in order. */ - values: $ReadOnlyArray, + values: $ReadOnlyArray, /** * The index in `props.values` of the segment to be (pre)selected. */ @@ -60,7 +91,7 @@ export type SegmentedControlProps = $ReadOnly<{| * Callback that is called when the user taps a segment; * passes the segment's value as an argument */ - onValueChange?: ?(value: string | number | Object) => mixed, + onValueChange?: ?(value: SegmentValue) => mixed, /** * Callback that is called when the user taps a segment; * passes the event as an argument