Interaction
Event Reminders
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.
event-reminders.in-progress.tsx
import React, { useCallback, useEffect, useId, useRef, useState } from "react"
import {
Platform,
Pressable,
Text,
TextInput,
View,
useColorScheme,
useWindowDimensions,
type StyleProp,
type ViewStyle,
type PressableProps,
} from "react-native"
import Animated, {
defineAnimation,
type AnimationObject,
cubicBezier,
Easing,
LinearTransition,
useAnimatedProps,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, {
Defs,
Filter,
FeGaussianBlur,
G,
Path,
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
}
export type ReminderType = "Notification" | "Email"
export type TimeUnit = "minutes" | "hours" | "days"
export interface Reminder {
id: string
type: ReminderType
value: number
unit: TimeUnit
}
export interface EventRemindersProps {
title?: string
date?: string
initialReminders?: Reminder[]
onUpdate?: (reminders: Reminder[]) => void
onDateChange?: (date: string) => void
width?: number
radius?: number
fontFamily?: string
compact?: boolean
theme?: "light" | "dark" | "system"
colors?: Partial<{
surface: string
border: string
text: string
muted: string
secondary: string
soft: string
softText: string
softHoverText: string
addHover: string
editSurface: string
editBorder: string
editHover: string
removeBorder: string
removeHover: string
removeHoverText: string
inputText: string
inputBorder: string
}>
style?: StyleProp<ViewStyle>
testID?: string
}
type Palette = Required<NonNullable<EventRemindersProps["colors"]>>
function critical(p: number) {
"worklet"
const t = 9.233413476 * p
return p >= 1 ? 1 : 1 - Math.exp(-t) * (1 + t)
}
function rowSpring(p: number) {
"worklet"
const omega = Math.log(0.8 / 0.6 / 0.001) / (0.8 * 0.5),
t = p * 0.5
return p >= 1
? 1
: 1 -
Math.exp(-omega * 0.8 * t) *
(Math.cos(omega * 0.6 * t) + (0.8 / 0.6) * Math.sin(omega * 0.6 * t))
}
const LAYOUT = LinearTransition.duration(500).easing(rowSpring)
const FADE = { duration: 300, easing: Easing.bezier(0.25, 0.1, 0.35, 1) }
const COLOR = { duration: 150, easing: Easing.bezier(0.4, 0, 0.2, 1) }
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
const AnimatedSvg = Animated.createAnimatedComponent(Svg)
function HoverButton({
children,
style,
color,
hoverColor = color,
hoverBackground,
...props
}: Omit<PressableProps, "children" | "style"> & {
children: React.ReactNode | ((foreground: string) => React.ReactNode)
style?: StyleProp<ViewStyle>
color?: string
hoverColor?: string
hoverBackground?: string
}) {
const [hovered, setHovered] = useState(false)
return (
<AnimatedPressable
{...props}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
style={[
style,
hovered && hoverBackground ? { backgroundColor: hoverBackground } : {},
{
transitionProperty: ["backgroundColor", "borderColor"],
transitionDuration: 150,
transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
},
]}
>
{typeof children === "function"
? children(hovered ? hoverColor! : color!)
: children}
</AnimatedPressable>
)
}
const enterRow = () => {
"worklet"
return {
initialValues: { opacity: 0, transform: [{ translateY: 10 }] },
animations: {
opacity: withTiming(1, { duration: 500, easing: rowSpring }),
transform: [
{ translateY: withTiming(0, { duration: 500, easing: rowSpring }) },
],
},
}
}
const exitRow = () => {
"worklet"
return {
initialValues: { opacity: 1, transform: [{ translateY: 0 }] },
animations: {
opacity: withTiming(0, { duration: 500, easing: rowSpring }),
transform: [
{ translateY: withTiming(-10, { duration: 500, easing: rowSpring }) },
],
},
}
}
class BlurTarget extends FeGaussianBlur {
setNativeProps = (
props: React.ComponentProps<typeof FeGaussianBlur> & {
style?: React.ComponentProps<typeof FeGaussianBlur>
}
) => {
if (Platform.OS === "web") {
const node = (
this as unknown as {
elementRef?: {
current: {
setAttribute: (key: string, value: string) => void
} | null
}
}
).elementRef?.current
const amount = props.style?.stdDeviation ?? props.stdDeviation
if (amount !== undefined)
node?.setAttribute("stdDeviation", String(amount))
} else this.root?.setNativeProps(props)
}
}
const AB = Animated.createAnimatedComponent(BlurTarget),
AG = Animated.createAnimatedComponent(G)
export function EventReminders({
title = "Design review",
date: initialDate = "Friday, October 2 · 10:00 AM",
initialReminders = [],
onUpdate,
onDateChange,
width = 400,
radius = 32,
fontFamily = Platform.OS === "web" ? "Arial" : undefined,
compact,
theme = "system",
colors: overrides,
style,
testID,
}: EventRemindersProps) {
const [reminders, setReminders] = useState(initialReminders),
[date, setDate] = useState(initialDate)
const [editing, setEditing] = useState(false),
[shownEditing, setShownEditing] = useState(false)
const nextId = useRef(0),
prefix = useId()
const window = useWindowDimensions(),
scheme = useColorScheme(),
small = compact ?? window.width < 640
const dark = theme === "dark" || (theme === "system" && scheme === "dark")
const colors = {
surface: dark ? "#171717" : "#fff",
border: dark ? "#262626" : "#e5e5e5",
text: dark ? "#fff" : "#171717",
muted: dark ? "#a3a3a3" : "#525252",
secondary: dark ? "#e5e5e5" : "#262626",
soft: dark ? "#262626" : "#f5f5f5",
softText: dark ? "#a3a3a3" : "#737373",
softHoverText: dark ? "#fff" : "#171717",
addHover: dark ? "#404040" : "#e5e5e5",
editSurface: dark ? "#262626" : "#fff",
editBorder: dark ? "#404040" : "#e5e5e5",
editHover: dark ? "#404040" : "#f5f5f5",
removeBorder: dark ? "#404040" : "#e5e5e5",
removeHover: dark ? "#262626" : "#f5f5f5",
removeHoverText: "#ef4444",
inputText: dark ? "#d4d4d4" : "#525252",
inputBorder: dark ? "#404040" : "#d4d4d4",
...overrides,
}
const dateOpacity = useSharedValue(0)
const swapDate = useCallback((next: boolean) => setShownEditing(next), [])
useEffect(() => {
if (editing === shownEditing) {
dateOpacity.set(withTiming(1, FADE))
return
}
dateOpacity.set(
withTiming(0, FADE, (finished) => {
if (finished) scheduleOnRN(swapDate, editing)
})
)
}, [editing, shownEditing])
const dateMotion = useAnimatedStyle(() => ({ opacity: dateOpacity.get() }))
function change(next: Reminder[]) {
setReminders(next)
onUpdate?.(next)
}
function update(id: string, patch: Partial<Reminder>) {
change(
reminders.map((item) => (item.id === id ? { ...item, ...patch } : item))
)
}
function add() {
change([
...reminders,
{
id: `${prefix}-${nextId.current++}`,
type: "Notification",
value: 5,
unit: "minutes",
},
])
}
return (
<Animated.View
testID={testID}
layout={LAYOUT}
collapsable={false}
style={[
{
alignSelf: "center",
width: Math.min(width, window.width - 32),
borderRadius: radius,
borderWidth: 2,
borderColor: colors.border,
backgroundColor: colors.surface,
transitionProperty: ["backgroundColor", "borderColor"],
transitionDuration: 150,
transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
padding: 24,
boxShadow:
"0px 10px 15px -3px rgba(0,0,0,.1), 0px 4px 6px -4px rgba(0,0,0,.1)",
},
style,
]}
>
<View
style={{
flexDirection: "row",
gap: 12,
alignItems: "flex-start",
marginBottom: 24,
}}
>
<View style={{ flex: 1 }}>
<Text
style={{
fontFamily,
fontSize: small ? 18 : 20,
lineHeight: (small ? 18 : 20) * 1.25,
fontWeight: "700",
color: colors.text,
}}
>
{title}
</Text>
<Animated.View style={[{ marginTop: 8 }, dateMotion]}>
{shownEditing ? (
<TextInput
autoFocus
value={date}
onChangeText={(value) => {
setDate(value)
onDateChange?.(value)
}}
onSubmitEditing={() => setEditing(false)}
returnKeyType="done"
accessibilityLabel="Event date"
style={{
fontFamily,
fontSize: 16,
lineHeight: 24,
fontWeight: "600",
color: colors.inputText,
backgroundColor: colors.soft,
borderRadius: 4,
borderBottomWidth: 2,
borderBottomColor: colors.inputBorder,
paddingHorizontal: 8,
paddingVertical: 4,
}}
/>
) : (
<Text
style={{
fontFamily,
fontSize: 16,
lineHeight: 24,
fontWeight: "600",
color: colors.muted,
}}
>
{date}
</Text>
)}
</Animated.View>
</View>
<HoverButton
accessibilityRole="button"
accessibilityLabel={editing ? "Save date" : "Edit date"}
hoverBackground={colors.editHover}
onPress={() => setEditing(!editing)}
style={{
padding: 8,
borderWidth: 2,
borderColor: colors.editBorder,
borderRadius: 8,
backgroundColor: colors.editSurface,
}}
>
<Svg
width={20}
height={20}
viewBox={editing ? "0 0 448 512" : "0 0 24 24"}
fill={colors.muted}
>
<Path
d={
editing
? "M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"
: "M8.707 19.707 18 10.414 13.586 6l-9.293 9.293a1.003 1.003 0 0 0-.263.464L3 21l5.242-1.03c.176-.044.337-.135.465-.263zM21 7.414a2 2 0 0 0 0-2.828L19.414 3a2 2 0 0 0-2.828 0L15 4.586 19.414 9 21 7.414z"
}
/>
</Svg>
</HoverButton>
</View>
<Animated.View
layout={LAYOUT}
collapsable={false}
style={{
borderTopWidth: 1,
borderStyle: "dashed",
borderColor: colors.border,
paddingTop: 8,
}}
>
{reminders.map((reminder) => (
<ReminderRow
key={reminder.id}
reminder={reminder}
update={(patch) => update(reminder.id, patch)}
remove={() =>
change(reminders.filter((item) => item.id !== reminder.id))
}
colors={colors}
fontFamily={fontFamily}
small={small}
/>
))}
</Animated.View>
<Animated.View layout={LAYOUT} style={{ marginTop: 24 }}>
<HoverButton
accessibilityRole="button"
onPress={add}
hoverBackground={colors.addHover}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
backgroundColor: colors.soft,
paddingVertical: 12,
borderRadius: 16,
}}
>
<LineIcon kind="plus" color={colors.secondary} size={18} />
<Text
style={{
fontFamily,
fontSize: 16,
lineHeight: 24,
fontWeight: "600",
color: colors.secondary,
}}
>
Add Reminder
</Text>
</HoverButton>
</Animated.View>
</Animated.View>
)
}
function LineIcon({
kind,
size = 16,
color,
}: {
kind: "plus" | "minus" | "x"
size?: number
color: string
}) {
const stroke = useSharedValue(color)
useEffect(() => {
stroke.set(withTiming(color, COLOR))
}, [color])
const colorProps = useAnimatedProps(() => ({ stroke: stroke.get() }))
return (
<AnimatedSvg
animatedProps={colorProps}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeWidth={kind === "minus" ? 2 : 2.5}
strokeLinecap="round"
>
<Path
d={
kind === "plus"
? "M12 5v14M5 12h14"
: kind === "minus"
? "M5 12h14"
: "m18 6-12 12M6 6l12 12"
}
/>
</AnimatedSvg>
)
}
function Chevrons() {
return (
<View style={{ height: 24 }}>
{[true, false].map((up) => (
<Svg
key={String(up)}
width={14}
height={14}
viewBox="0 0 24 24"
fill="none"
stroke="#a3a3a3"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
style={up ? undefined : { marginTop: -4 }}
>
<Path d={up ? "m18 15-6-6-6 6" : "m6 9 6 6 6-6"} />
</Svg>
))}
</View>
)
}
function ReminderRow({
reminder,
update,
remove,
colors,
fontFamily,
small,
}: {
reminder: Reminder
update: (patch: Partial<Reminder>) => void
remove: () => void
colors: Palette
fontFamily?: string
small: boolean
}) {
const [numberMotion, setNumberMotion] = useState({
value: reminder.value,
direction: 1,
})
if (numberMotion.value !== reminder.value)
setNumberMotion({
value: reminder.value,
direction: reminder.value >= numberMotion.value ? 1 : -1,
})
const direction = numberMotion.direction
const pill = {
borderWidth: 1.6,
borderColor: colors.border,
borderRadius: 999,
backgroundColor: colors.surface,
}
return (
<Animated.View
entering={enterRow}
exiting={exitRow}
layout={LAYOUT}
collapsable={false}
style={{
paddingVertical: 16,
borderBottomWidth: 1,
borderColor: colors.border,
borderStyle: "dashed",
gap: 12,
}}
>
<AnimatedPressable
accessibilityRole="button"
accessibilityLabel={`Reminder type: ${reminder.type}`}
onPress={() =>
update({
type: reminder.type === "Notification" ? "Email" : "Notification",
})
}
style={[
pill,
{
paddingHorizontal: 16,
paddingVertical: 8,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
transitionProperty: ["backgroundColor", "borderColor"],
transitionDuration: 150,
transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
},
]}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<Swap
value={reminder.type}
width={18}
height={24}
blur={4}
duration={500}
render={(value) => (
<Svg
width={18}
height={18}
viewBox={value === "Notification" ? "0 0 448 512" : "0 0 24 24"}
fill="#a3a3a3"
>
<Path
d={
value === "Notification"
? "M224 0c-17.7 0-32 14.3-32 32l0 19.2C119 66 64 130.6 64 208l0 18.8c0 47-17.3 92.4-48.5 127.6l-7.4 8.3c-8.4 9.4-10.4 22.9-5.3 34.4S19.4 416 32 416l384 0c12.6 0 24-7.4 29.2-18.9s3.1-25-5.3-34.4l-7.4-8.3C401.3 319.2 384 273.9 384 226.8l0-18.8c0-77.4-55-142-128-156.8L256 32c0-17.7-14.3-32-32-32zm45.3 493.3c12-12 18.7-28.3 18.7-45.3l-64 0-64 0c0 17 6.7 33.3 18.7 45.3s28.3 18.7 45.3 18.7s33.3-6.7 45.3-18.7z"
: "M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2m0 4-8 5-8-5V6l8 5 8-5z"
}
/>
</Svg>
)}
/>
<Word
value={reminder.type}
color={colors.secondary}
fontFamily={fontFamily}
/>
</View>
<Chevrons />
</AnimatedPressable>
<View
style={{
flexDirection: "row",
flexWrap: small ? "wrap" : "nowrap",
gap: 8,
alignItems: "center",
}}
>
<Animated.View
style={[
pill,
{
flex: 1,
minWidth: 120,
paddingHorizontal: 8,
paddingVertical: 4,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
transitionProperty: ["backgroundColor", "borderColor"],
transitionDuration: 150,
transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
},
]}
>
<HoverButton
accessibilityRole="button"
accessibilityLabel="Decrease reminder time"
color={colors.softText}
hoverColor={colors.softHoverText}
hitSlop={4}
onPress={() => update({ value: Math.max(1, reminder.value - 1) })}
style={{
padding: 8,
borderRadius: 999,
backgroundColor: colors.soft,
}}
>
{(color) => <LineIcon kind="minus" color={color} />}
</HoverButton>
<View style={{ flexDirection: "row", overflow: "hidden" }}>
{String(reminder.value)
.padStart(2, "0")
.split("")
.map((char, i) => (
<Swap
key={i}
value={char}
width={11}
height={28}
blur={2}
scaleFrom={0}
direction={direction}
render={(value) => (
<SvgText
x={5.5}
y={20}
textAnchor="middle"
fontFamily={fontFamily}
fontSize={18}
fontWeight="700"
fill={colors.text}
>
{value}
</SvgText>
)}
/>
))}
</View>
<HoverButton
accessibilityRole="button"
accessibilityLabel="Increase reminder time"
color={colors.softText}
hoverColor={colors.softHoverText}
hitSlop={4}
onPress={() => update({ value: reminder.value + 1 })}
style={{
padding: 8,
borderRadius: 999,
backgroundColor: colors.soft,
}}
>
{(color) => <LineIcon kind="plus" color={color} />}
</HoverButton>
</Animated.View>
<AnimatedPressable
layout={LAYOUT}
accessibilityRole="button"
accessibilityLabel={`Time unit: ${reminder.unit}`}
onPress={() => {
const units: TimeUnit[] = ["minutes", "hours", "days"]
update({
unit: units[(units.indexOf(reminder.unit) + 1) % units.length],
})
}}
style={[
pill,
{
flex: 1.4,
minWidth: 120,
paddingHorizontal: 16,
paddingVertical: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
transitionProperty: ["backgroundColor", "borderColor"],
transitionDuration: 150,
transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
},
]}
>
<Word
value={reminder.unit}
color={colors.secondary}
fontFamily={fontFamily}
/>
<Chevrons />
</AnimatedPressable>
<HoverButton
accessibilityRole="button"
accessibilityLabel="Remove reminder"
color={colors.softText}
hoverColor={colors.removeHoverText}
hoverBackground={colors.removeHover}
hitSlop={4}
onPress={remove}
style={{
padding: small ? 10 : 12,
borderWidth: 1,
borderColor: colors.removeBorder,
borderRadius: 999,
}}
>
{(color) => <LineIcon kind="x" size={18} color={color} />}
</HoverButton>
</View>
</Animated.View>
)
}
type WordGlyph = {
id: number
key: string
char: string
index: number
x: number
width: number
exit: boolean
initial: boolean
}
function Word({
value,
color,
fontFamily,
}: {
value: string
color: string
fontFamily?: string
}) {
const chars = value
.split("")
.map((char, i) => (i === 0 ? char.toUpperCase() : char))
const [widths, setWidths] = useState<Record<string, number>>({})
const serial = useRef(0),
mounted = useRef(false)
const [layers, setLayers] = useState<WordGlyph[]>([])
let total = 0
const target = chars.map((char, index) => {
const width = widths[char] ?? 9,
glyph = { key: char + ":" + index, char, index, x: total, width }
total += width
return glyph
})
useEffect(() => {
const initial = !mounted.current
mounted.current = true
setLayers((previous) => {
const retained = previous
.filter((layer) => !target.some((glyph) => glyph.key === layer.key))
.map((layer) => ({ ...layer, exit: true }))
return [
...retained,
...target.map((glyph) => {
const old = previous.find((layer) => layer.key === glyph.key)
return {
...glyph,
id: old?.id ?? serial.current++,
initial: old?.initial ?? initial,
exit: false,
}
}),
]
})
}, [value, widths])
const remove = useCallback(
(id: number) =>
setLayers((previous) =>
previous.filter((layer) => layer.id !== id || !layer.exit)
),
[]
)
return (
<View style={{ width: total, height: 24, overflow: "hidden" }}>
<View
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={{ position: "absolute", opacity: 0, flexDirection: "row" }}
>
{Array.from(new Set(chars)).map((char) => (
<Text
key={char}
onLayout={(event) => {
const width = event.nativeEvent.layout.width
setWidths((previous) =>
previous[char] === width
? previous
: { ...previous, [char]: width }
)
}}
style={{ fontFamily, fontSize: 16, fontWeight: "600" }}
>
{char}
</Text>
))}
</View>
{layers.map((layer) => (
<View
key={layer.id}
style={{
position: "absolute",
left: layer.x,
top: 0,
width: layer.width,
height: 24,
}}
>
<SwapLayer
id={layer.id}
exit={layer.exit}
initial={layer.initial}
width={layer.width}
height={24}
blur={0}
scaleFrom={1}
direction={1}
delay={layer.index * 17.5}
duration={300}
remove={remove}
>
<SvgText
x={0}
y={18}
fontFamily={fontFamily}
fontSize={16}
fontWeight="600"
fill={color}
>
{layer.char}
</SvgText>
</SwapLayer>
</View>
))}
</View>
)
}
type SwapProps = {
value: string
width: number
height: number
render: (value: string) => React.ReactNode
duration?: number
blur?: number
scaleFrom?: number
direction?: number
delay?: number
}
function Swap({
value,
width,
height,
render,
duration = 300,
blur = 0,
scaleFrom = 1,
direction = 1,
delay = 0,
}: SwapProps) {
const [layers, setLayers] = useState([
{ id: 0, value, exit: false, initial: true },
]),
next = useRef(1),
last = useRef(value)
useEffect(() => {
if (last.current === value) return
last.current = value
setLayers((current) => {
const returning = current.some((layer) => layer.value === value)
const layers = current.map((layer) => ({
...layer,
exit: layer.value !== value,
}))
return returning
? layers
: [
...layers,
{ id: next.current++, value, exit: false, initial: false },
]
})
}, [value])
const remove = useCallback(
(id: number) =>
setLayers((current) =>
current.filter((layer) => layer.id !== id || !layer.exit)
),
[]
)
return (
<View style={{ width, height }}>
{layers.map((layer) => (
<SwapLayer
key={layer.id}
{...layer}
width={width}
height={height}
blur={blur}
scaleFrom={scaleFrom}
direction={direction}
delay={delay}
duration={duration}
remove={remove}
>
{render(layer.value)}
</SwapLayer>
))}
</View>
)
}
function SwapLayer({
id: key,
exit,
initial,
width,
height,
blur,
scaleFrom,
direction,
delay,
duration,
remove,
children,
}: {
id: number
exit: boolean
initial: boolean
width: number
height: number
blur: number
scaleFrom: number
direction: number
delay: number
duration: number
remove: (id: number) => void
children: React.ReactNode
}) {
const id = useId().replace(/\W/g, ""),
p = useSharedValue(initial ? 1 : 0),
y = useSharedValue(initial ? 0 : direction * 5)
useEffect(() => {
p.set(
motionDelay(
delay,
withTiming(exit ? 0 : 1, { duration, easing: critical }, (finished) => {
if (finished && exit) scheduleOnRN(remove, key)
})
)
)
y.set(
motionDelay(
delay,
withTiming(exit ? -direction * 5 : 0, { duration, easing: critical })
)
)
}, [exit, direction, delay, duration])
const motion = useAnimatedProps(() => ({
opacity: p.get(),
transform: `translate(${width / 2} ${height / 2 + y.get()}) scale(${scaleFrom + (1 - scaleFrom) * p.get()}) translate(${-width / 2} ${-height / 2})`,
}))
const filter = useAnimatedProps(() => ({
stdDeviation: blur * (1 - p.get()),
}))
return (
<Svg
width={width}
height={height}
style={{ position: "absolute", overflow: "visible" }}
>
<Defs>
<Filter id={id} x="-100%" y="-50%" width="300%" height="200%">
<AB animatedProps={filter} />
</Filter>
</Defs>
<AG animatedProps={motion}>
<G filter={blur ? `url(#${id})` : undefined}>{children}</G>
</AG>
</Svg>
)
}
export default EventReminders
Give the gestures and motion a run on a real Android phone.