├── .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 | [](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 | 
8 | 
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 |
--------------------------------------------------------------------------------