Created
August 30, 2026 19:08
-
-
Save rajibchy/4bc89abc0dd86170d52af87e14b63661 to your computer and use it in GitHub Desktop.
NumberStepper React Native
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Copyright FSys Tech Limited [FSys]. All rights reserved. | |
| * | |
| * This software owned by FSys Tech Limited [FSys] and is protected by copyright law | |
| * and international copyright treaties. | |
| * | |
| * Access to and use of the software is governed by the terms of the applicable FSys Software | |
| * Services Agreement (the Agreement) and Customer end user license agreements granting | |
| * a non-assignable, non-transferable and non-exclusive license to use the software | |
| * for it's own data processing purposes under the terms defined in the Agreement. | |
| * | |
| * Except as otherwise granted within the terms of the Agreement, copying or reproduction of any part | |
| * of this source code or associated reference material to any other location for further reproduction | |
| * or redistribution, and any amendments to this copyright notice, are expressly prohibited. | |
| * | |
| * Any reproduction or redistribution for sale or hiring of the Software not in accordance with | |
| * the terms of the Agreement is a violation of copyright law. | |
| */ | |
| // by rajib chy | |
| // 1:05 AM 8/31/2026 | |
| import React, { useCallback, useEffect, useMemo, useState } from 'react'; | |
| import { | |
| View, StyleSheet, type StyleProp, | |
| type ViewStyle, type TextStyle, type KeyboardTypeOptions | |
| } from 'react-native'; | |
| import Text from './Text'; | |
| import TextInput from './TextInput'; | |
| import { TouchableOpacity } from './TouchableOpacity'; | |
| import { getMaxCount } from '../../app/util'; | |
| import { useConst } from '../hook/useConst'; | |
| type BaseProps = { | |
| readonly value: string; | |
| readonly label: string; | |
| readonly disabled?: boolean; | |
| readonly themeName: AppThemeType; | |
| readonly containerStyle?: StyleProp<ViewStyle>; | |
| readonly labelTextStyle?: StyleProp<TextStyle>; | |
| readonly valueTextStyle?: StyleProp<TextStyle>; | |
| } | |
| type StepperButtonProps = { | |
| readonly text: string; | |
| readonly disabled?: boolean; | |
| readonly themeName: AppThemeType; | |
| readonly buttonStyle?: StyleProp<ViewStyle>; | |
| readonly buttonTextStyle?: StyleProp<TextStyle>; | |
| readonly onPress: () => void; | |
| } | |
| export type NumberStepType = 'up' | 'down'; | |
| export type LabelFiledProps = BaseProps; | |
| export type NumberStepperProps = BaseProps & { | |
| readonly prop: string; | |
| /** | |
| * Works like the inputmode attribute in HTML, it determines which keyboard to open, e.g. numeric and has precedence over keyboardType. | |
| */ | |
| readonly inputMode: 'decimal' | 'numeric'; | |
| /** | |
| * Limits the maximum number of characters that can be entered. | |
| * Use this instead of implementing the logic in JS to avoid flicker. | |
| */ | |
| readonly maxLength?: number | undefined; | |
| /** | |
| * Determines which keyboard to open, e.g.`numeric`. | |
| * | |
| * The following values work across platforms: | |
| * | |
| * - `default` | |
| * - `numeric` | |
| * - `number-pad` | |
| * - `decimal-pad` | |
| * - `email-address` | |
| * - `phone-pad` | |
| * - `url` | |
| * | |
| * *iOS Only* | |
| * | |
| * The following values work on iOS only: | |
| * | |
| * - `ascii-capable` | |
| * - `numbers-and-punctuation` | |
| * - `name-phone-pad` | |
| * - `twitter` | |
| * - `web-search` | |
| * | |
| * *Android Only* | |
| * | |
| * The following values work on Android only: | |
| * | |
| * - `visible-password` | |
| * | |
| */ | |
| readonly keyboardType?: KeyboardTypeOptions | |
| readonly buttonStyle?: StyleProp<ViewStyle>; | |
| readonly buttonTextStyle?: StyleProp<TextStyle>; | |
| readonly onChangeText: (prop: string, text: string) => void; | |
| readonly onPress: (prop: string, mode: NumberStepType) => void; | |
| } | |
| type Selection = { | |
| start: number; | |
| end?: number | undefined; | |
| }; | |
| class StepperRef { | |
| private _isDiposed: boolean; | |
| private _setState: (() => void) | null = null; | |
| private _selection: Selection | undefined = undefined; | |
| private _value: string; | |
| public get selection(): Readonly<Selection> | null { | |
| if (!this._selection) | |
| return undefined; | |
| return { | |
| start: this._selection.start, | |
| end: this._value.length | |
| }; | |
| } | |
| constructor(value: string) { | |
| this._value = value ?? ""; | |
| } | |
| private _triggerState(): void { | |
| if (this._isDiposed || !this._setState) | |
| return; | |
| requestAnimationFrame(() => { | |
| if (this._isDiposed) | |
| return; | |
| this._setState(); | |
| }); | |
| } | |
| public dispose(): void { | |
| if (this._isDiposed) | |
| return; | |
| this._isDiposed = true; | |
| this._value = null; | |
| this._setState = null; | |
| this._selection = null; | |
| } | |
| public addState(next: () => void): void { | |
| this._setState = next; | |
| } | |
| public onChnageProp(value: string): void { | |
| const oval = this._value; | |
| this._value = value; | |
| if (oval !== value && this._selection) { | |
| this._selection.end = this._value.length; | |
| this._triggerState(); | |
| } | |
| } | |
| public onChangeText(value: string): void { | |
| this._value = value; | |
| this._selection = null; | |
| } | |
| public onFocus(): boolean { | |
| const vlen = this._value?.length ?? 0; | |
| if (!this._selection && vlen === 0) | |
| return false; | |
| this._selection = vlen === 0 ? null : { | |
| start: 0, end: vlen | |
| }; | |
| return true; | |
| } | |
| } | |
| const NumberStepper = React.memo<NumberStepperProps>(({ | |
| prop, | |
| value, | |
| label, | |
| inputMode, | |
| maxLength, | |
| disabled = false, | |
| keyboardType, | |
| onPress, | |
| buttonStyle, | |
| containerStyle, | |
| themeName, | |
| buttonTextStyle, | |
| labelTextStyle, | |
| valueTextStyle, | |
| onChangeText | |
| }) => { | |
| const _ref = useConst<StepperRef>( | |
| () => new StepperRef(value) | |
| ); | |
| const [, setState] = useState(0); | |
| const _onPlus = useCallback( | |
| () => onPress(prop, "up"), [prop, onPress] | |
| ); | |
| const _onMinus = useCallback( | |
| () => onPress(prop, "down"), [prop, onPress] | |
| ); | |
| const _onChangeText = useCallback( | |
| (text: string) => { | |
| _ref.onChangeText(text); | |
| onChangeText(prop, text); | |
| }, [prop, onChangeText] | |
| ); | |
| const _onFocus = useCallback(() => { | |
| if (!_ref.onFocus()) | |
| return; | |
| requestAnimationFrame( | |
| () => setState(getMaxCount) | |
| ); | |
| }, []); | |
| useEffect(() => { | |
| _ref.addState( | |
| () => setState(getMaxCount) | |
| ); | |
| return () => _ref.dispose(); | |
| }, []) | |
| const textStyle = useMemo<StyleProp<TextStyle>>( | |
| () => [styles.value, valueTextStyle], [valueTextStyle] | |
| ); | |
| const _rootStyle = useMemo<StyleProp<ViewStyle>>(() => [ | |
| styles.container, | |
| containerStyle, | |
| disabled ? { opacity: 0.5 } : {} | |
| ], [containerStyle, disabled]); | |
| const _labelStyle = useMemo<StyleProp<TextStyle>>( | |
| () => [styles.label, labelTextStyle], [labelTextStyle] | |
| ); | |
| useEffect( | |
| () => _ref.onChnageProp(value), [value] | |
| ); | |
| return ( | |
| <View | |
| style={_rootStyle} | |
| > | |
| <StepperButton | |
| text='-' | |
| onPress={_onMinus} | |
| disabled={disabled} | |
| themeName={themeName} | |
| buttonStyle={buttonStyle} | |
| buttonTextStyle={buttonTextStyle} | |
| /> | |
| <View | |
| style={styles.center} | |
| > | |
| <Text | |
| style={_labelStyle} | |
| > | |
| {label} | |
| </Text> | |
| <TextInput | |
| value={value} | |
| onFocus={_onFocus} | |
| autoComplete='off' | |
| style={textStyle} | |
| inputMode={inputMode} | |
| maxLength={maxLength} | |
| selection={_ref.selection} | |
| editable={disabled ? false : true} | |
| keyboardType={keyboardType} | |
| keyboardAppearance={themeName} | |
| onChangeText={_onChangeText} | |
| /> | |
| </View> | |
| <StepperButton | |
| text='+' | |
| onPress={_onPlus} | |
| disabled={disabled} | |
| themeName={themeName} | |
| buttonStyle={buttonStyle} | |
| buttonTextStyle={buttonTextStyle} | |
| /> | |
| </View> | |
| ); | |
| }, _isPropsEqual); | |
| NumberStepper.displayName = "NumberStepper"; | |
| const StepperButton = React.memo<StepperButtonProps>(({ | |
| text, | |
| onPress, | |
| disabled, | |
| buttonStyle, | |
| buttonTextStyle | |
| }) => { | |
| const _buttonStyle = useMemo<StyleProp<ViewStyle>>(() => [ | |
| styles.button, | |
| buttonStyle, | |
| disabled ? { opacity: 0.5 } : {} | |
| ], [buttonStyle, disabled]); | |
| const _textStyle = useMemo<StyleProp<TextStyle>>( | |
| () => [styles.buttonText, buttonTextStyle], [buttonTextStyle] | |
| ); | |
| return ( | |
| <TouchableOpacity | |
| onPress={onPress} | |
| disabled={disabled} | |
| activeOpacity={disabled ? 0.7 : 1} | |
| style={_buttonStyle} | |
| > | |
| <Text | |
| style={_textStyle} | |
| > | |
| {text} | |
| </Text> | |
| </TouchableOpacity> | |
| ) | |
| }, (p, n) => p.disabled === n.disabled && p.themeName === n.themeName); | |
| StepperButton.displayName = 'StepperButton'; | |
| const LabelFiled = React.memo<LabelFiledProps>(({ | |
| value, | |
| label, | |
| containerStyle, | |
| labelTextStyle, | |
| valueTextStyle | |
| }) => { | |
| const viewStyle = useMemo<StyleProp<ViewStyle>>( | |
| () => [{ flex: 1, height: 52, alignItems: 'center', }, containerStyle], [containerStyle] | |
| ); | |
| const labelStyle = useMemo<StyleProp<TextStyle>>( | |
| () => [styles.label, labelTextStyle], [labelTextStyle] | |
| ); | |
| const valueStyle = useMemo<StyleProp<TextStyle>>( | |
| () => [styles.value, { paddingTop: 5 }, valueTextStyle], [valueTextStyle] | |
| ); | |
| return ( | |
| <View | |
| style={viewStyle} | |
| > | |
| <Text | |
| style={labelStyle} | |
| > | |
| {label} | |
| </Text> | |
| <Text | |
| numberOfLines={1} | |
| ellipsizeMode='tail' | |
| style={valueStyle} | |
| > | |
| {value} | |
| </Text> | |
| </View> | |
| ) | |
| }, _isPropsEqual); | |
| LabelFiled.displayName = 'LabelFiled'; | |
| export default NumberStepper; | |
| export { LabelFiled, StepperButton }; | |
| function _isPropsEqual(prevProps: BaseProps, nextProps: BaseProps) { | |
| return ( | |
| prevProps.value === nextProps.value && | |
| prevProps.label === nextProps.label && | |
| prevProps.disabled === nextProps.disabled && | |
| prevProps.themeName === nextProps.themeName | |
| ); | |
| } | |
| const styles = StyleSheet.create({ | |
| container: { | |
| flex: 1, | |
| height: 52, | |
| flexDirection: 'row', | |
| alignItems: 'center', | |
| backgroundColor: '#1f1f1f', | |
| borderRadius: 14, | |
| }, | |
| button: { | |
| paddingTop: 10, | |
| width: 36, | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| }, | |
| buttonText: { | |
| color: '#fff', | |
| fontSize: 22, | |
| fontWeight: '600' | |
| }, | |
| center: { | |
| flex: 1, | |
| paddingTop: 13, | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| }, | |
| label: { | |
| color: '#9e9e9e', | |
| fontSize: 12, | |
| }, | |
| value: { | |
| fontSize: 18, | |
| color: '#ffffff', | |
| fontWeight: 'bold', | |
| width: '100%', | |
| textAlign: 'center', | |
| textAlignVertical: 'center' | |
| }, | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment