Skip to content

Instantly share code, notes, and snippets.

@rajibchy
Created August 30, 2026 19:08
Show Gist options
  • Select an option

  • Save rajibchy/4bc89abc0dd86170d52af87e14b63661 to your computer and use it in GitHub Desktop.

Select an option

Save rajibchy/4bc89abc0dd86170d52af87e14b63661 to your computer and use it in GitHub Desktop.
NumberStepper React Native
/*
* 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