Skip to content

Commit

Permalink
Support typing into native inputs in swap (#6100)
Browse files Browse the repository at this point in the history
* Remove unused TODO as build quote params should always rely on the corresponding input output amounts even if native values are updated

* Support input native changes in swap inputs controller animated reaction

* Temp cleanup on SwapInputAsset to pull out SwapInputAmountCaret and SwapInputNativeAmount

* Temp cleanup on SwapOutputAsset to pull out SwapOutputAmountCaret and SwapOutputNativeAmount

* Placeholder: generic SwapInputValuesCaret that still need to be hooked up with native inputs

* Add caret animatedStyle to SwapInputValuesCaret

* Use SwapInputValuesCaret component in SwapInputAsset and SwapOutputAsset

* Remove old input and output caret styles from useSwapTextStyles

* Cleanup of unused imports in swaps types

* Add assetToSellCaretStyle and assetToBuyCaretStyle to caret component

* Remove now unused assetToSell/BuyCaretStyles from useAnimatedSwapStyles

* Update native input in SwapInputAsset to be typeable

* Define size style for native caret in SwapInputValuesCaret

* Update swap number pad

Remove formatting that isn't a decimal or number on inputs.
Previously, we were removing only commas, but now that we support native
inputs, we want to remove the native currency symbols.

Prevent updating of the native inputs if their is no corresponding price
for the input / output asset.

* Create separate SwapNativeInput component

* Remove unused caret styles from SwapInputAsset and SwapOutputAsset

* Add param to ignore alignment for native currency formatting and add handler for basic currency formatting

* Update width on nativeCaret

* Add support for changes to native output value

* Fix missing checks for inputNativeValue and outputNativeValue

* Split native currency symbol from value in native input component

* Disable caret when correponding asset does not have price for native input

* Update formatting for input amount if input method is native inputs

* Disable pointer events when no price on native inputs

* Distinguish between placeholder values vs typed inputs for native input values

* Update checks for color for zero value in swap text styles

* Disable focus and pointer events on native output if output based quotes disabled

* Support showing explainer if output quotes disabled and user tries to update output native amount

* Ignore native input changes if native currency decimals exceeded

* Update add decimal in swap number pad to also handle scenarios where there are no native currency decimals

* Update South Korea and Japan currencies as they do not use decimals

* Fix numbers test after JPY decimal changes

* Update numbers test value which should get rounded up

* Fix: check for native placeholder value while checking native input decimal places
  • Loading branch information
jinchung authored Sep 20, 2024
1 parent ed06d93 commit 95abd8a
Show file tree
Hide file tree
Showing 14 changed files with 419 additions and 230 deletions.
30 changes: 6 additions & 24 deletions src/__swaps__/screens/Swap/components/SwapInputAsset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { FadeMask } from '@/__swaps__/screens/Swap/components/FadeMask';
import { GestureHandlerButton } from '@/__swaps__/screens/Swap/components/GestureHandlerButton';
import { SwapActionButton } from '@/__swaps__/screens/Swap/components/SwapActionButton';
import { SwapInput } from '@/__swaps__/screens/Swap/components/SwapInput';
import { SwapNativeInput } from '@/__swaps__/screens/Swap/components/SwapNativeInput';
import { SwapInputValuesCaret } from '@/__swaps__/screens/Swap/components/SwapInputValuesCaret';
import { TokenList } from '@/__swaps__/screens/Swap/components/TokenList/TokenList';
import { BASE_INPUT_WIDTH, INPUT_INNER_WIDTH, INPUT_PADDING, THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants';

Expand Down Expand Up @@ -50,7 +52,7 @@ function SwapInputActionButton() {
}

function SwapInputAmount() {
const { focusedInput, SwapTextStyles, SwapInputController, AnimatedSwapStyles } = useSwapContext();
const { focusedInput, SwapTextStyles, SwapInputController } = useSwapContext();

return (
<CopyPasteMenu
Expand Down Expand Up @@ -83,9 +85,7 @@ function SwapInputAmount() {
>
{SwapInputController.formattedInputAmount}
</AnimatedText>
<Animated.View style={[styles.caretContainer, SwapTextStyles.inputCaretStyle]}>
<Box as={Animated.View} borderRadius={1} style={[styles.caret, AnimatedSwapStyles.assetToSellCaretStyle]} />
</Animated.View>
<SwapInputValuesCaret inputCaretType="inputAmount" />
</MaskedView>
</GestureHandlerButton>
</CopyPasteMenu>
Expand Down Expand Up @@ -119,15 +119,7 @@ function InputAssetBalanceBadge() {
}

export function SwapInputAsset() {
const {
outputProgress,
inputProgress,
AnimatedSwapStyles,
SwapTextStyles,
SwapInputController,
internalSelectedInputAsset,
SwapNavigation,
} = useSwapContext();
const { outputProgress, inputProgress, AnimatedSwapStyles, internalSelectedInputAsset, SwapNavigation } = useSwapContext();

return (
<SwapInput asset={internalSelectedInputAsset} otherInputProgress={outputProgress} progress={inputProgress}>
Expand All @@ -143,9 +135,7 @@ export function SwapInputAsset() {
</Column>
</Columns>
<Columns alignHorizontal="justify" alignVertical="center" space="10px">
<AnimatedText numberOfLines={1} size="17pt" style={SwapTextStyles.inputNativeValueStyle} weight="heavy">
{SwapInputController.formattedInputNativeValue}
</AnimatedText>
<SwapNativeInput nativeInputType="inputNativeValue" />
<Column width="content">
<InputAssetBalanceBadge />
</Column>
Expand Down Expand Up @@ -174,14 +164,6 @@ export const styles = StyleSheet.create({
backgroundOverlay: {
backgroundColor: 'rgba(0, 0, 0, 0.88)',
},
caret: {
height: 32,
width: 2,
},
caretContainer: {
flexGrow: 100,
flexShrink: 0,
},
flipButton: {
borderRadius: 15,
height: 30,
Expand Down
95 changes: 95 additions & 0 deletions src/__swaps__/screens/Swap/components/SwapInputValuesCaret.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Box, useColorMode } from '@/design-system';
import React from 'react';
import { StyleSheet } from 'react-native';
import Animated, { Easing, SharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming } from 'react-native-reanimated';
import { SLIDER_COLLAPSED_HEIGHT, SLIDER_HEIGHT, caretConfig } from '@/__swaps__/screens/Swap/constants';
import { equalWorklet } from '@/__swaps__/safe-math/SafeMath';
import { NavigationSteps } from '@/__swaps__/screens/Swap/hooks/useSwapNavigation';
import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider';
import { inputKeys } from '@/__swaps__/types/swap';
import { getColorValueForThemeWorklet } from '@/__swaps__/utils/swaps';

export function SwapInputValuesCaret({ inputCaretType, disabled }: { inputCaretType: inputKeys; disabled?: SharedValue<boolean> }) {
const { isDarkMode } = useColorMode();
const {
configProgress,
focusedInput,
inputProgress,
internalSelectedInputAsset,
internalSelectedOutputAsset,
isQuoteStale,
outputProgress,
SwapInputController,
sliderPressProgress,
} = useSwapContext();

const inputMethod = SwapInputController.inputMethod;
const inputValues = SwapInputController.inputValues;

const caretStyle = useAnimatedStyle(() => {
const shouldShow =
!disabled?.value &&
configProgress.value === NavigationSteps.INPUT_ELEMENT_FOCUSED &&
focusedInput.value === inputCaretType &&
inputProgress.value === 0 &&
outputProgress.value === 0 &&
(inputMethod.value !== 'slider' ||
(inputMethod.value === 'slider' && equalWorklet(inputValues.value.inputAmount, 0)) ||
(sliderPressProgress.value === SLIDER_COLLAPSED_HEIGHT / SLIDER_HEIGHT && isQuoteStale.value === 0));

const opacity = shouldShow
? withRepeat(
withSequence(
withTiming(1, { duration: 0 }),
withTiming(1, { duration: 400, easing: Easing.bezier(0.87, 0, 0.13, 1) }),
withTiming(0, caretConfig),
withTiming(1, caretConfig)
),
-1,
true
)
: withTiming(0, caretConfig);

const isZero =
(inputMethod.value !== 'slider' && inputValues.value[inputCaretType] === 0) ||
(inputMethod.value === 'slider' && equalWorklet(inputValues.value.inputAmount, 0));

return {
display: shouldShow ? 'flex' : 'none',
opacity,
position: isZero ? 'absolute' : 'relative',
};
});

const assetCaretStyle = useAnimatedStyle(() => {
const selectedAsset =
inputCaretType === 'inputAmount' || inputCaretType === 'inputNativeValue' ? internalSelectedInputAsset : internalSelectedOutputAsset;
return {
backgroundColor: getColorValueForThemeWorklet(selectedAsset.value?.highContrastColor, isDarkMode, true),
};
});

const caretSizeStyle =
inputCaretType === 'inputNativeValue' || inputCaretType === 'outputNativeValue' ? styles.nativeCaret : styles.inputCaret;

return (
<Animated.View style={[styles.caretContainer, caretStyle]}>
<Box as={Animated.View} borderRadius={1} style={[caretSizeStyle, assetCaretStyle]} />
</Animated.View>
);
}

export const styles = StyleSheet.create({
nativeCaret: {
height: 19,
width: 1.5,
},
inputCaret: {
height: 32,
width: 2,
},
caretContainer: {
flexGrow: 100,
flexShrink: 0,
},
});
82 changes: 82 additions & 0 deletions src/__swaps__/screens/Swap/components/SwapNativeInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { AnimatedText, Box } from '@/design-system';
import React from 'react';
import { StyleSheet } from 'react-native';
import Animated, { runOnJS, useAnimatedStyle, useDerivedValue } from 'react-native-reanimated';

import { SwapInputValuesCaret } from '@/__swaps__/screens/Swap/components/SwapInputValuesCaret';
import { GestureHandlerButton } from '@/__swaps__/screens/Swap/components/GestureHandlerButton';
import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider';
import { equalWorklet } from '@/__swaps__/safe-math/SafeMath';

export function SwapNativeInput({
nativeInputType,
handleTapWhileDisabled,
}: {
nativeInputType: 'inputNativeValue' | 'outputNativeValue';
handleTapWhileDisabled?: () => void;
}) {
const {
focusedInput,
internalSelectedInputAsset,
internalSelectedOutputAsset,
outputQuotesAreDisabled,
SwapTextStyles,
SwapInputController,
} = useSwapContext();

const formattedNativeInput =
nativeInputType === 'inputNativeValue' ? SwapInputController.formattedInputNativeValue : SwapInputController.formattedOutputNativeValue;

const textStyle = nativeInputType === 'inputNativeValue' ? SwapTextStyles.inputNativeValueStyle : SwapTextStyles.outputNativeValueStyle;

const nativeCurrencySymbol = formattedNativeInput.value.slice(0, 1);
const formattedNativeValue = useDerivedValue(() => {
return formattedNativeInput.value.slice(1);
});

const disabled = useDerivedValue(() => {
if (nativeInputType === 'outputNativeValue' && outputQuotesAreDisabled.value) return true;

// disable caret and pointer events for native inputs when corresponding asset is missing price
const asset = nativeInputType === 'inputNativeValue' ? internalSelectedInputAsset : internalSelectedOutputAsset;
const assetPrice = asset.value?.nativePrice || asset.value?.price?.value || 0;
return !assetPrice || equalWorklet(assetPrice, 0);
});

const pointerEventsStyle = useAnimatedStyle(() => {
return {
pointerEvents: disabled.value ? 'none' : 'box-only',
};
});

return (
<GestureHandlerButton
disableButtonPressWrapper
onPressStartWorklet={() => {
'worklet';
if (outputQuotesAreDisabled.value && handleTapWhileDisabled && nativeInputType === 'outputNativeValue') {
runOnJS(handleTapWhileDisabled)();
} else {
focusedInput.value = nativeInputType;
}
}}
>
<Box as={Animated.View} style={[styles.nativeRowContainer, pointerEventsStyle]}>
<AnimatedText numberOfLines={1} size="17pt" style={textStyle} weight="heavy">
{nativeCurrencySymbol}
</AnimatedText>
<Box as={Animated.View} style={styles.nativeContainer}>
<AnimatedText numberOfLines={1} size="17pt" style={textStyle} weight="heavy">
{formattedNativeValue}
</AnimatedText>
<SwapInputValuesCaret inputCaretType={nativeInputType} disabled={disabled} />
</Box>
</Box>
</GestureHandlerButton>
);
}

export const styles = StyleSheet.create({
nativeContainer: { alignItems: 'center', flexDirection: 'row', height: 17, pointerEvents: 'box-only' },
nativeRowContainer: { alignItems: 'center', flexDirection: 'row' },
});
86 changes: 75 additions & 11 deletions src/__swaps__/screens/Swap/components/SwapNumberPad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import Animated, {
withDelay,
withTiming,
} from 'react-native-reanimated';

import { supportedNativeCurrencies } from '@/references';
import { Bleed, Box, Columns, HitSlop, Separator, Text, useColorMode, useForegroundColor } from '@/design-system';
import { stripCommas } from '@/__swaps__/utils/swaps';
import { equalWorklet } from '@/__swaps__/safe-math/SafeMath';
import { stripNonDecimalNumbers } from '@/__swaps__/utils/swaps';
import {
CUSTOM_KEYBOARD_HEIGHT,
LIGHT_SEPARATOR_COLOR,
Expand All @@ -30,6 +31,7 @@ import { colors } from '@/styles';
import { NavigationSteps, useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider';
import { IS_IOS } from '@/env';
import { inputKeys } from '@/__swaps__/types/swap';
import { useAccountSettings } from '@/hooks';

type numberPadCharacter = number | 'backspace' | '.';

Expand All @@ -49,23 +51,81 @@ const getFormattedInputKey = (inputKey: inputKeys) => {

export const SwapNumberPad = () => {
const { isDarkMode } = useColorMode();
const { focusedInput, isQuoteStale, SwapInputController, configProgress, outputQuotesAreDisabled } = useSwapContext();
const { nativeCurrency } = useAccountSettings();
const {
focusedInput,
internalSelectedInputAsset,
internalSelectedOutputAsset,
isQuoteStale,
SwapInputController,
configProgress,
outputQuotesAreDisabled,
} = useSwapContext();

const longPressTimer = useSharedValue(0);

const addNumber = (number?: number) => {
const removeFormatting = (inputKey: inputKeys) => {
'worklet';
return stripNonDecimalNumbers(SwapInputController[getFormattedInputKey(inputKey)].value);
};

const ignoreChange = ({ currentValue, addingDecimal = false }: { currentValue?: string; addingDecimal?: boolean }) => {
'worklet';
// ignore when: outputQuotesAreDisabled and we are updating the output amount or output native value
if ((focusedInput.value === 'outputAmount' || focusedInput.value === 'outputNativeValue') && outputQuotesAreDisabled.value) {
return true;
}

// ignore when: corresponding asset does not have a price and we are updating native inputs
const inputAssetPrice = internalSelectedInputAsset.value?.nativePrice || internalSelectedInputAsset.value?.price?.value || 0;
const outputAssetPrice = internalSelectedOutputAsset.value?.nativePrice || internalSelectedOutputAsset.value?.price?.value || 0;
const outputAssetHasNoPrice = !outputAssetPrice || equalWorklet(outputAssetPrice, 0);
const inputAssetHasNoPrice = !inputAssetPrice || equalWorklet(inputAssetPrice, 0);
if (
(focusedInput.value === 'outputNativeValue' && outputAssetHasNoPrice) ||
(focusedInput.value === 'inputNativeValue' && inputAssetHasNoPrice)
) {
return true;
}

// ignore when: decimals exceed native currency decimals
if (currentValue) {
const currentValueDecimals = currentValue.split('.')?.[1]?.length ?? -1;
const nativeCurrencyDecimals = supportedNativeCurrencies[nativeCurrency].decimals;

const isNativePlaceholderValue = equalWorklet(currentValue, 0) && SwapInputController.inputMethod.value !== focusedInput.value;

if (addingDecimal && nativeCurrencyDecimals === 0) {
return true;
} else if (
(focusedInput.value === 'inputNativeValue' || focusedInput.value === 'outputNativeValue') &&
!isNativePlaceholderValue &&
currentValueDecimals >= nativeCurrencyDecimals
) {
return true;
}
}
return false;
};

const addNumber = (number?: number) => {
'worklet';
const inputKey = focusedInput.value;
const currentValue = removeFormatting(inputKey);

if (ignoreChange({ currentValue })) {
return;
}

// Immediately stop the quote fetching interval
SwapInputController.quoteFetchingInterval.stop();

const inputKey = focusedInput.value;
const currentValue = stripCommas(SwapInputController[getFormattedInputKey(inputKey)].value);
const inputMethod = SwapInputController.inputMethod.value;

const newValue = currentValue === '0' ? `${number}` : `${currentValue}${number}`;
const isNativePlaceholderValue =
equalWorklet(currentValue, 0) && inputMethod !== inputKey && (inputKey === 'inputNativeValue' || inputKey === 'outputNativeValue');

const newValue = currentValue === '0' || isNativePlaceholderValue ? `${number}` : `${currentValue}${number}`;

// For a uint256, the maximum value is:
// 2e256 − 1 =115792089237316195423570985008687907853269984665640564039457584007913129639935
Expand All @@ -79,7 +139,7 @@ export const SwapNumberPad = () => {
isQuoteStale.value = 1;
}

if (SwapInputController.inputMethod.value !== inputKey) {
if (inputMethod !== inputKey) {
SwapInputController.inputMethod.value = inputKey;
}

Expand All @@ -94,7 +154,11 @@ export const SwapNumberPad = () => {
const addDecimalPoint = () => {
'worklet';
const inputKey = focusedInput.value;
const currentValue = stripCommas(SwapInputController[getFormattedInputKey(inputKey)].value);
const currentValue = removeFormatting(inputKey);

if (ignoreChange({ currentValue, addingDecimal: true })) {
return;
}

if (!currentValue.includes('.')) {
if (SwapInputController.inputMethod.value !== inputKey) {
Expand All @@ -115,7 +179,7 @@ export const SwapNumberPad = () => {
const deleteLastCharacter = () => {
'worklet';

if ((focusedInput.value === 'outputAmount' || focusedInput.value === 'outputNativeValue') && outputQuotesAreDisabled.value) {
if (ignoreChange({})) {
return;
}

Expand All @@ -125,7 +189,7 @@ export const SwapNumberPad = () => {
SwapInputController.inputMethod.value = inputKey;
}

const currentValue = stripCommas(SwapInputController[getFormattedInputKey(inputKey)].value);
const currentValue = removeFormatting(inputKey);
// Handle deletion, ensuring a placeholder zero remains if the entire number is deleted
const newValue = currentValue.length > 1 ? currentValue.slice(0, -1) : 0;

Expand Down
Loading

0 comments on commit 95abd8a

Please sign in to comment.