Interaction
Swap Currency Card
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.
swap-currency-card.in-progress.tsx
import React, {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react"
import {
Platform,
Modal,
Keyboard,
Pressable,
Text,
TextInput,
View,
useColorScheme,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
type AnimationObject,
Easing,
defineAnimation,
interpolateColor,
useAnimatedProps,
useAnimatedStyle,
useSharedValue,
withTiming,
type SharedValue,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, {
Defs,
Filter,
FeGaussianBlur,
G,
Path,
Rect,
SvgXml,
Text as SvgText,
} from "react-native-svg"
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
}
const WEB = Platform.OS === "web",
FONT = WEB ? "Arial" : Platform.OS === "ios" ? "Helvetica" : "sans-serif"
const AG = Animated.createAnimatedComponent(G),
AR = Animated.createAnimatedComponent(Rect)
const CSS = Easing.bezier(0.4, 0, 0.2, 1),
MENU = Easing.bezier(0, 0, 0.58, 1)
const clamp = (n: number) => {
"worklet"
return Math.max(0, Math.min(1, n))
}
const DIGIT_FREQUENCY = Math.log(0.8 / 0.6 / 0.001) / (0.8 * 0.4)
function digitEase(progress: number) {
"worklet"
const t = progress * 0.4,
a = DIGIT_FREQUENCY * 0.6,
d = DIGIT_FREQUENCY * 0.8
return progress >= 1
? 1
: 1 - Math.exp(-d * t) * (Math.cos(a * t) + (d / a) * Math.sin(a * t))
}
type EntranceSpring = {
current: number
origin: number
started: number
factor: number
onStart: (a: EntranceSpring, value: number, time: number) => void
onFrame: (a: EntranceSpring, time: number) => boolean
}
function entranceSpring(target: number, normalize = false): number {
"worklet"
return defineAnimation<EntranceSpring>(target, () => ({
current: target,
origin: target,
started: 0,
factor: 1,
onStart(a, value, time) {
a.current = a.origin = value
a.started = time
a.factor =
normalize && value !== target ? 100 / Math.abs(value - target) : 1
},
onFrame(a, time) {
const t = (time - a.started) / 1000,
x = a.origin - target,
env = Math.exp(-10 * t),
c = Math.cos(10 * t),
s = Math.sin(10 * t)
a.current = target + x * env * (c + s)
const velocity = -20 * x * env * s,
small = Math.abs(x) * a.factor < 5
if (
Math.abs(velocity) * a.factor <= (small ? 0.01 : 2) &&
Math.abs(a.current - target) * a.factor <= (small ? 0.005 : 0.5)
) {
a.current = target
return true
}
return false
},
})) as unknown as number
}
class BlurTarget extends FeGaussianBlur {
setNativeProps = (
props: React.ComponentProps<typeof FeGaussianBlur> & {
style?: React.ComponentProps<typeof FeGaussianBlur>
}
) => {
if (WEB) {
const amount = props.style?.stdDeviation ?? props.stdDeviation
const node = (
this as unknown as {
elementRef?: {
current: { setAttribute: (k: string, v: string) => void } | null
}
}
).elementRef?.current
if (amount !== undefined)
node?.setAttribute("stdDeviation", String(amount))
} else this.root?.setNativeProps(props)
}
}
const AB = Animated.createAnimatedComponent(BlurTarget)
function Blur({ id, value }: { id: string; value: SharedValue<number> }) {
const props = useAnimatedProps(() => ({
stdDeviation: Math.max(0, value.value),
}))
return (
<Filter id={id} x="-50%" y="-50%" width="200%" height="200%">
<AB animatedProps={props} />
</Filter>
)
}
export interface Currency {
code: string
countryCode: string
flag: string
rate: number
name: string
}
export type SwapCurrencyColors = {
surface: string
border: string
title: string
field: string
number: string
selector: string
selectorBorder: string
selectorText: string
muted: string
menuHover: string
proceed: string
proceedHover: string
proceedText: string
shadow: string
}
export type SwapCurrencyCardProps = {
currencies: Currency[]
defaultFromCode?: string
defaultToCode?: string
defaultAmount?: string
theme?: "light" | "dark" | "system"
colors?: Partial<SwapCurrencyColors>
size?: "auto" | "compact" | "large"
width?: number
radius?: number
fontFamily?: string
style?: StyleProp<ViewStyle>
testID?: string
onProceed?: (result: {
from: Currency
to: Currency
fromAmount: string
toAmount: string
}) => void
}
const LIGHT: SwapCurrencyColors = {
surface: "#FEFEFE",
border: "#E5E5E9",
title: "#898990",
field: "#F6F5FA",
number: "#010103",
selector: "#fefefe",
selectorBorder: "#E5E5E9",
selectorText: "#374151",
muted: "#9ca3af",
menuHover: "#f9fafb",
proceed: "#262629",
proceedHover: "#000",
proceedText: "#fff",
shadow: "rgba(0,0,0,.1)",
}
const DARK: SwapCurrencyColors = {
surface: "#18181b",
border: "#27272a",
title: "#71717a",
field: "rgba(39,39,42,.5)",
number: "#fff",
selector: "#27272a",
selectorBorder: "#3f3f46",
selectorText: "#e4e4e7",
muted: "#71717a",
menuHover: "rgba(63,63,70,.5)",
proceed: "#f4f4f5",
proceedHover: "#fff",
proceedText: "#09090b",
shadow: "rgba(0,0,0,.5)",
}
const flagRequests = new Map<string, Promise<string>>()
function flagXml(source: string, prefix: string) {
let xml = source
const root = xml.match(/<svg\b[^>]*>/)?.[0] ?? ""
if (!/\bviewBox=/.test(root)) {
const width = parseFloat(root.match(/\bwidth=["']([^"']+)/)?.[1] ?? ""),
height = parseFloat(root.match(/\bheight=["']([^"']+)/)?.[1] ?? "")
if (width > 0 && height > 0)
xml = xml.replace(/<svg\b/, `<svg viewBox="0 0 ${width} ${height}"`)
}
const ids = new Set(
[...xml.matchAll(/\bid=["']([^"']+)["']/g)].map((m) => m[1])
)
xml = xml.replace(
/\bid=(["'])([^"']+)\1/g,
(_, quote, id) => `id=${quote}${prefix}-${id}${quote}`
)
xml = xml.replace(/#([^"'\s)<>]+)/g, (match, id) =>
ids.has(id) ? `#${prefix}-${id}` : match
)
return xml
}
function Flag({
currency,
x,
y,
large,
}: {
currency: Currency
x: number
y: number
large: boolean
}) {
const [failed, setFailed] = useState(false),
[xml, setXml] = useState<string | null>(null),
id = useId().replace(/[^\w-]/g, "")
const w = large ? 24 : 20,
h = large ? 20 : 16,
uri =
currency.countryCode === "eu"
? "https://upload.wikimedia.org/wikipedia/commons/b/b7/Flag_of_Europe.svg"
: `https://flagcdn.com/${currency.countryCode.toLowerCase()}.svg`
useEffect(() => {
let alive = true
setFailed(false)
setXml(null)
if (!currency.countryCode) return
let request = flagRequests.get(uri)
if (!request) {
request = fetch(uri).then((response) => {
if (!response.ok) throw new Error("Flag download failed")
return response.text()
})
flagRequests.set(uri, request)
}
request
.then((source) => {
if (alive) setXml(flagXml(source, `flag-${id}`))
})
.catch(() => {
flagRequests.delete(uri)
if (alive) setFailed(true)
})
return () => {
alive = false
}
}, [currency.countryCode, uri, id])
return (
<G transform={`translate(${x} ${y})`}>
{!currency.countryCode || failed ? (
<SvgText
x={w / 2}
y={h * 0.85}
fontSize={large ? 14 : 12}
textAnchor="middle"
>
{currency.flag}
</SvgText>
) : (
<Svg
x={0}
y={0}
width={w}
height={h}
viewBox={`0 0 ${w} ${h}`}
style={{ overflow: "hidden" }}
>
{xml && (
<SvgXml
xml={xml}
width={w}
height={h}
preserveAspectRatio="xMidYMid slice"
onError={() => setFailed(true)}
/>
)}
</Svg>
)}
{currency.countryCode && (
<Rect
width={w}
height={h}
rx={2}
fill="none"
stroke="#3f3f46"
strokeWidth={0.8}
/>
)}
</G>
)
}
type DigitLayer = {
id: number
char: string
initial: boolean
exiting: boolean
delay: number
}
function Digit({
layer,
delay,
width,
font,
color,
family,
remove,
}: {
layer: DigitLayer
delay: number
width: number
font: number
color: string
family: string
remove: (id: number) => void
}) {
const id = useId().replace(/:/g, ""),
opacity = useSharedValue(layer.initial ? 1 : 0),
y = useSharedValue(layer.initial ? 0 : -10),
scale = useSharedValue(layer.initial ? 1 : 0.65),
blur = useSharedValue(layer.initial ? 0 : 2)
useLayoutEffect(() => {
const timing = { duration: 400, easing: digitEase }
y.value = motionDelay(delay, withTiming(0, timing))
scale.value = motionDelay(delay, withTiming(1, timing))
}, [])
useLayoutEffect(() => {
const timing = { duration: 400, easing: digitEase }
blur.value = motionDelay(delay, withTiming(layer.exiting ? 2 : 0, timing))
opacity.value = motionDelay(
delay,
withTiming(layer.exiting ? 0 : 1, timing, (finished) => {
"worklet"
if (finished && layer.exiting) scheduleOnRN(remove, layer.id)
})
)
}, [layer.exiting])
const props = useAnimatedProps(() => ({
opacity: clamp(opacity.value),
transform: `translate(${width / 2} ${y.value}) scale(${scale.value})`,
}))
return (
<>
<Defs>
<Blur id={id} value={blur} />
</Defs>
<AG animatedProps={props} filter={`url(#${id})`}>
<SvgText
y={font * 0.35}
fontFamily={family}
fontSize={font}
fontWeight="700"
fill={color}
textAnchor="middle"
>
{layer.char}
</SvgText>
</AG>
</>
)
}
function Column({
char,
delay,
font,
color,
family,
}: {
char: string
delay: number
font: number
color: string
family: string
}) {
const serial = useRef(0),
last = useRef(char),
[layers, setLayers] = useState<DigitLayer[]>([
{ id: 0, char, initial: true, exiting: false, delay },
])
useLayoutEffect(() => {
if (char === last.current) {
setLayers((old) =>
old.every((layer) => layer.exiting || layer.delay === delay)
? old
: old.map((layer) => (layer.exiting ? layer : { ...layer, delay }))
)
return
}
last.current = char
setLayers((old) => {
const returning = old.some((layer) => layer.char === char)
const next = old.map((layer) => ({
...layer,
exiting: layer.char !== char,
delay: layer.char === char ? delay : layer.delay,
}))
return returning
? next
: [
...next,
{
id: ++serial.current,
char,
initial: false,
exiting: false,
delay,
},
]
})
}, [char, delay])
const remove = (id: number) =>
setLayers((old) => old.filter((l) => l.id !== id || !l.exiting))
return (
<>
{layers.map((layer) => (
<Digit
key={layer.id}
layer={layer}
delay={layer.delay}
width={font * 0.6}
font={font}
color={color}
family={family}
remove={remove}
/>
))}
</>
)
}
function Amount({
value,
x,
y,
font,
color,
family,
}: {
value: string
x: number
y: number
font: number
color: string
family: string
}) {
const chars = [...(value || "0")]
let offset = 0
return (
<G transform={`translate(${x} ${y})`}>
{chars.map((char, index) => {
const numeric = !Number.isNaN(Number(char)),
at = offset
offset += font * (numeric ? 0.6 : 0.54)
return (
<G key={index} transform={`translate(${at} 0)`}>
{numeric ? (
<Column
char={char}
delay={(chars.length - 1 - index) * 30}
font={font}
color={color}
family={family}
/>
) : (
<SvgText
x={font * 0.27}
y={font * 0.35}
fontFamily={family}
fontSize={font}
fontWeight="700"
textAnchor="middle"
fill={color}
>
{char}
</SvgText>
)}
</G>
)
})}
</G>
)
}
function SelectorVisual({
currency,
x,
y,
w,
h,
large,
open,
pressed,
colors,
family,
}: {
currency: Currency
x: number
y: number
w: number
h: number
large: boolean
open: boolean
pressed: SharedValue<number>
colors: SwapCurrencyColors
family: string
}) {
const angle = useSharedValue(0),
font = large ? 14 : 12,
flagW = large ? 24 : 20,
gap = large ? 8 : 6,
pad = large ? 12 : 10,
icon = large ? 20 : 16
useEffect(() => {
angle.value = withTiming(open ? 180 : 0, { duration: 150, easing: CSS })
}, [open])
const props = useAnimatedProps(() => ({
transform: `translate(${x + w / 2} ${y + h / 2}) scale(${pressed.value}) translate(${-w / 2} ${-h / 2})`,
}))
const chevron = useAnimatedProps(() => ({
transform: `translate(${w - pad - icon / 2} ${h / 2}) rotate(${angle.value}) scale(${icon / 24})`,
}))
return (
<AG animatedProps={props}>
<Rect
width={w}
height={h}
rx={h / 2}
fill={colors.selector}
stroke={colors.selectorBorder}
strokeWidth={0.8}
/>
<Flag
currency={currency}
x={pad}
y={(h - (large ? 20 : 16)) / 2}
large={large}
/>
<SvgText
x={pad + flagW + gap}
y={h / 2 + font * 0.35}
fontFamily={family}
fontSize={font}
fontWeight="600"
fill={colors.selectorText}
>
{currency.code}
</SvgText>
<AG animatedProps={chevron}>
<Path
d="m-6 -3 6 6 6-6"
stroke="#9ca3af"
fill="none"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</AG>
</AG>
)
}
function MenuRow({
currency,
selected,
x,
y,
width,
height,
large,
colors,
family,
hover,
}: {
currency: Currency
selected: boolean
x: number
y: number
width: number
height: number
large: boolean
colors: SwapCurrencyColors
family: string
hover: boolean
}) {
const progress = useSharedValue(0),
pad = large ? 16 : 12,
flagW = large ? 24 : 20,
gap = large ? 12 : 8,
font = large ? 14 : 12
useEffect(() => {
progress.value = withTiming(hover ? 1 : 0, { duration: 150, easing: CSS })
}, [hover])
const bg = useAnimatedProps(() => ({
fill: interpolateColor(
progress.value,
[0, 1],
["transparent", colors.menuHover]
),
}))
return (
<G transform={`translate(${x} ${y})`}>
<AR width={width} height={height} animatedProps={bg} />
<Flag
currency={currency}
x={pad}
y={(height - (large ? 20 : 16)) / 2}
large={large}
/>
<SvgText
x={pad + flagW + gap}
y={height / 2 + font * 0.35}
fontFamily={family}
fontSize={font}
fontWeight="500"
fill={colors.selectorText}
>
{currency.code}
</SvgText>
{selected && (
<Path
d="m-5 0 3 3 6-6"
transform={`translate(${width - pad - 8} ${height / 2}) scale(${large ? 1 : 0.875})`}
fill="none"
stroke={colors.muted}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</G>
)
}
function MenuVisual({
open,
close,
x,
y,
width,
height,
rows,
selected,
large,
colors,
family,
hover,
}: {
open: boolean
close: () => void
x: number
y: number
width: number
height: number
rows: Currency[]
selected: Currency
large: boolean
colors: SwapCurrencyColors
family: string
hover: number | null
}) {
const id = useId().replace(/:/g, ""),
progress = useSharedValue(0),
blur = useSharedValue(4),
rowHeight = large ? 40 : 32
useLayoutEffect(() => {
blur.value = withTiming(open ? 0 : 4, { duration: 200, easing: MENU })
progress.value = withTiming(
open ? 1 : 0,
{ duration: 200, easing: MENU },
(finished) => {
"worklet"
if (finished && !open) scheduleOnRN(close)
}
)
}, [open])
const props = useAnimatedProps(() => ({
opacity: clamp(progress.value),
transform: `translate(${x + width / 2} ${y + height / 2 - 8 * (1 - progress.value)}) scale(${0.96 + 0.04 * progress.value}) translate(${-width / 2} ${-height / 2})`,
}))
return (
<>
<Defs>
<Blur id={id} value={blur} />
<Filter
id={`${id}shadowA`}
x="-50%"
y="-50%"
width="200%"
height="200%"
>
<FeGaussianBlur stdDeviation={7.5} />
</Filter>
<Filter
id={`${id}shadowB`}
x="-50%"
y="-50%"
width="200%"
height="200%"
>
<FeGaussianBlur stdDeviation={3} />
</Filter>
</Defs>
<AG animatedProps={props} filter={`url(#${id})`}>
<Rect
x={3}
y={13}
width={width - 6}
height={height - 6}
rx={Math.max(0, (large ? 16 : 12) - 3)}
fill="rgba(0,0,0,.1)"
filter={`url(#${id}shadowA)`}
/>
<Rect
x={4}
y={8}
width={width - 8}
height={height - 8}
rx={Math.max(0, (large ? 16 : 12) - 4)}
fill="rgba(0,0,0,.1)"
filter={`url(#${id}shadowB)`}
/>
<Rect
width={width}
height={height}
rx={large ? 16 : 12}
fill={colors.selector}
stroke={colors.selectorBorder}
strokeWidth={1.6}
/>
{rows.map((currency, i) => (
<MenuRow
key={currency.code}
currency={currency}
selected={currency.code === selected.code}
x={1.6}
y={5.6 + i * rowHeight}
width={width - 3.2}
height={rowHeight}
large={large}
colors={colors}
family={family}
hover={hover === i}
/>
))}
</AG>
</>
)
}
function convert(value: string, from: Currency, to: Currency) {
const n = parseFloat(value)
return Number.isNaN(n) ? "" : ((n / from.rate) * to.rate).toFixed(2)
}
function fieldPath(
x: number,
y: number,
w: number,
h: number,
top: number,
bottom: number
) {
return `M${x + top} ${y}H${x + w - top}A${top} ${top} 0 0 1 ${x + w} ${y + top}V${y + h - bottom}A${bottom} ${bottom} 0 0 1 ${x + w - bottom} ${y + h}H${x + bottom}A${bottom} ${bottom} 0 0 1 ${x} ${y + h - bottom}V${y + top}A${top} ${top} 0 0 1 ${x + top} ${y}Z`
}
export function SwapCurrencyCard({
currencies,
defaultFromCode,
defaultToCode,
defaultAmount = "10",
theme = "system",
colors: overrides,
size = "auto",
width: customWidth,
radius: customRadius,
fontFamily = FONT,
style,
testID,
onProceed,
}: SwapCurrencyCardProps) {
const window = useWindowDimensions(),
system = useColorScheme(),
large = size === "large" || (size === "auto" && window.width >= 640),
colors = {
...(theme === "dark" || (theme === "system" && system === "dark")
? DARK
: LIGHT),
...overrides,
}
const [from, setFrom] = useState(
() => currencies.find((c) => c.code === defaultFromCode) || currencies[0]
),
[to, setTo] = useState(
() => currencies.find((c) => c.code === defaultToCode) || currencies[1]
),
[fromAmount, setFromAmount] = useState(defaultAmount),
[toAmount, setToAmount] = useState("")
const [menu, setMenu] = useState<0 | 1 | null>(null),
[mounted, setMounted] = useState<[boolean, boolean]>([false, false]),
[hover, setHover] = useState<number | null>(null)
const [labelWidths, setLabelWidths] = useState<Record<string, number>>({})
const labelKey = (code: string) => `${fontFamily}|${large}|${code}`
const measureLabel = (code: string, value: number) => {
if (value <= 0) return
const key = labelKey(code)
setLabelWidths((old) =>
old[key] === value ? old : { ...old, [key]: value }
)
}
const cardRef = useRef<View>(null)
const amountRefs = useRef<Array<TextInput | null>>([null, null])
const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null)
const menuRef = useRef(menu)
menuRef.current = menu
useEffect(() => {
if (!WEB) return
const close = (event: MouseEvent) => {
const card = cardRef.current as unknown as HTMLElement | null
if (card && !card.contains(event.target as Node)) setMenu(null)
}
document.addEventListener("mousedown", close)
return () => document.removeEventListener("mousedown", close)
}, [])
const entrance = useSharedValue(0),
entranceScale = useSharedValue(0.95),
blur = useSharedValue(8),
fromPress = useSharedValue(1),
toPress = useSharedValue(1),
proceedPress = useSharedValue(1),
proceedHover = useSharedValue(0),
id = useId().replace(/:/g, "")
useEffect(() => {
entrance.value = entranceSpring(1, true)
entranceScale.value = entranceSpring(1)
blur.value = entranceSpring(0, true)
}, [])
useEffect(() => {
const frame = requestAnimationFrame(() =>
setToAmount(convert(fromAmount, from, to))
)
return () => cancelAnimationFrame(frame)
}, [fromAmount, from, to])
const width = customWidth ?? (large ? 384 : 320),
border = 1.6,
pad = large ? 32 : 24,
gap = large ? 24 : 20,
inside = width - 2 * (pad + border),
titleH = large ? 31.1111 : 28,
selectorH = large ? 37.6 : 29.6,
rowPad = large ? 16 : 14,
rowH = selectorH + 2 * rowPad,
rowGap = large ? 8 : 6,
buttonH = large ? 59 : 52,
footerH = large ? 24 : 20
const contentX = pad + border,
titleY = contentX,
row1Y = titleY + titleH + gap,
row2Y = row1Y + rowH + rowGap,
buttonY = row2Y + rowH + gap,
footerY = buttonY + buttonH + gap,
height = footerY + footerH + pad + border,
radius = customRadius ?? (large ? 40 : 32),
numberFont = large ? 24 : 20
const selectorWidths = [from, to].map(
(currency) =>
(large ? 85.6 : 69.6) +
(labelWidths[labelKey(currency.code)] ??
currency.code.length * (large ? 9.85417 : 8.44643))
),
selectorXs = selectorWidths.map((w) => contentX + inside - rowPad - w),
selectorYs = [row1Y + rowPad, row2Y + rowPad],
menuWidth = large ? 192 : 160,
menuRowH = large ? 40 : 32,
menuHeight = currencies.length * menuRowH + 11.2,
menuX = contentX + inside - rowPad - menuWidth,
menuYs = selectorYs.map((y) => y + selectorH + 8)
const measureAnchor = () =>
cardRef.current?.measureInWindow((x, y) => {
const scale = entranceScale.value
setAnchor({
x: x - (width * (1 - scale)) / 2,
y: y - (height * (1 - scale)) / 2,
})
})
useEffect(() => {
if (WEB) return
const listener = Keyboard.addListener("keyboardDidHide", () => {
if (menuRef.current !== null) measureAnchor()
})
return () => listener.remove()
}, [width, height])
const rootStyle = useAnimatedStyle(() => ({
opacity: clamp(entrance.value),
transform: [{ scale: entranceScale.value }],
}))
const proceedGroup = useAnimatedProps(() => ({
transform: `translate(${contentX + inside / 2} ${buttonY + buttonH / 2}) scale(${proceedPress.value}) translate(${-inside / 2} ${-buttonH / 2})`,
}))
const proceedFill = useAnimatedProps(() => ({
fill: interpolateColor(
proceedHover.value,
[0, 1],
[colors.proceed, colors.proceedHover]
),
}))
const openMenu = (index: 0 | 1) => {
const next = menu === index ? null : index
setMenu(next)
setHover(null)
if (next !== null)
setMounted((old) => {
const n = [...old] as [boolean, boolean]
n[next] = true
return n
})
if (!WEB && next !== null) {
Keyboard.dismiss()
measureAnchor()
}
}
const unmount = (index: 0 | 1) => {
if (menuRef.current === index) return
setMounted((old) => {
const n = [...old] as [boolean, boolean]
n[index] = false
return n
})
}
const change = (value: string, index: 0 | 1) => {
if (value !== "" && !/^\d*\.?\d*$/.test(value)) return
if (index === 0) {
setFromAmount(value)
setToAmount(convert(value, from, to))
} else {
setToAmount(value)
setFromAmount(convert(value, to, from))
}
}
const choose = (currency: Currency, index: 0 | 1) => {
if (index === 0) {
setFrom(currency)
setToAmount(convert(fromAmount, currency, to))
} else {
setTo(currency)
setToAmount(convert(fromAmount, from, currency))
}
setMenu(null)
setHover(null)
}
const press = (shared: SharedValue<number>, value: number) => {
shared.value = withTiming(value, { duration: 150, easing: CSS })
}
if (currencies.length < 2)
throw new Error("SwapCurrencyCard requires at least two currencies")
return (
<Animated.View
testID={testID}
ref={cardRef}
collapsable={false}
style={[
{ width, height, alignSelf: "center", overflow: "visible" },
style,
rootStyle,
]}
>
<Svg
width={width}
height={height}
style={{ position: "absolute", left: 0, top: 0, overflow: "visible" }}
pointerEvents="none"
>
<Defs>
<Blur id={id} value={blur} />
<Filter
id={`${id}shadow`}
x="-70%"
y="-70%"
width="240%"
height="240%"
>
<FeGaussianBlur stdDeviation={large ? 30 : 23.5} />
</Filter>
<Filter
id={`${id}actionShadowA`}
x="-50%"
y="-100%"
width="200%"
height="300%"
>
<FeGaussianBlur stdDeviation={7.5} />
</Filter>
<Filter
id={`${id}actionShadowB`}
x="-50%"
y="-100%"
width="200%"
height="300%"
>
<FeGaussianBlur stdDeviation={3} />
</Filter>
</Defs>
<G filter={`url(#${id})`}>
<Rect
x={16}
y={48}
width={width - 32}
height={height - 32}
rx={Math.max(0, radius - 16)}
fill={colors.shadow}
filter={`url(#${id}shadow)`}
/>
<Rect
x={border / 2}
y={border / 2}
width={width - border}
height={height - border}
rx={radius}
fill={colors.surface}
stroke={colors.border}
strokeWidth={border}
/>
<SvgText
x={contentX}
y={titleY + titleH / 2 + (large ? 20 : 18) * 0.35}
fontFamily={fontFamily}
fontSize={large ? 20 : 18}
fontWeight="600"
fill={colors.title}
>
Swap Currency
</SvgText>
{[row1Y, row2Y].map((y, index) => (
<G key={index}>
<Path
d={fieldPath(
contentX,
y,
inside,
rowH,
index === 0 ? (large ? 24 : 32) : large ? 24 : 16,
index === 0 ? (large ? 24 : 16) : large ? 24 : 32
)}
fill={colors.field}
/>
<Amount
value={index === 0 ? fromAmount : toAmount}
x={contentX + rowPad}
y={y + rowH / 2}
font={numberFont}
color={colors.number}
family={fontFamily}
/>
<SelectorVisual
currency={index === 0 ? from : to}
x={selectorXs[index]}
y={selectorYs[index]}
w={selectorWidths[index]}
h={selectorH}
large={large}
open={menu === index}
pressed={index === 0 ? fromPress : toPress}
colors={colors}
family={fontFamily}
/>
</G>
))}
<AG animatedProps={proceedGroup}>
<Rect
x={3}
y={13}
width={inside - 6}
height={buttonH - 6}
rx={(large ? 22 : 16) - 3}
fill="rgba(0,0,0,.1)"
filter={`url(#${id}actionShadowA)`}
/>
<Rect
x={4}
y={8}
width={inside - 8}
height={buttonH - 8}
rx={(large ? 22 : 16) - 4}
fill="rgba(0,0,0,.1)"
filter={`url(#${id}actionShadowB)`}
/>
<AR
width={inside}
height={buttonH}
rx={large ? 22 : 16}
animatedProps={proceedFill}
/>
<SvgText
x={inside / 2}
y={buttonH / 2 + (large ? 18 : 16) * 0.35}
textAnchor="middle"
fontFamily={fontFamily}
fontSize={large ? 18 : 16}
fontWeight="600"
fill={colors.proceedText}
>
Proceed
</SvgText>
</AG>
<SvgText
x={width / 2}
y={footerY + footerH / 2 + (large ? 16 : 14) * 0.35}
textAnchor="middle"
fontFamily={fontFamily}
fontSize={large ? 16 : 14}
fontWeight="500"
fill={theme === "light" ? "#9F9EA1" : colors.muted}
>
1 {from.code} ≈ {(to.rate / from.rate).toFixed(2)} {to.code}
</SvgText>
{mounted.map(
(visible, index) =>
visible && (
<MenuVisual
key={index}
open={menu === index}
close={() => unmount(index as 0 | 1)}
x={menuX}
y={menuYs[index]}
width={menuWidth}
height={menuHeight}
rows={currencies}
selected={index === 0 ? from : to}
large={large}
colors={colors}
family={fontFamily}
hover={menu === index ? hover : null}
/>
)
)}
</G>
</Svg>
{WEB && menu !== null && (
<Pressable
accessibilityRole="button"
accessibilityLabel="Dismiss currency menu"
onPress={() => setMenu(null)}
style={{ position: "absolute", left: 0, top: 0, width, height }}
/>
)}
{[row1Y, row2Y].map((y, index) => (
<TextInput
key={index}
ref={(ref) => {
amountRefs.current[index] = ref
}}
accessibilityLabel={index === 0 ? "From amount" : "To amount"}
value={index === 0 ? fromAmount : toAmount}
onChangeText={(value) => change(value, index as 0 | 1)}
onFocus={() => setMenu(null)}
onPressIn={() => setMenu(null)}
keyboardType="decimal-pad"
selectionColor={colors.number}
cursorColor={colors.number}
style={{
position: "absolute",
left: contentX + rowPad,
top: y + rowH / 2 - 16,
width: selectorXs[index] - (contentX + rowPad) - 8,
height: 32,
padding: 0,
fontFamily,
fontSize: numberFont,
fontWeight: "600",
...(WEB ? { caretColor: colors.number } : {}),
letterSpacing: numberFont * 0.08,
color: "transparent",
backgroundColor: "transparent",
borderWidth: 0,
}}
/>
))}
<Pressable
accessibilityRole="button"
accessibilityLabel="Proceed"
onPress={() => {
setMenu(null)
onProceed?.({ from, to, fromAmount, toAmount })
}}
onPressIn={() => press(proceedPress, 0.98)}
onPressOut={() => press(proceedPress, 1)}
onHoverIn={() => {
proceedHover.value = withTiming(1, { duration: 150, easing: CSS })
}}
onHoverOut={() => {
proceedHover.value = withTiming(0, { duration: 150, easing: CSS })
}}
style={{
position: "absolute",
left: contentX,
top: buttonY,
width: inside,
height: buttonH,
}}
/>
{([0, 1] as const).map((index) => (
<Pressable
key={index}
accessibilityRole="button"
accessibilityLabel={`${index === 0 ? "From" : "To"} currency ${index === 0 ? from.code : to.code}`}
accessibilityState={{ expanded: menu === index }}
onPress={() => openMenu(index)}
onPressIn={() => press(index === 0 ? fromPress : toPress, 0.95)}
onPressOut={() => press(index === 0 ? fromPress : toPress, 1)}
style={{
position: "absolute",
left: selectorXs[index],
top: selectorYs[index],
width: selectorWidths[index],
height: selectorH,
}}
/>
))}
{menu !== null && (
<View
style={{
position: "absolute",
left: menuX,
top: menuYs[menu],
width: menuWidth,
height: menuHeight,
padding: 5.6,
}}
>
{currencies.map((currency, index) => (
<Pressable
key={currency.code}
accessibilityRole="button"
accessibilityLabel={currency.name}
accessibilityState={{
selected: currency.code === (menu === 0 ? from : to).code,
}}
onPress={() => choose(currency, menu)}
onHoverIn={() => setHover(index)}
onHoverOut={() => setHover(null)}
style={{ height: menuRowH }}
/>
))}
</View>
)}
{!WEB && (
<Modal
transparent
animationType="none"
visible={menu !== null && anchor !== null}
onRequestClose={() => setMenu(null)}
statusBarTranslucent
hardwareAccelerated
>
<View style={{ flex: 1 }}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Dismiss currency menu"
onPress={() => setMenu(null)}
style={{
position: "absolute",
left: 0,
top: 0,
right: 0,
bottom: 0,
}}
/>
{anchor && (
<Animated.View
pointerEvents="box-none"
style={[
{
position: "absolute",
left: anchor.x,
top: anchor.y,
width,
height,
},
rootStyle,
]}
>
{[row1Y, row2Y].map((y, index) => (
<Pressable
key={`amount${index}`}
accessibilityRole="button"
accessibilityLabel={`Edit ${index === 0 ? "from" : "to"} amount`}
onPress={() => {
setMenu(null)
requestAnimationFrame(() =>
amountRefs.current[index]?.focus()
)
}}
style={{
position: "absolute",
left: contentX + rowPad,
top: y,
width: selectorXs[index] - contentX - rowPad - 8,
height: rowH,
}}
/>
))}
<Pressable
accessibilityRole="button"
accessibilityLabel="Proceed"
onPress={() => {
setMenu(null)
onProceed?.({ from, to, fromAmount, toAmount })
}}
onPressIn={() => press(proceedPress, 0.98)}
onPressOut={() => press(proceedPress, 1)}
style={{
position: "absolute",
left: contentX,
top: buttonY,
width: inside,
height: buttonH,
}}
/>
{([0, 1] as const).map((index) => (
<Pressable
key={`selector${index}`}
accessibilityRole="button"
accessibilityLabel={`${index === 0 ? "From" : "To"} currency ${index === 0 ? from.code : to.code}`}
accessibilityState={{ expanded: menu === index }}
onPress={() => openMenu(index)}
onPressIn={() =>
press(index === 0 ? fromPress : toPress, 0.95)
}
onPressOut={() =>
press(index === 0 ? fromPress : toPress, 1)
}
style={{
position: "absolute",
left: selectorXs[index],
top: selectorYs[index],
width: selectorWidths[index],
height: selectorH,
}}
/>
))}
{menu !== null && (
<View
style={{
position: "absolute",
left: menuX,
top: menuYs[menu],
width: menuWidth,
height: menuHeight,
padding: 5.6,
}}
>
{currencies.map((currency, index) => (
<Pressable
key={currency.code}
accessibilityRole="button"
accessibilityLabel={currency.name}
accessibilityState={{
selected:
currency.code === (menu === 0 ? from : to).code,
}}
onPress={() => choose(currency, menu)}
style={{ height: menuRowH }}
/>
))}
</View>
)}
</Animated.View>
)}
</View>
</Modal>
)}
<View
pointerEvents="none"
aria-hidden
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={{
position: "absolute",
width: 0,
height: 0,
overflow: "hidden",
}}
>
<View style={{ width: 10000, alignItems: "flex-start" }}>
{currencies.map((currency) => (
<Text
key={currency.code}
allowFontScaling={false}
style={{
fontFamily,
fontSize: large ? 14 : 12,
fontWeight: "600",
}}
ref={(ref) => {
if (WEB && ref)
measureLabel(
currency.code,
(ref as unknown as Element).getBoundingClientRect().width
)
}}
onLayout={(event) => {
if (!WEB)
measureLabel(currency.code, event.nativeEvent.layout.width)
}}
>
{currency.code}
</Text>
))}
</View>
</View>
</Animated.View>
)
}
Give the gestures and motion a run on a real Android phone.