├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .watchmanconfig ├── README.md ├── __tests__ ├── Segment.test.tsx ├── SegmentedControl.test.tsx ├── __snapshots__ │ └── SegmentedControl.test.tsx.snap └── utils.test.ts ├── docs └── images │ ├── example-one.gif │ └── example-two.gif ├── jest.config.js ├── package.json ├── src ├── Divider │ ├── Divider.tsx │ ├── DividerStyles.ts │ └── index.ts ├── Segment │ ├── Segment.tsx │ ├── SegmentStyles.ts │ └── index.ts ├── SegmentedContext │ ├── SegmentedContext.ts │ └── index.ts ├── SegmentedControl │ ├── SegmentedControl.tsx │ ├── SegmentedControlStyles.ts │ └── index.ts ├── index.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | 'react-native/react-native': true, 6 | }, 7 | extends: [ 8 | 'plugin:react/recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'prettier/@typescript-eslint', 11 | 'plugin:prettier/recommended', 12 | ], 13 | globals: { 14 | Atomics: 'readonly', 15 | SharedArrayBuffer: 'readonly', 16 | }, 17 | parser: '@typescript-eslint/parser', 18 | parserOptions: { 19 | ecmaFeatures: { 20 | jsx: true, 21 | }, 22 | ecmaVersion: 2018, 23 | sourceType: 'module', 24 | }, 25 | plugins: ['react', 'react-native', '@typescript-eslint'], 26 | rules: {}, 27 | }; 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | .DS_Store 4 | 5 | lib/ 6 | 7 | .npmrc 8 | 9 | coverage/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": [ 3 | ".git", 4 | "node_modules", 5 | "src" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/react-native-resegmented-control)](https://www.npmjs.com/package/react-native-resegmented-control) 2 | 3 | # React Native Resegmented Control 4 | 5 | React Native Resegmented Control is a fully customizable, declarative component that mimics the design of `UISegmentedControl` from iOS 13. Supported on iOS and Android. 6 | 7 | ![SegmentedControlExampleOne](docs/images/example-one.gif?raw=true) 8 | ![SegmentedControlExampleTwo](docs/images/example-two.gif?raw=true) 9 | 10 | ## Motivation 11 | 12 | We wanted to use the new segmented control in our app, but there are a few issues with the native component `SegmentedControlIOS`. 13 | 14 | 1. The new design is only available on iOS 13 and above - say bye to app support for older versions. 15 | 2. The component is not fully customizable. 16 | 3. There is no equivalent component for Android - boo. 17 | 18 | **Why not use one of the other existing libaries?** 19 | While any of the other libraries would do the job, none of them comes with the new iOS 13 design out of the box. We really wanted the fancy slider animation. 😎 20 | 21 | ## Installation 22 | 23 | 1\. First install the library from npm using yarn or npm 24 | 25 | `yarn add react-native-resegmented-control` 26 | 27 | 2\. Install additional dependencies 28 | 29 | `yarn add react-native-gesture-handler react-native-reanimated` 30 | 31 | 3a. (Pre 0.59 RN) Link the native modules 32 | 33 | `react-native link react-native-gesture-handler react-native-reanimated` 34 | 35 | 3b. (Post 0.60 RN) Install the Pods 36 | 37 | `pod install` 38 | 39 | ## Example 40 | 41 | ```jsx 42 | import { SegmentedControl, Segment } from 'react-native-resegmented-control'; 43 | 44 | setSelectedSegment(name)} 49 | style={[styles.segmentedControl]} 50 | > 51 | 52 | 53 | ; 54 | ``` 55 | 56 | ## SegmentedControl 57 | 58 | ### `activeTintColor` 59 | 60 | Color of the active content. 61 | 62 | | Type | Required | Default | 63 | | ------ | -------- | --------- | 64 | | string | No | `#000000` | 65 | 66 | ### `disabled` 67 | 68 | Disable the segmented control. 69 | 70 | | Type | Required | Default | 71 | | ------- | -------- | ------- | 72 | | boolean | No | false | 73 | 74 | ### `disabledStyle` 75 | 76 | Style of the disabled segmented control. Uses the same styles as a `View` component. 77 | 78 | | Type | Required | Default | 79 | | --------- | -------- | ------------------ | 80 | | ViewStyle | No | `{ opacity: 0.5 }` | 81 | 82 | ### `inactiveTintColor` 83 | 84 | Color of the inactive content. 85 | 86 | | Type | Required | Default | 87 | | ------ | -------- | --------- | 88 | | string | No | `#000000` | 89 | 90 | ### `initialSelectedName` 91 | 92 | Name of the segment to initially select. 93 | 94 | | Type | Required | 95 | | ------ | -------- | 96 | | string | No | 97 | 98 | ### `onChangeValue` 99 | 100 | Callback that is called when the user taps a segment. Passes the `name` of the `Segment` as an argument. 101 | 102 | | Type | Required | 103 | | -------- | -------- | 104 | | function | No | 105 | 106 | ```ts 107 | function onChangeValue(name: string): void {} 108 | ``` 109 | 110 | ### `sliderStyle` 111 | 112 | Style of the slider. Uses the same styles as a `View` component. 113 | 114 | | Type | Required | 115 | | --------- | -------- | 116 | | ViewStyle | No | 117 | 118 | ### `style` 119 | 120 | Style of the segmented control. Uses the same styles as a `View` component. 121 | 122 | | Type | Required | 123 | | --------- | -------- | 124 | | ViewStyle | No | 125 | 126 | ## Segment 127 | 128 | ### `content` 129 | 130 | Element for the segment. 131 | 132 | | Type | Required | Props | 133 | | ----------------- | -------- | ------------------------------------------------------------ | 134 | | Element, Function | Yes | `active`, `activeTintColor`, `disabled`, `inactiveTintColor` | 135 | 136 | ### `disabled` 137 | 138 | Disable the segment. 139 | 140 | | Type | Required | Default | 141 | | ------- | -------- | ------- | 142 | | boolean | No | false | 143 | 144 | ### `disabledStyle` 145 | 146 | Style of the disabled segment. Uses the same styles as a `View` component. 147 | 148 | | Type | Required | Default | 149 | | --------- | -------- | ------------------ | 150 | | ViewStyle | No | `{ opacity: 0.5 }` | 151 | 152 | ### `name` 153 | 154 | Unique name used to identify each segment. 155 | 156 | | Type | Required | 157 | | ------ | -------- | 158 | | string | Yes | 159 | 160 | ### `style` 161 | 162 | Style of the segment. Uses the same styles as a `View` component. 163 | 164 | | Type | Required | 165 | | --------- | -------- | 166 | | ViewStyle | No | 167 | 168 | ## Unit Testing with Jest 169 | 170 | This package relies on [`react-native-reanimated`](https://github.com/software-mansion/react-native-reanimated). 171 | 172 | When rendering this component with renderers such as Jest you may see this error: 173 | 174 | ``` 175 | ● Test suite failed to run 176 | 177 | Invariant Violation: Native module cannot be null. 178 | 179 | at invariant (node_modules/invariant/invariant.js:40:15) 180 | at new NativeEventEmitter (node_modules/react-native/Libraries/EventEmit 181 | ter/NativeEventEmitter.js:36:27) 182 | at Object. (node_modules/react-native-reanimated/src/Reanimat 183 | edEventEmitter.js:4:1) 184 | at Object. (node_modules/react-native-reanimated/src/core/Ani 185 | matedCall.js:1:909) 186 | ``` 187 | 188 | To get around this you can use the react-native-reanimated mock. Here is how to in Jest: 189 | 190 | In your test file add this: 191 | 192 | ``` 193 | jest.mock('react-native-reanimated', () => 194 | require('react-native-reanimated/mock') 195 | ); 196 | ``` 197 | 198 | ## To Dos 199 | 200 | - More customizable options 201 | - Pixel perfect to native design 202 | -------------------------------------------------------------------------------- /__tests__/Segment.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text } from 'react-native'; 3 | import { render } from '@testing-library/react-native'; 4 | import { SegmentedControl, Segment } from '../src'; 5 | 6 | jest.mock('react-native-reanimated', () => 7 | require('react-native-reanimated/mock'), 8 | ); 9 | 10 | describe('SegmentedControl', () => { 11 | it('should not render without SegmentedControl', () => { 12 | const errorSpy = jest.spyOn(console, 'error'); 13 | errorSpy.mockImplementation(); 14 | 15 | expect(() => { 16 | render(); 17 | }).toThrow('Segment must be used within a SegmentedControl.'); 18 | 19 | errorSpy.mockRestore(); 20 | }); 21 | 22 | it('should render a text when content is a string', () => { 23 | const { getByText } = render( 24 | 25 | 26 | , 27 | ); 28 | 29 | expect(getByText('Test')).toBeDefined(); 30 | }); 31 | 32 | it('should render when content is a function', () => { 33 | const { getByText } = render( 34 | 35 | Function} /> 36 | , 37 | ); 38 | 39 | expect(getByText('Function')).toBeDefined(); 40 | }); 41 | 42 | it('should render when content is an element', () => { 43 | const { getByText } = render( 44 | 45 | Element} /> 46 | , 47 | ); 48 | 49 | expect(getByText('Element')).toBeDefined(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /__tests__/SegmentedControl.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import { Text, View } from 'react-native'; 3 | import { fireEvent, render } from '@testing-library/react-native'; 4 | import { SegmentedControl } from '../src/SegmentedControl'; 5 | import { Segment, SegmentContentProps } from '../src/Segment'; 6 | 7 | jest.mock('react-native-reanimated', () => 8 | require('react-native-reanimated/mock'), 9 | ); 10 | 11 | jest.mock('react-native-gesture-handler', () => { 12 | const actual = jest.requireActual('react-native-gesture-handler'); 13 | const { TouchableOpacity } = jest.requireActual('react-native'); 14 | return { 15 | ...actual, 16 | TouchableOpacity, 17 | }; 18 | }); 19 | 20 | describe('SegmentedControl', () => { 21 | it('should render', () => { 22 | const { asJSON } = render( 23 | 24 | 25 | , 26 | ); 27 | 28 | expect(asJSON()).toMatchSnapshot(); 29 | }); 30 | 31 | it('should render initially without slider, press on a segment and slider should appear', async () => { 32 | const { getByTestId, getByText } = render( 33 | 34 | 35 | 36 | , 37 | ); 38 | 39 | expect(() => getByTestId('SegmentedControl_Slider')).toThrow(); 40 | 41 | const secondSegment = getByText('Second'); 42 | fireEvent.press(secondSegment); 43 | 44 | expect(getByTestId('SegmentedControl_Slider')).toBeDefined(); 45 | }); 46 | 47 | it('should render initially with slider on `Second`', async () => { 48 | let activeSegment = null; 49 | 50 | const SpyContent = ({ active }: SegmentContentProps): ReactElement => { 51 | activeSegment = active; 52 | 53 | return Second; 54 | }; 55 | 56 | const { getByTestId } = render( 57 | 58 | 59 | 60 | , 61 | ); 62 | 63 | expect(getByTestId('SegmentedControl_Slider')).toBeDefined(); 64 | expect(activeSegment).toBe(true); 65 | }); 66 | 67 | it('should call onChangeValue when pressed on `Test`', async () => { 68 | const changeValueSpy = jest.fn(); 69 | const { getByTestId } = render( 70 | 71 | 72 | , 73 | ); 74 | 75 | const button = getByTestId('Segment_Button'); 76 | fireEvent.press(button); 77 | 78 | expect(changeValueSpy).toBeCalledWith('Test'); 79 | }); 80 | 81 | it('should throw if a child component is not a Segment', () => { 82 | const errorSpy = jest.spyOn(console, 'error'); 83 | errorSpy.mockImplementation(); 84 | 85 | expect(() => { 86 | render( 87 | 88 | 89 | , 90 | ); 91 | }).toThrow('SegmentedControl only accepts Segment as children.'); 92 | 93 | errorSpy.mockRestore(); 94 | }); 95 | 96 | it('should throw if a Segment has no name', () => { 97 | const errorSpy = jest.spyOn(console, 'error'); 98 | errorSpy.mockImplementation(); 99 | 100 | expect(() => { 101 | render( 102 | 103 | 104 | , 105 | ); 106 | }).toThrow('Segment requires `name` to be defined.'); 107 | 108 | errorSpy.mockRestore(); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/SegmentedControl.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SegmentedControl should render 1`] = ` 4 | 13 | 31 | 45 | 50 | 60 | 78 | Test 79 | 80 | 81 | 82 | 83 | 84 | 85 | `; 86 | -------------------------------------------------------------------------------- /__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { clamp } from '../src/utils'; 2 | 3 | describe('clamp', () => { 4 | it('14 should clamp to 10', () => { 5 | expect(clamp(14, 0, 10)).toBe(10); 6 | }); 7 | 8 | it('-1 should clamp to 0', () => { 9 | expect(clamp(-1, 0, 10)).toBe(0); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /docs/images/example-one.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardvclam/react-native-resegmented-control/b20619ad48c2b545edd0ba2a3e290b75ba00829f/docs/images/example-one.gif -------------------------------------------------------------------------------- /docs/images/example-two.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardvclam/react-native-resegmented-control/b20619ad48c2b545edd0ba2a3e290b75ba00829f/docs/images/example-two.gif -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const jestPreset = require('@testing-library/react-native/jest-preset'); 2 | const { defaults: tsjPreset } = require('ts-jest/presets'); 3 | 4 | module.exports = { 5 | preset: '@testing-library/react-native', 6 | ...tsjPreset, 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | setupFiles: [ 9 | ...jestPreset.setupFiles, 10 | './node_modules/react-native-gesture-handler/jestSetup.js', 11 | ], 12 | setupFilesAfterEnv: ['@testing-library/react-native/cleanup-after-each'], 13 | transform: { 14 | '^.+\\.js$': '/node_modules/react-native/jest/preprocessor.js', 15 | '\\.(ts|tsx)$': 'ts-jest', 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-resegmented-control", 3 | "version": "2.4.0", 4 | "description": "A fully customizable, declarative component that mimics the design of UISegmentedControl from iOS 13. Supported on iOS and Android", 5 | "keywords": [ 6 | "react-native", 7 | "segmented", 8 | "control", 9 | "resegmented", 10 | "UISegmentedControl", 11 | "SegmentedControlIOS" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/richardvclam/react-native-resegmented-control" 16 | }, 17 | "types": "lib/index.d.ts", 18 | "main": "lib/index.js", 19 | "files": [ 20 | "lib" 21 | ], 22 | "scripts": { 23 | "build": "rm -rf lib && tsc", 24 | "prepare": "npm run test && npm run build", 25 | "cov": "jest --coverage", 26 | "test": "jest", 27 | "lint": "tsc --noEmit && eslint \"{src,__tests__}/**/*.{js,jsx,ts,tsx}\" --quiet --fix" 28 | }, 29 | "author": "", 30 | "license": "ISC", 31 | "dependencies": { 32 | "react-native-redash": "^9.3.2" 33 | }, 34 | "devDependencies": { 35 | "@testing-library/react-native": "^5.0.3", 36 | "@types/jest": "^24.0.25", 37 | "@types/node": "^13.1.2", 38 | "@types/react-native": "^0.60.27", 39 | "@typescript-eslint/eslint-plugin": "^2.27.0", 40 | "@typescript-eslint/parser": "^2.27.0", 41 | "babel-jest": "^25.2.6", 42 | "eslint": "^6.8.0", 43 | "eslint-config-prettier": "^6.10.1", 44 | "eslint-plugin-import": "^2.20.2", 45 | "eslint-plugin-jsx-a11y": "^6.2.3", 46 | "eslint-plugin-prettier": "^3.1.2", 47 | "eslint-plugin-react": "^7.19.0", 48 | "eslint-plugin-react-hooks": "^1.7.0", 49 | "eslint-plugin-react-native": "^3.8.1", 50 | "jest": "^25.2.7", 51 | "prettier": "^2.0.4", 52 | "prettier-eslint": "^9.0.1", 53 | "react": "^16.12.0", 54 | "react-native": "^0.61.5", 55 | "react-native-gesture-handler": "^1.5.2", 56 | "react-native-reanimated": "^1.4.0", 57 | "react-test-renderer": "^16.12.0", 58 | "ts-jest": "^25.3.1", 59 | "typescript": "^3.8.3" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Divider/Divider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View } from 'react-native'; 3 | import Animated, { Easing } from 'react-native-reanimated'; 4 | import { timing } from 'react-native-redash'; 5 | 6 | import styles from './DividerStyles'; 7 | 8 | export interface DividerProps { 9 | hide?: boolean; 10 | } 11 | 12 | function _Divider({ hide = false }: DividerProps): JSX.Element { 13 | const opacity = React.useRef(new Animated.Value(hide ? 0 : 1)); 14 | 15 | Animated.useCode(() => { 16 | return Animated.set( 17 | opacity.current, 18 | timing({ 19 | from: hide ? 0 : 1, 20 | to: hide ? 1 : 0, 21 | easing: Easing.linear, 22 | duration: 200, 23 | }), 24 | ); 25 | }, [hide]); 26 | 27 | return ( 28 | 31 | 32 | 33 | ); 34 | } 35 | 36 | export const Divider = React.memo(_Divider); 37 | -------------------------------------------------------------------------------- /src/Divider/DividerStyles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | const styles = StyleSheet.create({ 4 | dividerContainer: { 5 | paddingTop: 7, 6 | paddingBottom: 7, 7 | zIndex: 0, 8 | }, 9 | divider: { 10 | height: '100%', 11 | width: 1, 12 | borderWidth: 0, 13 | backgroundColor: 'rgba(120, 120, 120, 0.2)', 14 | }, 15 | }); 16 | 17 | export default styles; 18 | -------------------------------------------------------------------------------- /src/Divider/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Divider'; 2 | -------------------------------------------------------------------------------- /src/Segment/Segment.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useContext } from 'react'; 2 | import { StyleProp, Text, View, ViewStyle } from 'react-native'; 3 | import { TouchableOpacity } from 'react-native-gesture-handler'; 4 | 5 | import { SegmentedContext } from '../SegmentedContext'; 6 | import styles from './SegmentStyles'; 7 | 8 | export interface SegmentContentProps { 9 | active: boolean; 10 | activeTintColor: string; 11 | disabled: boolean; 12 | inactiveTintColor: string; 13 | } 14 | 15 | export interface SegmentProps { 16 | activeTintColor?: string; 17 | content: React.ReactNode; 18 | disabled?: boolean; 19 | disabledStyle?: ViewStyle; 20 | inactiveTintColor?: string; 21 | name: string; 22 | style?: StyleProp; 23 | } 24 | 25 | export const Segment: FC = ({ 26 | activeTintColor, 27 | content, 28 | disabled, 29 | disabledStyle, 30 | inactiveTintColor, 31 | name, 32 | style, 33 | }: SegmentProps) => { 34 | const context = useContext(SegmentedContext); 35 | 36 | if (!context) { 37 | throw new Error('Segment must be used within a SegmentedControl.'); 38 | } 39 | 40 | const { selectedName, onChange } = context; 41 | 42 | const active = selectedName === name; 43 | 44 | const handlePress = (): void => { 45 | if (typeof onChange === 'function') { 46 | onChange(name); 47 | } 48 | }; 49 | 50 | const renderContent = (): React.ReactNode => { 51 | if ( 52 | typeof content === 'string' || 53 | typeof content === 'number' || 54 | typeof content === 'boolean' 55 | ) { 56 | return ( 57 | 65 | {content} 66 | 67 | ); 68 | } 69 | 70 | if (typeof content === 'function') { 71 | return content({ activeTintColor, inactiveTintColor, active, disabled }); 72 | } 73 | 74 | return content; 75 | }; 76 | 77 | return ( 78 | 86 | 91 | {renderContent()} 92 | 93 | 94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /src/Segment/SegmentStyles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | const styles = StyleSheet.create({ 4 | container: { 5 | flex: 1, 6 | alignItems: 'center', 7 | zIndex: 2, 8 | }, 9 | disabled: { 10 | opacity: 0.5, 11 | }, 12 | segment: { 13 | flex: 1, 14 | flexDirection: 'row', 15 | justifyContent: 'center', 16 | alignItems: 'center', 17 | }, 18 | segmentText: { 19 | fontSize: 13, 20 | paddingLeft: 2, 21 | paddingRight: 2, 22 | width: '100%', 23 | textAlign: 'center', 24 | }, 25 | segmentActiveText: { 26 | fontWeight: 'bold', 27 | }, 28 | }); 29 | 30 | export default styles; 31 | -------------------------------------------------------------------------------- /src/Segment/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Segment'; 2 | -------------------------------------------------------------------------------- /src/SegmentedContext/SegmentedContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export const SegmentedContext = createContext<{ 4 | selectedName: string | null | undefined; 5 | onChange: ((name: string) => void) | undefined; 6 | } | null>(null); 7 | -------------------------------------------------------------------------------- /src/SegmentedContext/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SegmentedContext'; 2 | -------------------------------------------------------------------------------- /src/SegmentedControl/SegmentedControl.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; 3 | import { 4 | PanGestureHandler, 5 | PanGestureHandlerGestureEvent 6 | } from 'react-native-gesture-handler'; 7 | import Animated, { Easing } from 'react-native-reanimated'; 8 | import { timing } from 'react-native-redash'; 9 | 10 | import { Divider } from '../Divider'; 11 | import { Segment, SegmentProps } from '../Segment'; 12 | import { SegmentedContext } from '../SegmentedContext'; 13 | import { clamp } from '../utils'; 14 | import styles from './SegmentedControlStyles'; 15 | 16 | export interface SegmentedControlProps { 17 | activeTintColor?: string; 18 | children: 19 | | React.ReactElement 20 | | React.ReactElement[]; 21 | disabled?: boolean; 22 | disabledStyle?: ViewStyle; 23 | inactiveTintColor?: string; 24 | initialSelectedName?: string; 25 | onChangeValue?: (name: string) => void; 26 | sliderStyle?: ViewStyle; 27 | style?: ViewStyle; 28 | } 29 | 30 | export const SegmentedControl = ({ 31 | activeTintColor = '#000000', 32 | children, 33 | disabled = false, 34 | disabledStyle, 35 | inactiveTintColor = '#000000', 36 | initialSelectedName, 37 | onChangeValue, 38 | sliderStyle, 39 | style, 40 | }: SegmentedControlProps): JSX.Element => { 41 | const [_initialized, _setInitialized] = useState(false); 42 | const [_width, _setWidth] = useState(0); 43 | const [_initialSelectedName] = useState(initialSelectedName); 44 | const [_activeName, _setActiveName] = useState(_initialSelectedName); 45 | const [_sliderPosition, _setSliderPosition] = useState( 46 | new Animated.Value(0), 47 | ); 48 | const [_sliderWidth, _setSliderWidth] = useState(0); 49 | const [_map, _setMap] = useState<{ [key: string]: number } | undefined>( 50 | undefined, 51 | ); 52 | 53 | const values = Array.isArray(children) ? children : [children]; 54 | 55 | // Map segment names to index 56 | useEffect(() => { 57 | const tempMap = {}; 58 | 59 | values.forEach((child, index) => { 60 | if (child.type !== Segment) { 61 | throw new Error('SegmentedControl only accepts Segment as children.'); 62 | } 63 | 64 | if (!child.props.name) { 65 | throw new Error('Segment requires `name` to be defined.'); 66 | } 67 | 68 | tempMap[child.props.name] = index; 69 | }); 70 | 71 | _setMap(tempMap); 72 | }, []); 73 | 74 | // Set slider width 75 | useEffect(() => { 76 | _setSliderWidth(_width * (1 / values.length - 0.015)); 77 | }, [values, _width]); 78 | 79 | // Set initial slider position 80 | useEffect(() => { 81 | if ( 82 | typeof _initialSelectedName !== 'undefined' && 83 | typeof _map !== 'undefined' && 84 | _width > 0 && 85 | !_initialized 86 | ) { 87 | const index = _map[_initialSelectedName]; 88 | const position = _width * (index / values.length); 89 | _setSliderPosition(new Animated.Value(position)); 90 | _setInitialized(true); 91 | } 92 | }, [values, _width, _map, _initialSelectedName]); 93 | 94 | // This hook is used to animate the slider position 95 | Animated.useCode(() => { 96 | const index = _activeName && _map ? _map[_activeName] : 0; 97 | const sliderPosition = _width * (index / values.length); 98 | 99 | return Animated.set( 100 | _sliderPosition, 101 | timing({ 102 | from: _sliderPosition, 103 | to: sliderPosition, 104 | easing: Easing.linear, 105 | duration: 200, 106 | }), 107 | ); 108 | }, [_activeName]); 109 | 110 | const handleLayout = (event: LayoutChangeEvent): void => 111 | _setWidth(event.nativeEvent.layout.width); 112 | 113 | const handleChangeValue = (name: string): void => { 114 | if (typeof _activeName === 'undefined' && typeof _map !== 'undefined') { 115 | const index = _map[name]; 116 | _setSliderPosition(new Animated.Value(_width * (index / values.length))); 117 | } 118 | _setActiveName(name); 119 | 120 | if (typeof onChangeValue === 'function') { 121 | onChangeValue(name); 122 | } 123 | }; 124 | 125 | const handleGestureEvent = (event: PanGestureHandlerGestureEvent): void => { 126 | if (disabled) return; 127 | 128 | const { x } = event.nativeEvent; 129 | 130 | const calculatedIndex = Math.floor((x / _width) * values.length); 131 | const index = clamp(calculatedIndex, 0, values.length - 1); 132 | const { name } = values[index].props; 133 | 134 | handleChangeValue(name); 135 | }; 136 | 137 | const currentIndex = _map?.[_activeName || ''] ?? -1; 138 | 139 | return ( 140 | 146 | 147 | 156 | {typeof _activeName !== 'undefined' && ( 157 | 173 | )} 174 | 175 | {values.map((child, index) => { 176 | return ( 177 | 178 | {index > 0 && ( 179 | 182 | )} 183 | {{ 184 | ...child, 185 | props: { 186 | disabled, 187 | inactiveTintColor, 188 | activeTintColor, 189 | ...child.props, 190 | }, 191 | }} 192 | 193 | ); 194 | })} 195 | 196 | 197 | 198 | ); 199 | }; 200 | 201 | export default SegmentedControl; 202 | -------------------------------------------------------------------------------- /src/SegmentedControl/SegmentedControlStyles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | const styles = StyleSheet.create({ 4 | container: { 5 | backgroundColor: '#eeeeef', 6 | flexDirection: 'row', 7 | alignItems: 'center', 8 | borderRadius: 8, 9 | height: 28, 10 | position: 'relative', 11 | }, 12 | disabledContainer: { 13 | opacity: 0.5, 14 | }, 15 | slider: { 16 | position: 'absolute', 17 | top: 0, 18 | left: 0, 19 | zIndex: 1, 20 | }, 21 | sliderDefault: { 22 | height: '86%', 23 | backgroundColor: 'white', 24 | borderRadius: 7, 25 | margin: 2, 26 | shadowOffset: { width: 0.95, height: 0.95 }, 27 | shadowColor: '#a2a2a2', 28 | shadowOpacity: 0.5, 29 | shadowRadius: 2, 30 | }, 31 | }); 32 | 33 | export default styles; 34 | -------------------------------------------------------------------------------- /src/SegmentedControl/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SegmentedControl'; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { SegmentedControl, SegmentedControlProps } from './SegmentedControl'; 2 | export { Segment, SegmentProps, SegmentContentProps } from './Segment'; 3 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function clamp(num: number, min: number, max: number): number { 2 | return Math.min(Math.max(num, min), max); 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | "module": "commonjs", 5 | "target": "es5", 6 | "lib": ["es5", "es6", "es7", "es2017"], 7 | "sourceMap": true, 8 | "allowJs": false, 9 | "jsx": "react", 10 | "moduleResolution": "node", 11 | "rootDirs": ["src"], 12 | "baseUrl": "./src", 13 | "forceConsistentCasingInFileNames": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noUnusedLocals": true, 20 | "declaration": true, 21 | "allowSyntheticDefaultImports": true, 22 | "experimentalDecorators": true, 23 | "skipLibCheck": true, 24 | "emitDecoratorMetadata": true, 25 | "esModuleInterop": true, 26 | "typeRoots": ["./@types", "./node_modules/@types"] 27 | }, 28 | "include": ["src/**/*", "__tests__"], 29 | "exclude": ["node_modules", "lib", "scripts", "__tests__"] 30 | } 31 | --------------------------------------------------------------------------------