Interaction
Aave Swap Component
An expressive interaction rebuilt for touch
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
aave-swap-component.in-progress.tsx
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
Image,
Platform,
Pressable,
Text,
TextInput,
View,
useColorScheme,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
defineAnimation,
type AnimationObject,
Easing,
cubicBezier,
LinearTransition,
useAnimatedStyle,
useFrameCallback,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, { Path } from "react-native-svg"
import { LinearGradient } from "expo-linear-gradient"
type DelayedMotion = AnimationObject<number> & {
current: number
startedAt: number
started: boolean
held: boolean
next: AnimationObject<number>
previous?: AnimationObject<number>
}
function motionDelay(delay: number, animation: number): number {
"worklet"
const definition = animation as unknown as
AnimationObject<number> | (() => AnimationObject<number>)
return defineAnimation<DelayedMotion>(animation, () => {
"worklet"
const next = typeof definition === "function" ? definition() : definition
return {
isHigherOrder: true,
current: next.current ?? 0,
startedAt: 0,
started: false,
held: false,
next,
onStart(a, value, now, previous) {
a.current = value
a.startedAt = now
a.started = false
a.held = false
const prior = previous?.next
? previous.started
? previous.next
: previous.previous
: previous
const stationary =
previous?.next &&
!previous.started &&
(previous.held || now - previous.startedAt > 30)
a.previous = prior
? {
...prior,
current: value,
velocity: stationary ? 0 : prior.velocity,
}
: undefined
if (next.reduceMotion === undefined) next.reduceMotion = a.reduceMotion
},
onFrame(a, now) {
if (!a.started) {
if (!a.reduceMotion && now - a.startedAt < delay) {
a.held = true
return false
}
const start = a.reduceMotion ? now : a.startedAt + delay
const previous = a.previous
? {
...a.previous,
lastTimestamp: start,
startTimestamp: 0,
startTime: 0,
}
: undefined
next.onStart(next, a.current, start, previous)
a.started = true
a.previous = undefined
}
const finished = next.onFrame(next, now)
a.current = next.current ?? a.current
return finished
},
callback: next.callback,
}
}) as unknown as number
}
export interface TokenConfig {
name: string
symbol: string
priceUSD: number
max?: number
logo: string
}
export interface AaveSwapComponentProps {
from: TokenConfig
to: TokenConfig
onInputChange?: (value: string) => void
autoFocus?: boolean
compact?: boolean
width?: number
radius?: number
fontFamily?: string
theme?: "light" | "dark" | "system"
colors?: Partial<{
surface: string
border: string
text: string
muted: string
button: string
buttonHover: string
buttonMuted: string
error: string
fromLogo: string
clear: string
clearHover: string
clearBorder: string
clearText: string
}>
style?: StyleProp<ViewStyle>
testID?: string
}
const SPRING = { stiffness: 150, damping: 19, mass: 1.2 }
const INPUT_SPRING = { stiffness: 240, damping: 16, mass: 0.8 }
const CSS_EASE = cubicBezier(0.4, 0, 0.2, 1)
const BUTTON_LAYOUT = LinearTransition.springify()
.stiffness(SPRING.stiffness)
.damping(SPRING.damping)
.mass(SPRING.mass)
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
const DEFAULT_CURVE = [
0, 0.005, 0.019, 0.039, 0.066, 0.096, 0.129, 0.165, 0.202, 0.24, 0.278, 0.316,
0.354, 0.39, 0.426, 0.461, 0.494, 0.526, 0.557, 0.586, 0.614, 0.64, 0.665,
0.689, 0.711, 0.731, 0.751, 0.769, 0.786, 0.802, 0.817, 0.831, 0.844, 0.856,
0.867, 0.877, 0.887, 0.896, 0.904, 0.912, 0.919, 0.925, 0.931, 0.937, 0.942,
0.947, 0.951, 0.955, 0.959, 0.962, 0.965, 0.968, 0.971, 0.973, 0.976, 0.978,
0.98, 0.981, 0.983, 0.984, 0.986, 0.987, 0.988, 0.989, 0.99, 0.991, 0.992,
0.992, 0.993, 0.994, 0.994, 0.995, 0.995, 0.996, 0.996, 0.9963, 0.9967,
0.9969, 0.9972, 0.9975, 0.9977, 0.9979, 0.9981, 0.9982, 0.9984, 0.9985,
0.9987, 0.9988, 0.9989, 1,
]
const USD_CURVE = [
0, 0.005927, 0.022466, 0.047872, 0.080554, 0.119068, 0.162116, 0.208536,
0.2573, 0.3075, 0.358346, 0.409157, 0.45935, 0.508438, 0.556014, 0.601751,
0.645389, 0.686733, 0.72564, 0.762019, 0.795818, 0.827026, 0.855662, 0.881772,
0.905423, 0.926704, 0.945714, 0.962568, 0.977386, 0.990295, 1.001426,
1.010911, 1.018881, 1.025465, 1.030792, 1.034982, 1.038155, 1.040423,
1.041892, 1.042662, 1.042827, 1.042473, 1.04168, 1.040522, 1.039065, 1.037371,
1.035493, 1.03348, 1.031376, 1.029217, 1.027037, 1.024864, 1.022722, 1.020631,
1.018608, 1.016667, 1.014817, 1.013067, 1.011422, 1.009887, 1.008462,
1.007148, 1.005944, 1.004847, 1.003855, 1.002964, 1.002169, 1.001466,
1.000848, 1.000311, 0.999849, 0.999457, 0.999128, 0.998858, 0.99864, 0.99847,
0.998342, 0.998253, 0.998196, 0.998169, 0.998167, 0.998186, 0.998224,
0.998276, 0.998341, 0.998415, 0.998497, 0.998584, 0.998675, 0.998768,
0.998861, 0.998954, 0.999045, 0.999134, 0.99922, 0.999303, 0.999381, 0.999455,
0.999525, 0.999589, 0.99965,
]
function sample(curve: number[], p: number) {
"worklet"
const x = Math.max(0, Math.min(1, p)) * (curve.length - 1),
i = Math.floor(x)
return (
curve[i] + (curve[Math.min(i + 1, curve.length - 1)] - curve[i]) * (x - i)
)
}
function defaultEase(p: number) {
"worklet"
return sample(DEFAULT_CURVE, p)
}
function usdEase(p: number) {
"worklet"
return sample(USD_CURVE, p)
}
const OUTPUT_EASE = Easing.bezier(0, 0, 0.58, 1).factory()
function shakeAngle(progress: number) {
"worklet"
const steps = [-2, 2, -1, 1, 0]
const position = Math.max(0, Math.min(1, progress)) * 4
const index = Math.min(3, Math.floor(position))
return (
steps[index] +
(steps[index + 1] - steps[index]) * OUTPUT_EASE(position - index)
)
}
export function AaveSwapComponent({
from,
to,
onInputChange,
autoFocus = true,
compact,
width = 420,
radius,
fontFamily = Platform.OS === "web" ? "Arial" : undefined,
theme = "system",
colors: overrides,
style,
testID,
}: AaveSwapComponentProps) {
const [inputVal, setInputVal] = useState("0"),
[isMax, setIsMax] = useState(false),
[maxHovered, setMaxHovered] = useState(false),
[clearHovered, setClearHovered] = useState(false)
const window = useWindowDimensions(),
scheme = useColorScheme(),
small = compact ?? window.width < 640,
dark = theme === "dark" || (theme === "system" && scheme === "dark")
const colors = {
surface: dark ? "#0e0e0e" : "#fafafa",
border: dark ? "#2b2b2b" : "#e5e5e5",
text: dark ? "#fff" : "#171717",
muted: dark ? "#8e8e8e" : "#737373",
button: dark ? "#2b2b2b" : "#e5e5e5",
buttonHover: dark ? "#3b3b3b" : "#d4d4d4",
buttonMuted: dark ? "#8e8e8e" : "#a3a3a3",
error: "#ef4444",
fromLogo: "#6D7FF9",
clear: dark ? "#1b1b1b" : "#f5f5f5",
clearHover: dark ? "#1b1b1b" : "#e5e5e5",
clearBorder: dark ? "rgba(43,43,43,.7)" : "#e5e5e5",
clearText: dark ? "#9AADAD" : "#737373",
...overrides,
}
const numeric = parseFloat(inputVal) || 0,
usd = numeric * from.priceUSD,
output = numeric === 0 ? 0 : (numeric * from.priceUSD) / to.priceUSD,
error = !!from.max && numeric > from.max
const cardWidth = Math.min(width, window.width * 0.95),
padding = small ? 16 : 24,
cardRadius = radius ?? (small ? 28 : 32)
const logoSize = small ? 36 : 40,
amountSize = small ? 36 : 60
const clearScale = useSharedValue(1),
errorP = useSharedValue(0),
errorShake = useSharedValue(1),
usdP = useSharedValue(0),
usdY = useSharedValue(5)
useEffect(() => {
errorP.set(
motionDelay(
150,
withTiming(error ? 1 : 0, {
duration: 250,
easing: OUTPUT_EASE,
})
)
)
if (error) {
errorShake.set(0)
errorShake.set(
motionDelay(
150,
withTiming(1, { duration: 250, easing: Easing.linear })
)
)
}
usdP.set(withSpring(error ? 0 : 1, SPRING))
usdY.set(withSpring(error ? 5 : 0, SPRING))
}, [error])
const clearMotion = useAnimatedStyle(() => ({
transform: [{ scale: clearScale.get() }],
}))
const errorMotion = useAnimatedStyle(() => ({
opacity: errorP.get(),
transform: [
{ scale: 0.8 + 0.2 * errorP.get() },
{ rotate: `${shakeAngle(errorShake.get())}deg` },
],
}))
const usdMotion = useAnimatedStyle(() => ({
opacity: usdP.get(),
transform: [{ translateY: usdY.get() }],
}))
function change(raw: string) {
const value = raw.replace(/[^0-9.]/g, "")
if (value.split(".").length > 2 || value.length > 5) return
if (value !== from.max?.toString()) setIsMax(false)
const next =
value === ""
? "0"
: value.length > 1 && value.startsWith("0") && !value.startsWith("0.")
? value.replace(/^0+/, "")
: value
setInputVal(next)
onInputChange?.(next)
}
function max() {
if (!from.max) return
setIsMax(true)
setInputVal(String(from.max))
onInputChange?.(String(from.max))
}
function clear() {
setIsMax(false)
setInputVal("0")
onInputChange?.("0")
}
const card = {
padding,
borderWidth: 1.6,
borderColor: colors.border,
backgroundColor: colors.surface,
borderRadius: cardRadius,
}
return (
<View
testID={testID}
style={[{ width: cardWidth, alignSelf: "center", gap: 4 }, style]}
>
<Animated.View
style={[
card,
{
transitionProperty: ["backgroundColor", "borderColor"],
transitionDuration: 150,
transitionTimingFunction: CSS_EASE,
},
]}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
marginBottom: 16,
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: small ? 8 : 12,
flex: 1,
}}
>
<View
style={{
width: logoSize,
height: logoSize,
borderRadius: 999,
backgroundColor: colors.fromLogo,
alignItems: "center",
justifyContent: "center",
}}
>
<Image
source={{ uri: from.logo }}
accessibilityLabel={from.symbol}
style={{ width: small ? 20 : 24, height: small ? 20 : 24 }}
resizeMode="contain"
/>
</View>
<View style={{ flex: 1 }}>
<Text
numberOfLines={1}
style={{
fontFamily,
fontSize: small ? 16 : 18,
lineHeight: small ? 24 : 28,
fontWeight: "500",
color: colors.text,
}}
>
{from.name}
</Text>
{!!from.max && (
<NativeNumber
value={from.max}
size={small ? 12 : 14}
color={colors.muted}
surface={colors.surface}
fontFamily={fontFamily}
/>
)}
</View>
</View>
{!!from.max && (
<AnimatedPressable
layout={BUTTON_LAYOUT}
accessibilityRole="button"
onPress={max}
onHoverIn={() => setMaxHovered(true)}
onHoverOut={() => setMaxHovered(false)}
style={{
backgroundColor:
maxHovered && !isMax && !error
? colors.buttonHover
: colors.button,
borderRadius: 999,
paddingHorizontal: small ? 12 : 16,
paddingVertical: 6,
transitionProperty: ["backgroundColor"],
transitionDuration: 150,
transitionTimingFunction: CSS_EASE,
}}
>
<Animated.Text
style={{
fontFamily,
fontSize: small ? 12 : 16,
fontWeight: "500",
color: isMax || error ? colors.buttonMuted : colors.text,
transitionProperty: ["color"],
transitionDuration: 150,
transitionTimingFunction: CSS_EASE,
}}
>
{isMax || error ? "Using Max" : "Use Max"}
</Animated.Text>
</AnimatedPressable>
)}
</View>
<View style={{ borderTopWidth: 1, borderColor: colors.border }} />
<View
style={{
paddingVertical: small ? 24 : 32,
overflow: "hidden",
alignItems: "center",
}}
>
<View style={{ height: 80, width: "100%", overflow: "hidden" }}>
<RawAmount
value={inputVal}
width={cardWidth - 2 * padding - 3.2}
size={amountSize}
color={colors.text}
fontFamily={fontFamily}
/>
<TextInput
autoFocus={autoFocus}
keyboardType="decimal-pad"
inputMode="decimal"
accessibilityLabel={`Amount of ${from.symbol}`}
value={inputVal === "0" && !isMax ? "" : inputVal}
onChangeText={change}
placeholder="0"
placeholderTextColor="transparent"
cursorColor={colors.text}
selectionColor={colors.text}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: 80,
color: "transparent",
fontFamily,
fontSize: amountSize,
fontWeight: "500",
textAlign: "center",
padding: 0,
}}
/>
</View>
<View
style={{
height: small ? 24 : 32,
width: "100%",
marginTop: small ? 4 : 8,
alignItems: "center",
justifyContent: "center",
}}
>
<Animated.View
pointerEvents="none"
style={[
{ position: "absolute", alignItems: "center" },
errorMotion,
]}
>
<Text
style={{
fontFamily,
fontSize: small ? 14 : 18,
fontWeight: "500",
color: colors.error,
}}
>
Not Enough {from.symbol}
</Text>
</Animated.View>
<Animated.View
pointerEvents="none"
style={[
{ flexDirection: "row", gap: 4, alignItems: "center" },
usdMotion,
]}
>
<Text
style={{
fontFamily,
fontSize: small ? 18 : 16,
fontWeight: "500",
color: "#737373",
}}
>
≈
</Text>
<NativeNumber
value={usd}
currency
size={small ? 18 : 16}
color="#737373"
surface={colors.surface}
fontFamily={fontFamily}
spinDuration={0}
transformDuration={750}
transformEase={usdEase}
/>
<Svg
width={14}
height={14}
viewBox="0 0 24 24"
fill="none"
stroke="#737373"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<Path d="m3 7 4-4 4 4M7 3v18m6-4 4 4 4-4M17 21V3" />
</Svg>
</Animated.View>
</View>
</View>
</Animated.View>
<View
pointerEvents="none"
style={{
height: 16,
marginVertical: -8,
zIndex: 2,
alignItems: "center",
justifyContent: "center",
}}
>
<View
style={{
padding: 6,
borderRadius: 999,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: dark ? "#0c0c0c" : "#fff",
}}
>
<Svg
width={18}
height={18}
viewBox="0 0 24 24"
fill="none"
stroke="#a3a3a3"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<Path d="m6 9 6 6 6-6" />
</Svg>
</View>
</View>
<View
style={[
card,
{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: 8,
},
]}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: small ? 8 : 12,
flex: 1,
}}
>
<View
style={{
width: logoSize,
height: logoSize,
borderRadius: 999,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: "#fff",
overflow: "hidden",
}}
>
<Image
source={{ uri: to.logo }}
accessibilityLabel={to.symbol}
style={{ width: "100%", height: "100%" }}
resizeMode="cover"
/>
</View>
<View style={{ flex: 1 }}>
<Text
numberOfLines={1}
style={{
fontFamily,
fontSize: small ? 16 : 18,
lineHeight: small ? 24 : 28,
fontWeight: "500",
color: colors.text,
}}
>
{to.name}
</Text>
<Text
numberOfLines={1}
style={{
fontFamily,
fontSize: small ? 12 : 14,
color: colors.muted,
}}
>
Receive {to.symbol}
</Text>
</View>
</View>
<NativeNumber
value={output}
size={small ? 18 : 24}
color={colors.text}
surface={colors.surface}
fontFamily={fontFamily}
spinDuration={600}
maximumFractionDigits={2}
spinEase={OUTPUT_EASE}
/>
</View>
<View style={{ paddingTop: small ? 8 : 16 }}>
<Pressable
accessibilityRole="button"
onPress={clear}
onPressIn={() => clearScale.set(withSpring(0.98, SPRING))}
onPressOut={() => clearScale.set(withSpring(1, SPRING))}
onHoverIn={() => setClearHovered(true)}
onHoverOut={() => setClearHovered(false)}
>
<Animated.View
style={[
{
paddingVertical: 12,
borderRadius: 999,
borderWidth: 1.2,
borderColor: colors.clearBorder,
backgroundColor: clearHovered
? colors.clearHover
: colors.clear,
alignItems: "center",
transitionProperty: ["backgroundColor", "borderColor"],
transitionDuration: 150,
transitionTimingFunction: CSS_EASE,
},
clearMotion,
]}
>
<Text
style={{
fontFamily,
fontSize: 16,
lineHeight: 24,
color: colors.clearText,
}}
>
Clear
</Text>
</Animated.View>
</Pressable>
</View>
</View>
)
}
type RawChar = {
key: string
char: string
x: number
width: number
exit: boolean
}
function RawAmount({
value,
width,
size,
color,
fontFamily,
}: {
value: string
width: number
size: number
color: string
fontFamily?: string
}) {
const [metrics, setMetrics] = useState<Record<string, number>>({}),
[layers, setLayers] = useState<RawChar[]>([])
useEffect(() => {
const chars = value.split(""),
total = chars.reduce(
(sum, char) =>
sum + (metrics[char] ?? size * (char === "." ? 0.28 : 0.56)),
0
)
let x = (width - total) / 2
const current = chars.map((char, i) => {
const w = metrics[char] ?? size * (char === "." ? 0.28 : 0.56),
layer = {
key: value === "0" ? "placeholder" : `${i}-${char}`,
char,
x,
width: w,
exit: false,
}
x += w
return layer
})
setLayers((previous) => [
...previous
.filter((layer) => !current.some((next) => next.key === layer.key))
.map((layer) => ({ ...layer, exit: true })),
...current,
])
}, [value, metrics, width, size])
const remove = useCallback(
(key: string) =>
setLayers((current) =>
current.filter((layer) => layer.key !== key || !layer.exit)
),
[]
)
return (
<View
pointerEvents="none"
style={{ position: "absolute", left: 0, top: 0, width, height: 80 }}
>
<View
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={{ position: "absolute", opacity: 0, flexDirection: "row" }}
>
{"0123456789.".split("").map((char) => (
<Text
key={`${size}-${char}`}
onLayout={(e) => {
const w = e.nativeEvent.layout.width
setMetrics((previous) =>
previous[char] === w ? previous : { ...previous, [char]: w }
)
}}
style={{
fontFamily,
fontSize: size,
fontWeight: "500",
fontVariant: ["tabular-nums"],
}}
>
{char}
</Text>
))}
</View>
{layers.map((layer) => (
<RawGlyph
key={layer.key}
layer={layer}
size={size}
color={color}
fontFamily={fontFamily}
remove={remove}
/>
))}
</View>
)
}
function RawGlyph({
layer,
size,
color,
fontFamily,
remove,
}: {
layer: RawChar
size: number
color: string
fontFamily?: string
remove: (key: string) => void
}) {
const opacity = useSharedValue(0),
y = useSharedValue(40)
useEffect(() => {
opacity.set(
withSpring(layer.exit ? 0 : 1, INPUT_SPRING, (finished) => {
if (finished && layer.exit) scheduleOnRN(remove, layer.key)
})
)
y.set(withSpring(layer.exit ? 40 : 0, INPUT_SPRING))
}, [layer.exit])
const motion = useAnimatedStyle(() => ({
opacity: Math.max(0, Math.min(1, opacity.get())),
transform: [{ translateY: y.get() }],
}))
return (
<Animated.View
style={[
{
position: "absolute",
left: layer.x,
width: layer.width,
top: 0,
height: 80,
justifyContent: "center",
},
motion,
]}
>
<Text
style={{
fontFamily,
fontSize: size,
fontWeight: "500",
fontVariant: ["tabular-nums"],
color,
includeFontPadding: false,
}}
>
{layer.char}
</Text>
</Animated.View>
)
}
function useAccumulated(
target: number,
duration: number,
easing: (p: number) => number,
initial = target
) {
const value = useSharedValue(initial),
base = useSharedValue(initial),
timelines = useSharedValue<{ start: number; delta: number }[]>([]),
previous = useRef(initial)
function finish() {
if (timelines.get().length === 0) frame.setActive(false)
}
const frame = useFrameCallback((info) => {
const now = info.timestamp,
pending = timelines
.get()
.map((item) => (item.start < 0 ? { ...item, start: now } : item))
let offset = 0
for (const item of pending)
offset +=
item.delta * (1 - easing(Math.min(1, (now - item.start) / duration)))
value.set(base.get() + offset)
const remaining = pending.filter((item) => now - item.start < duration)
timelines.set(remaining)
if (!remaining.length) {
value.set(base.get())
scheduleOnRN(finish)
}
}, false)
useEffect(() => {
if (previous.current === target) return
const delta = previous.current - target
previous.current = target
base.set(target)
if (!duration) {
timelines.set([])
value.set(target)
return
}
timelines.set([...timelines.get(), { start: -1, delta }])
frame.setActive(true)
}, [target, duration])
useEffect(() => () => frame.setActive(false), [])
return value
}
type FlowChar = {
key: string
char: string
x: number
width: number
exit: boolean
initial: boolean
}
function NativeNumber({
value,
size,
color,
surface,
fontFamily,
currency = false,
maximumFractionDigits = 3,
spinDuration = 900,
transformDuration = 900,
spinEase = defaultEase,
transformEase = defaultEase,
}: {
value: number
size: number
color: string
surface: string
fontFamily?: string
currency?: boolean
maximumFractionDigits?: number
spinDuration?: number
transformDuration?: number
spinEase?: (p: number) => number
transformEase?: (p: number) => number
}) {
const formatter = useMemo(
() =>
new Intl.NumberFormat(
"en-US",
currency
? { style: "currency", currency: "USD" }
: { maximumFractionDigits }
),
[currency, maximumFractionDigits]
)
const formatted = formatter.format(value)
const [metrics, setMetrics] = useState<Record<string, number>>({}),
[layers, setLayers] = useState<FlowChar[]>([]),
previousValue = useRef(value)
const [trend, setTrend] = useState(0)
const total = formatted
.split("")
.reduce(
(sum, char) =>
sum +
(metrics[/\d/.test(char) ? "0" : char] ??
size * (/\d/.test(char) ? 0.56 : 0.3)),
0
)
const width = useAccumulated(total, transformDuration, transformEase)
const frameStyle = useAnimatedStyle(() => ({
width: Math.max(0, width.get()),
}))
useEffect(() => {
setTrend(Math.sign(value - previousValue.current))
previousValue.current = value
let integerCount = formatter
.formatToParts(value)
.filter((part) => part.type === "integer")
.map((part) => part.value)
.join("").length
let fractionIndex = 0,
symbolIndex = 0,
fraction = false,
x = 0
const current = formatted.split("").map((char) => {
if (char === ".") fraction = true
const key = /\d/.test(char)
? fraction
? `fraction:${fractionIndex++}`
: `integer:${--integerCount}`
: `${char}:${symbolIndex++}`
const w =
metrics[/\d/.test(char) ? "0" : char] ??
size * (/\d/.test(char) ? 0.56 : 0.3)
const layer = {
key,
char,
x,
width: w,
exit: false,
initial: !layers.length,
}
x += w
return layer
})
setLayers((previous) => [
...previous
.filter((layer) => !current.some((next) => next.key === layer.key))
.map((layer) => ({
...layer,
exit: true,
char: /\d/.test(layer.char) ? "0" : layer.char,
})),
...current.map((layer) => ({
...layer,
initial:
previous.find((old) => old.key === layer.key)?.initial ??
layer.initial,
})),
])
}, [formatted, metrics, size])
const remove = useCallback(
(key: string) =>
setLayers((current) =>
current.filter((layer) => layer.key !== key || !layer.exit)
),
[]
)
return (
<Animated.View
accessible
accessibilityLabel={formatted}
style={[{ height: size * 1.25, overflow: "hidden" }, frameStyle]}
>
<View
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={{ position: "absolute", opacity: 0, flexDirection: "row" }}
>
{Array.from(new Set(("0" + formatted).split(""))).map((char) => (
<Text
key={`${size}-${char}`}
onLayout={(e) => {
const w = e.nativeEvent.layout.width
setMetrics((previous) =>
previous[char] === w ? previous : { ...previous, [char]: w }
)
}}
style={{
fontFamily,
fontSize: size,
fontWeight: "500",
fontVariant: ["tabular-nums"],
}}
>
{char}
</Text>
))}
</View>
{layers.map((layer) => (
<FlowGlyph
key={layer.key}
layer={layer}
size={size}
color={color}
fontFamily={fontFamily}
trend={trend}
spinDuration={spinDuration}
transformDuration={transformDuration}
spinEase={spinEase}
transformEase={transformEase}
remove={remove}
/>
))}
<LinearGradient
pointerEvents="none"
colors={[surface, "transparent"]}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: size * 0.08,
}}
/>
<LinearGradient
pointerEvents="none"
colors={["transparent", surface]}
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: size * 0.08,
}}
/>
</Animated.View>
)
}
function FlowGlyph({
layer,
size,
color,
fontFamily,
trend,
spinDuration,
transformDuration,
spinEase,
transformEase,
remove,
}: {
layer: FlowChar
size: number
color: string
fontFamily?: string
trend: number
spinDuration: number
transformDuration: number
spinEase: (p: number) => number
transformEase: (p: number) => number
remove: (key: string) => void
}) {
const numeric = /\d/.test(layer.char)
const initialDigit = numeric && layer.initial ? Number(layer.char) : 0
const [motion, setMotion] = useState({
digit: initialDigit,
target: initialDigit,
})
if (numeric && motion.digit !== Number(layer.char)) {
const digit = Number(layer.char),
delta = digit - motion.digit,
direction = trend || Math.sign(delta)
setMotion({
digit,
target:
motion.target +
(direction < 0 && digit > motion.digit
? delta - 10
: direction > 0 && digit < motion.digit
? delta + 10
: delta),
})
}
const rotation = useAccumulated(
motion.target,
spinDuration,
spinEase,
initialDigit
)
const x = useAccumulated(layer.x, transformDuration, transformEase)
const opacity = useAccumulated(
layer.exit ? 0 : 1,
450,
OUTPUT_EASE,
layer.initial ? 1 : 0
)
useEffect(() => {
if (layer.exit) {
const timer = setTimeout(
() => remove(layer.key),
Math.max(450, transformDuration, spinDuration)
)
return () => clearTimeout(timer)
}
}, [layer.exit])
const placement = useAnimatedStyle(() => ({
opacity: Math.max(0, Math.min(1, opacity.get())),
transform: [{ translateX: x.get() }],
}))
return (
<Animated.View
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={[
{
position: "absolute",
left: 0,
top: 0,
width: layer.width,
height: size * 1.25,
},
placement,
]}
>
{numeric ? (
Array.from({ length: 10 }, (_, digit) => (
<WheelDigit
key={digit}
digit={digit}
rotation={rotation}
width={layer.width}
size={size}
color={color}
fontFamily={fontFamily}
/>
))
) : (
<Text
style={{
fontFamily,
fontSize: size,
lineHeight: size * 1.25,
fontWeight: "500",
color,
includeFontPadding: false,
}}
>
{layer.char}
</Text>
)}
</Animated.View>
)
}
function WheelDigit({
digit,
rotation,
width,
size,
color,
fontFamily,
}: {
digit: number
rotation: ReturnType<typeof useSharedValue<number>>
width: number
size: number
color: string
fontFamily?: string
}) {
const style = useAnimatedStyle(() => {
const offset = ((((digit - rotation.get() + 5) % 10) + 10) % 10) - 5
return {
opacity: Math.abs(offset) > 1.1 ? 0 : 1,
transform: [{ translateY: offset * size * 1.25 }],
}
})
return (
<Animated.Text
style={[
{
position: "absolute",
left: 0,
top: 0,
width,
height: size * 1.25,
fontFamily,
fontSize: size,
lineHeight: size * 1.25,
fontWeight: "500",
fontVariant: ["tabular-nums"],
color,
textAlign: "center",
includeFontPadding: false,
},
style,
]}
>
{digit}
</Animated.Text>
)
}
export default AaveSwapComponent
Give the gestures and motion a run on a real Android phone.