I am currently working on a unit conversion calculator in React Native + Expo Go. Handle the value of each TextInput to convert hectares to m/2. When typing in one TextInput the result will be set in the other TextInput and do the calculation vice versa. This works correctly on Android devices but not on iOS devices.
The problem is when I typing on iOS devices the caculation execute 2 times, changing input's values on both TextInputs instead of one of this.
- package.json
"expo": "~50.0.8","react": "18.2.0","react-native": "0.73.4","react-hook-form": "^7.49.2","typescript": "^5.1.3"
- Parent component:
const unitConverter = () => { const [convertionSelected, setConvertionSelected] = useState("area"); const [listOfUnits, setListOfUnits] = useState(converters.area); const [showUnitsModal, setShowUnitsModal] = useState(false); const [unitNameFrom, setUnitNameFrom]: useStateProp<null | string> = useState(null); const [unitNameTo, setUnitNameTo]: useStateProp<null | string> = useState(null); const [selectClicked, setSelectClicked]: useStateProp<null | string> = useState(null); const [unitSelectedTop, setUnitSelectedTop]: useStateProp<null | item> = useState(null); const [unitSelectedBottom, setUnitSelectedBottom]: useStateProp<null | item> = useState(null); const [valueTop, setValueTop] = useState("0") const [valueBottom, setValueBottom] = useState("0") const [isScrollEnabled, setIsScrollEnabled] = useState(false); const scrollViewRef = useRef(null) const { control, setError, formState: { errors }, clearErrors, reset, } = useForm({ defaultValues: { inputTop: "", inputBottom: "" }, }); /** * Setting type of converter * * @param converter unit converter, area, peso, volumen */ const handleConverterType = (converter: string) => { setConvertionSelected(converter); if (converter === "area") setListOfUnits(converters.area); if (converter === "peso") setListOfUnits(converters.peso); if (converter === "volumen") setListOfUnits(converters.volumen); if (unitNameFrom) setUnitNameFrom(null); if (unitNameTo) setUnitNameTo(null); if (unitSelectedTop) setUnitSelectedTop(null); if (unitSelectedBottom) setUnitSelectedBottom(null); if (valueTop.length || valueBottom.length) { setValueTop("0"); setValueBottom("0"); } }; /** * Handle units select modal visibility * * @param show handle select modal visibility * @param select handle if clicked on top or bottom buttons selects */ const handleShowUnitsModal = (show: boolean, select: string = "") => { if (platform === "ios" && Keyboard.isVisible()) Keyboard.dismiss(); setShowUnitsModal(show); setSelectClicked(select); }; // handle unit selected const handleUnitSelected = (unit: item) => { if (selectClicked === "top") { if (showUnitsModal) handleShowUnitsModal(false); setUnitSelectedTop(unit); setUnitNameFrom(unit.option); } if (selectClicked === "bottom") { if (showUnitsModal) handleShowUnitsModal(false); setUnitSelectedBottom(unit); setUnitNameTo(unit.option); } if (JSON.stringify(errors) != "{}") { clearErrors("inputTop"); clearErrors("inputBottom"); } }; // Handling if input value is a number const handleIsNotNum = (input: string) => { if (input === "top") { setError("inputTop", { type: "required", message: "Ingrese un valor numérico válido", }); } else { setError("inputBottom", { type: "required", message: "Ingrese un valor numérico válido", }); } }; // Handling useFrom's clearErrors for inputs const handleClearErrors = (input: clearErrorsProp) => { if (errors[input]?.message) clearErrors(input); } /** * Handle input's value format it a thoundsands, and/or centecimals * * @param num {string} input value * @returns formated input value as latam coin */ const formatNumber = (num: string) => { if (num === ",") return "0," if (num.length === 2) { if (num[0] === "0" && ONLY_NUMBER_REGEX.test(num[1])) num = num[1] } num = num.replaceAll(".", ""); if (num.includes(",")) { const [integers, decimals] = num.split(",") const newDecimals = decimals.length >= 4 ? decimals.slice(0, 4) : decimals if (newDecimals.length === 4 && newDecimals.split("").every(num => num === "0")) return integers return integers.replace(FORMAT_NUM_REGEX, ".").concat(",").concat(newDecimals) } else { return num.replace(FORMAT_NUM_REGEX, ".") } } /** * Handle convertion result value format it a thoundsands, and/or centecimals * * @param num {string} convertion result value * @returns convertion result value as latam coin */ const formatConvertionResult = (num: string) => { if (num.includes(".")) { const [integers, decimals] = num.split(".") const formatIntegers = integers.replace(FORMAT_NUM_REGEX, ".") // greater than or equals to 4 decimals if (decimals.length >= 4) { const splitDecimals = decimals.slice(0, 4) const areZeros = splitDecimals.split("").every(num => num === "0") if (areZeros) return formatIntegers return `${formatIntegers},${splitDecimals}`; } // less than to 4 decimals const splitDecimals = decimals.slice(0, 3) const areZeros = splitDecimals.split("").every(num => num === "0") if (areZeros) return formatIntegers return `${formatIntegers},${splitDecimals}` } return num.replace(FORMAT_NUM_REGEX, ".") } /** * Calculate values to get convertion by units settled * * @param num */ const settingValueToTop = (num: string = "") => { num = num.length ? num : valueBottom; num = num.replaceAll(".", ""); num = num.includes(",") ? num.replace(",", ".") : num; const unitCurrent = units[convertionSelected][unitSelectedBottom.name][unitSelectedTop.name]; const total = parseFloat(num) * unitCurrent; const formatTotal = formatConvertionResult(total.toString()) setValueTop(formatTotal); } /** * Calculate values to get convertion by units settled * * @param num */ const settingValueToBottom = (num: string = "") => { num = num.length ? num : valueTop; num = num.replaceAll(".", ""); num = num.includes(",") ? num.replace(",", ".") : num; const unitCurrent = units[convertionSelected][unitSelectedTop.name][unitSelectedBottom.name]; const total = parseFloat(num) * unitCurrent; const formatTotal = formatConvertionResult(total.toString()) setValueBottom(formatTotal); } // Input Top handler const onChangeInputTop = (num: string) => { console.log("top") console.log(num) if (num == "") { if (valueBottom !== "0") setValueBottom("0") handleClearErrors("inputTop") return setValueTop("0") } if (num === ".") return handleIsNotNum("bottom"); if (!NUMBER_REGEX.test(num)) return handleIsNotNum("top"); handleClearErrors("inputTop") const value = formatNumber(num); setValueTop(value); if (unitSelectedBottom != null && unitSelectedTop != null) { settingValueToBottom(value); } } // Input Bottom handler const onChangeInputBottom = (num: string) => { console.log("bottom") console.log(num) if (num == "") { if (valueTop !== "0") setValueTop("0"); handleClearErrors("inputBottom") return setValueBottom("0"); } if (num === ".") return handleIsNotNum("bottom"); if (!NUMBER_REGEX.test(num)) return handleIsNotNum("bottom"); handleClearErrors("inputBottom") const value = formatNumber(num); setValueBottom(value); if (unitSelectedBottom != null && unitSelectedTop != null) { settingValueToTop(value); } } /** * Handle scrollView's scroll to enable it */ const onPressInInput = () => { if (!isScrollEnabled) setIsScrollEnabled(true) } /** * Handle scrollView's scroll to disable it * when keyboard did hide, view will scroll till top in iOS devices */ Keyboard.addListener("keyboardDidHide", () => { setIsScrollEnabled(false) if (platform === "ios") { scrollViewRef.current?.scrollTo({ y: 0, animated: false }) } }); /** * Handle inputs values when unit top or bottom were changes * and setting value unit convertion to top or bottom */ useEffect(() => { if (unitSelectedTop != null && unitSelectedBottom != null) { if (valueBottom !== "0" && valueTop !== "0") return settingValueToBottom(); if (valueBottom !== "0" && valueTop === "0") return settingValueToTop(); if (valueBottom === "0" && valueTop !== "0") return settingValueToBottom(); } }, [unitSelectedTop]); useEffect(() => { if (unitSelectedTop != null && unitSelectedBottom != null) { if (valueBottom !== "0" && valueTop !== "0") return settingValueToTop(); if (valueBottom !== "0" && valueTop === "0") return settingValueToTop(); if (valueBottom === "0" && valueTop !== "0") return settingValueToBottom(); } }, [unitSelectedBottom]); return (<SafeAreaView style={styles.container}><ScrollView ref={scrollViewRef} keyboardShouldPersistTaps="handled" overScrollMode="never" scrollEnabled={isScrollEnabled} bounces={false} automaticallyAdjustKeyboardInsets={true} // onTouchEnd={onPressOutInput} style={styles.scrollview}><View style={styles.containerBody}><View style={styles.bodyTop}><HeaderTools /><ToolsNavbar convertionSelected={convertionSelected} handleConverter={handleConverterType} /><InputTopConverterUnit control={control} unitNameFrom={unitNameFrom} valueTop={valueTop} handleShowUnitsModal={handleShowUnitsModal} onChangeInputTop={onChangeInputTop} onPressInInput={onPressInInput} /><IconConverterUnit /></View><View style={[styles.bodyBottom, { marginTop: platform === "ios" ? "5%" : "10%" }]}><InputBottomConverterUnit control={control} unitNameTo={unitNameTo} valueBottom={valueBottom} handleShowUnitsModal={handleShowUnitsModal} onChangeInputBottom={onChangeInputBottom} onPressInInput={onPressInInput} /></View></View></ScrollView><ModalUnits visible={showUnitsModal} listOfUnits={listOfUnits} unitSelectedTop={unitSelectedTop} unitSelectedBottom={unitSelectedBottom} handleShowUnitsModal={handleShowUnitsModal} handleUnitSelected={handleUnitSelected} /></SafeAreaView> );};
- Inputs
type propsTop = { control: control; unitNameFrom: null | string; valueTop: string; handleShowUnitsModal: (show: boolean, select: string) => void; onChangeInputTop: (e: any) => void; onPressInInput: () => void;};export const InputTopConverterUnit: FC<propsTop> = ({ control, valueTop, unitNameFrom, handleShowUnitsModal, onChangeInputTop, onPressInInput,}) => { return (<Controller name={"inputTop"} control={control} rules={{ required: "Ingrese un valor", }} render={({ fieldState: { error } }) => { return (<View style={[styles.containerInput, { marginTop: "3%" }]}><UnitsSelect clickOn="top" onPress={handleShowUnitsModal} unitName={unitNameFrom} /><TextInput style={[ styles.inputValue, { paddingHorizontal: valueTop.length ? "10%" : "40%", }, ]} keyboardType={"numeric"} cursorColor={"#123021"} onChangeText={onChangeInputTop} value={valueTop} multiline={true} onTouchStart={onPressInInput} /> {error?.message && <Text>{error?.message}</Text>}</View> ); }} /> );};type propsBottom = { control: control; unitNameTo: null | string; valueBottom: string; handleShowUnitsModal: (show: boolean, select: string) => void; onChangeInputBottom: (num: string) => void; onPressInInput: () => void;};export const InputBottomConverterUnit: FC<propsBottom> = ({ control, valueBottom, unitNameTo, handleShowUnitsModal, onChangeInputBottom, onPressInInput,}) => { return (<Controller name={"inputBottom"} control={control} rules={{ required: "Ingrese un valor", }} defaultValue={0} render={({ fieldState: { error } }) => { return (<View style={[styles.containerInput, { height: "70%" }]}><UnitsSelect clickOn="bottom" onPress={handleShowUnitsModal} unitName={unitNameTo} /><TextInput style={[ styles.inputValue, { paddingHorizontal: valueBottom.length ? "10%" : "40%", }, ]} keyboardType={"numeric"} cursorColor={"#123021"} onChangeText={onChangeInputBottom} onTouchStart={onPressInInput} value={valueBottom} multiline={true} /> {error?.message && <Text>{error?.message}</Text>}</View> ); }} /> );};
- Modal units for select
type item = { id: number; name: string; option: string;};type props = { visible: boolean; listOfUnits: item[]; unitSelectedTop: null | item; unitSelectedBottom: null | item; handleShowUnitsModal: (prop: boolean) => void; handleUnitSelected: (prop: item) => void};export const ModalUnits: FC<props> = ({ visible, listOfUnits, unitSelectedTop, unitSelectedBottom, handleShowUnitsModal, handleUnitSelected,}) => { const [newListOfUnits, setNewListOfUnits]: useStateProp<item[]> = useState(listOfUnits); const handleUnitsFromNewList = () => { if (unitSelectedBottom != null && unitSelectedTop != null) { const newList = listOfUnits.filter(unit => unit.name != unitSelectedBottom.name && unit.name != unitSelectedTop.name ); return setNewListOfUnits(newList); } } // Filter list of units by unit selected useEffect(() => { if (unitSelectedTop != null && unitSelectedBottom == null) { const newList = listOfUnits.filter(unit => unit.name != unitSelectedTop.name); return setNewListOfUnits(newList); } else { handleUnitsFromNewList(); } }, [unitSelectedTop]); // Filter list of units by unit selected useEffect(() => { if (unitSelectedBottom != null && unitSelectedTop == null) { const newList = listOfUnits.filter(unit => unit.name != unitSelectedBottom.name); return setNewListOfUnits(newList); } else { handleUnitsFromNewList(); } }, [unitSelectedBottom]) // Setting new list of units convertion useEffect(() => { setNewListOfUnits(listOfUnits) }, [listOfUnits]) return (<Modal animationType="fade" transparent={true} visible={visible}><TouchableWithoutFeedback onPress={() => handleShowUnitsModal(false)}><View style={styles.container}><View style={styles.containerModal}><ScrollView overScrollMode="never"> {newListOfUnits.map((item, index) => { return (<TouchableOpacity key={item.name} activeOpacity={0.5} style={[ styles.btnUnit, { borderBottomWidth: newListOfUnits.length !== index + 1 ? 1.5 : 0, borderBottomColor: newListOfUnits.length !== index + 1 ? "#123021" : "none", }, ]} onPress={() => { handleUnitSelected(item) }}><Text style={styles.txtUnit}>{item.option}</Text></TouchableOpacity> ) } )}</ScrollView></View></View></TouchableWithoutFeedback></Modal> );};
- constants
type unitProps = { area: {} peso: {}; volumen: {};};export const units: unitProps = { area: { ha: { m2: 10_000, mz: 1.419, tarea: 15.9, }, m2: { ha: 0.000_1, mz: 0.000_141_964, tarea: 0.001_590_330, }, mz: { ha: 0.7, m2: 7_000, tarea: 11.202_290, }, tarea: { m2: 628.8, ha: 0.062_88, mz: 0.089_267, }, }, peso: { kg: { ton: 0.001, onzas: 35.274, libras: 2.204_62, tonLargas: 0.000_984_2, tonCortas: 0.001_102, }, ton: { kg: 1_000, onzas: 35274.94, libras: 2204.62, tonLargas: 0.984_206, tonCortas: 1.102_3, }, onzas: { kg: 0.028_35, ton: 0.000_028_349_5, libras: 0.062_5, tonLargas: 0.000_027_901_8, tonCortas: 0.000_031_25, }, libras: { kg: 0.453_592, ton: 0.000_453_59, onzas: 16, tonLargas: 0.000_446_43, tonCortas: 0.000_5, }, tonLargas: { kg: 1_016.05, onzas: 35_839.98, libras: 2_240, ton: 1.016, tonCortas: 1.12, }, tonCortas: { kg: 907.18, onzas: 31_999.99, libras: 2_000, ton: 0.907_185, tonLargas: 0.892_857, }, quintales: { kg: 1000, onzas: 3_527.396_2, libras: 220.462, ton: 0.1, tonLargas: 0.098_420_7, tonCortas: 0.110_231, }, }, volumen: { gal: { // EEUU - líquido lts: 3.785, ml: 3785.411_81, }, lts: { gal: 0.264_172, ml: 1_000, }, ml: { gal: 0.000_264_17, lts: 0.001, }, },};type option = { id: number; name: string; option: string;};type convertersProps = { area: option[]; peso: option[]; volumen: option[];};export const converters: convertersProps = { area: [ { id: 0, name: "ha", option: "Hectareas (ha)", }, { id: 1, name: "m2", option: "Metros cuadrados (m2)", }, { id: 2, name: "mz", option: "Manzanas", }, { id: 3, name: "tarea", option: "Tareas", }, ], peso: [ { id: 0, name: "kg", option: "Kilogramos (kg)", }, { id: 1, name: "ton", option: "Toneladas (t)", }, { id: 2, name: "onzas", option: "Onzas (oz)", }, { id: 3, name: "tonLargas", option: "Toneladas Largas (lo tn)", }, { id: 4, name: "tonCortas", option: "Toneladas Cortas (sh tn)", }, { id: 5, name: "quintales", option: "Quintales (qm)", }, ], volumen: [ { id: 0, name: "gal", option: "Galon (gal)", }, { id: 1, name: "lts", option: "Litros (lts)", }, { id: 2, name: "ml", option: "Mililitros (ml)", }, ],};
- Button for select unit to convert
type props = { clickOn: string; onPress: (open: boolean, clickOn: string) => void; unitName: null | string;};export const UnitsSelect: FC<props> = ({ clickOn, onPress, unitName }) => { return (<TouchableOpacity activeOpacity={0.7} style={styles.select} onPress={() => onPress(true, clickOn)}><Text style={styles.txtUnit}>{unitName ?? "Seleccione unidad"}</Text><CaretDown size={30} color="#123021" style={styles.iconCartetCircleDown} /></TouchableOpacity> );};
The function is simple, type a number in a TextInput to see the result in the other TextInput when the units to convert were selected.