Skip to content
pixelfor Expo
Ctrl K

Interaction

Aave Swap Component

An expressive interaction rebuilt for touch

Try it on your phone

Aave Swap Component

Scan to open Aave Swap Component in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Watermelon UI

Our Expo component

React Native Web

Component 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.

Back to Watermelon UI