@expokit/cli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +32 -2
- package/bin/expo-kit.js +1204 -0
- package/bin/lib.mjs +343 -0
- package/bin/mcp.mjs +338 -0
- package/package.json +44 -4
- package/registry/_docs/accordion.json +99 -0
- package/registry/_docs/action-sheet.json +110 -0
- package/registry/_docs/alert.json +127 -0
- package/registry/_docs/avatar.json +203 -0
- package/registry/_docs/badge.json +117 -0
- package/registry/_docs/bottom-nav.json +119 -0
- package/registry/_docs/bottom-sheet.json +158 -0
- package/registry/_docs/bullet.json +24 -0
- package/registry/_docs/button-3d.json +122 -0
- package/registry/_docs/button.json +115 -0
- package/registry/_docs/calendar.json +182 -0
- package/registry/_docs/card-stack.json +108 -0
- package/registry/_docs/card.json +111 -0
- package/registry/_docs/carousel.json +102 -0
- package/registry/_docs/checkbox.json +93 -0
- package/registry/_docs/checkout.json +102 -0
- package/registry/_docs/chip-group.json +116 -0
- package/registry/_docs/color-swatch.json +66 -0
- package/registry/_docs/confetti.json +82 -0
- package/registry/_docs/coupon.json +176 -0
- package/registry/_docs/cube-pager.json +84 -0
- package/registry/_docs/dashboard.json +120 -0
- package/registry/_docs/date-picker.json +249 -0
- package/registry/_docs/dialog.json +162 -0
- package/registry/_docs/drawer.json +171 -0
- package/registry/_docs/fab.json +120 -0
- package/registry/_docs/flip-card.json +111 -0
- package/registry/_docs/floating-input.json +191 -0
- package/registry/_docs/gift-card.json +108 -0
- package/registry/_docs/glass-button.json +118 -0
- package/registry/_docs/glass-nav-bar.json +78 -0
- package/registry/_docs/glass-search-bar.json +90 -0
- package/registry/_docs/glass-segmented.json +66 -0
- package/registry/_docs/glass-sheet.json +108 -0
- package/registry/_docs/glass-tab-bar.json +76 -0
- package/registry/_docs/glass.json +78 -0
- package/registry/_docs/inbox.json +72 -0
- package/registry/_docs/isometric-bar.json +72 -0
- package/registry/_docs/kit.json +11 -0
- package/registry/_docs/kpi-card.json +11 -0
- package/registry/_docs/like-button.json +72 -0
- package/registry/_docs/liquidglass-tab.json +27 -0
- package/registry/_docs/list-item.json +200 -0
- package/registry/_docs/loyalty-card.json +108 -0
- package/registry/_docs/morph-button.json +108 -0
- package/registry/_docs/number-ticker.json +96 -0
- package/registry/_docs/offers.json +78 -0
- package/registry/_docs/onboarding.json +66 -0
- package/registry/_docs/otp-input.json +134 -0
- package/registry/_docs/pagination-dots.json +88 -0
- package/registry/_docs/parallax-layers.json +84 -0
- package/registry/_docs/parallax-scroll.json +186 -0
- package/registry/_docs/password-input.json +92 -0
- package/registry/_docs/payment-card.json +153 -0
- package/registry/_docs/pixel-bar.json +66 -0
- package/registry/_docs/polar-area.json +78 -0
- package/registry/_docs/profile.json +84 -0
- package/registry/_docs/progress-steps.json +92 -0
- package/registry/_docs/progress.json +132 -0
- package/registry/_docs/radio-group.json +92 -0
- package/registry/_docs/rating.json +90 -0
- package/registry/_docs/ring-carousel.json +102 -0
- package/registry/_docs/scratch-card.json +90 -0
- package/registry/_docs/search-bar.json +140 -0
- package/registry/_docs/search-results.json +120 -0
- package/registry/_docs/segmented-control.json +103 -0
- package/registry/_docs/select.json +252 -0
- package/registry/_docs/settings.json +72 -0
- package/registry/_docs/shimmer-button.json +78 -0
- package/registry/_docs/sign-in.json +198 -0
- package/registry/_docs/skeleton.json +142 -0
- package/registry/_docs/slide-to-confirm.json +84 -0
- package/registry/_docs/slider.json +157 -0
- package/registry/_docs/spin-wheel.json +114 -0
- package/registry/_docs/spinner.json +91 -0
- package/registry/_docs/stepper.json +168 -0
- package/registry/_docs/swipe-deck.json +102 -0
- package/registry/_docs/swipeable-row.json +72 -0
- package/registry/_docs/switch.json +104 -0
- package/registry/_docs/tabs.json +105 -0
- package/registry/_docs/tag-input.json +128 -0
- package/registry/_docs/textarea.json +175 -0
- package/registry/_docs/ticket-card.json +174 -0
- package/registry/_docs/ticket-shape.json +11 -0
- package/registry/_docs/tilt-card.json +141 -0
- package/registry/_docs/time-picker.json +253 -0
- package/registry/_docs/timeline-chart.json +30 -0
- package/registry/_docs/timeline.json +156 -0
- package/registry/_docs/toast.json +161 -0
- package/registry/_docs/treemap.json +30 -0
- package/registry/_docs/waterfall.json +36 -0
- package/registry/_docs/wheel-list.json +102 -0
- package/registry/accordion/files/src/components/ui/accordion.tsx +233 -0
- package/registry/accordion/files/src/lib/kit.tsx +219 -0
- package/registry/accordion.json +17 -0
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +580 -0
- package/registry/action-sheet/files/src/lib/kit.tsx +219 -0
- package/registry/action-sheet.json +18 -0
- package/registry/alert/files/src/components/ui/alert.tsx +654 -0
- package/registry/alert/files/src/components/ui/button.tsx +274 -0
- package/registry/alert/files/src/lib/kit.tsx +219 -0
- package/registry/alert.json +20 -0
- package/registry/avatar/files/src/components/ui/avatar.tsx +790 -0
- package/registry/avatar/files/src/lib/kit.tsx +219 -0
- package/registry/avatar.json +18 -0
- package/registry/badge/files/src/components/ui/badge.tsx +316 -0
- package/registry/badge/files/src/lib/kit.tsx +219 -0
- package/registry/badge.json +17 -0
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +1050 -0
- package/registry/bottom-nav/files/src/lib/kit.tsx +219 -0
- package/registry/bottom-nav.json +18 -0
- package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +585 -0
- package/registry/bottom-sheet/files/src/lib/kit.tsx +219 -0
- package/registry/bottom-sheet.json +17 -0
- package/registry/bullet.json +14 -0
- package/registry/button/files/src/components/ui/button.tsx +274 -0
- package/registry/button/files/src/lib/kit.tsx +219 -0
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +212 -0
- package/registry/button-3d/files/src/lib/kit.tsx +219 -0
- package/registry/button-3d.json +18 -0
- package/registry/button.json +18 -0
- package/registry/calendar/files/src/components/ui/calendar.tsx +822 -0
- package/registry/calendar/files/src/lib/kit.tsx +219 -0
- package/registry/calendar.json +17 -0
- package/registry/card/files/src/components/ui/card.tsx +300 -0
- package/registry/card/files/src/lib/kit.tsx +219 -0
- package/registry/card-stack.json +15 -0
- package/registry/card.json +17 -0
- package/registry/carousel.json +15 -0
- package/registry/checkbox/files/src/components/ui/checkbox.tsx +150 -0
- package/registry/checkbox/files/src/lib/kit.tsx +219 -0
- package/registry/checkbox.json +17 -0
- package/registry/checkout.json +26 -0
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +269 -0
- package/registry/chip-group/files/src/lib/kit.tsx +219 -0
- package/registry/chip-group.json +17 -0
- package/registry/color-swatch.json +17 -0
- package/registry/confetti/files/src/components/ui/confetti.tsx +222 -0
- package/registry/confetti/files/src/lib/kit.tsx +219 -0
- package/registry/confetti.json +15 -0
- package/registry/coupon/files/src/components/ui/coupon.tsx +698 -0
- package/registry/coupon/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/coupon/files/src/lib/kit.tsx +219 -0
- package/registry/coupon/files/src/lib/ticket-shape.tsx +543 -0
- package/registry/coupon.json +23 -0
- package/registry/cube-pager.json +15 -0
- package/registry/dashboard.json +32 -0
- package/registry/date-picker/files/src/components/ui/calendar.tsx +822 -0
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +796 -0
- package/registry/date-picker/files/src/lib/kit.tsx +219 -0
- package/registry/date-picker.json +19 -0
- package/registry/dialog/files/src/components/ui/dialog.tsx +759 -0
- package/registry/dialog/files/src/lib/kit.tsx +219 -0
- package/registry/dialog.json +18 -0
- package/registry/drawer/files/src/components/ui/drawer.tsx +1135 -0
- package/registry/drawer/files/src/lib/kit.tsx +219 -0
- package/registry/drawer.json +18 -0
- package/registry/fab.json +18 -0
- package/registry/flip-card/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/flip-card/files/src/lib/kit.tsx +219 -0
- package/registry/flip-card.json +15 -0
- package/registry/floating-input/files/src/components/ui/floating-input.tsx +528 -0
- package/registry/floating-input/files/src/lib/kit.tsx +219 -0
- package/registry/floating-input.json +17 -0
- package/registry/gift-card.json +21 -0
- package/registry/glass-button.json +22 -0
- package/registry/glass-nav-bar.json +21 -0
- package/registry/glass-search-bar.json +21 -0
- package/registry/glass-segmented.json +22 -0
- package/registry/glass-sheet.json +22 -0
- package/registry/glass-tab-bar.json +22 -0
- package/registry/glass.json +19 -0
- package/registry/inbox.json +30 -0
- package/registry/isometric-bar.json +14 -0
- package/registry/kit/files/src/lib/kit.tsx +219 -0
- package/registry/kit.json +11 -0
- package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +45 -0
- package/registry/kpi-card.json +14 -0
- package/registry/like-button.json +18 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +97 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/explore.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/index.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/profile.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/_layout.tsx +33 -0
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +259 -0
- package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +122 -0
- package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +61 -0
- package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +130 -0
- package/registry/liquidglass-tab/files/src/lib/tabs.ts +108 -0
- package/registry/liquidglass-tab.json +23 -0
- package/registry/list-item/files/src/components/ui/list-item.tsx +508 -0
- package/registry/list-item/files/src/lib/kit.tsx +219 -0
- package/registry/list-item.json +17 -0
- package/registry/loyalty-card.json +23 -0
- package/registry/morph-button.json +18 -0
- package/registry/number-ticker.json +15 -0
- package/registry/offers.json +24 -0
- package/registry/onboarding.json +17 -0
- package/registry/otp-input/files/src/components/ui/otp-input.tsx +380 -0
- package/registry/otp-input/files/src/lib/kit.tsx +219 -0
- package/registry/otp-input.json +17 -0
- package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +262 -0
- package/registry/pagination-dots/files/src/lib/kit.tsx +219 -0
- package/registry/pagination-dots.json +15 -0
- package/registry/parallax-layers.json +15 -0
- package/registry/parallax-scroll.json +17 -0
- package/registry/password-input/files/src/components/ui/floating-input.tsx +528 -0
- package/registry/password-input/files/src/components/ui/password-input.tsx +271 -0
- package/registry/password-input/files/src/lib/kit.tsx +219 -0
- package/registry/password-input.json +19 -0
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +385 -0
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/payment-card/files/src/lib/kit.tsx +219 -0
- package/registry/payment-card.json +21 -0
- package/registry/pixel-bar.json +14 -0
- package/registry/polar-area.json +14 -0
- package/registry/profile.json +17 -0
- package/registry/progress/files/src/components/ui/progress.tsx +883 -0
- package/registry/progress/files/src/lib/kit.tsx +219 -0
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +559 -0
- package/registry/progress-steps/files/src/lib/kit.tsx +219 -0
- package/registry/progress-steps.json +18 -0
- package/registry/progress.json +17 -0
- package/registry/radio-group/files/src/components/ui/radio-group.tsx +327 -0
- package/registry/radio-group/files/src/lib/kit.tsx +219 -0
- package/registry/radio-group.json +18 -0
- package/registry/rating.json +18 -0
- package/registry/ring-carousel.json +15 -0
- package/registry/scratch-card.json +18 -0
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +530 -0
- package/registry/search-bar/files/src/lib/kit.tsx +219 -0
- package/registry/search-bar.json +17 -0
- package/registry/search-results.json +30 -0
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +341 -0
- package/registry/segmented-control/files/src/lib/kit.tsx +219 -0
- package/registry/segmented-control.json +18 -0
- package/registry/select/files/src/components/ui/select.tsx +1398 -0
- package/registry/select/files/src/lib/kit.tsx +219 -0
- package/registry/select.json +17 -0
- package/registry/settings.json +26 -0
- package/registry/shimmer-button.json +17 -0
- package/registry/sign-in.json +26 -0
- package/registry/skeleton/files/src/components/ui/skeleton.tsx +799 -0
- package/registry/skeleton/files/src/lib/kit.tsx +219 -0
- package/registry/skeleton.json +17 -0
- package/registry/slide-to-confirm.json +18 -0
- package/registry/slider/files/src/components/ui/slider.tsx +716 -0
- package/registry/slider/files/src/lib/kit.tsx +219 -0
- package/registry/slider.json +17 -0
- package/registry/spin-wheel.json +18 -0
- package/registry/spinner/files/src/components/ui/spinner.tsx +335 -0
- package/registry/spinner/files/src/lib/kit.tsx +219 -0
- package/registry/spinner.json +17 -0
- package/registry/stepper/files/src/components/ui/stepper.tsx +646 -0
- package/registry/stepper/files/src/lib/kit.tsx +219 -0
- package/registry/stepper.json +17 -0
- package/registry/swipe-deck.json +15 -0
- package/registry/swipeable-row.json +15 -0
- package/registry/switch/files/src/components/ui/switch.tsx +344 -0
- package/registry/switch/files/src/lib/kit.tsx +219 -0
- package/registry/switch.json +18 -0
- package/registry/tabs/files/src/components/ui/tabs.tsx +539 -0
- package/registry/tabs/files/src/lib/kit.tsx +219 -0
- package/registry/tabs.json +17 -0
- package/registry/tag-input/files/src/components/ui/tag-input.tsx +720 -0
- package/registry/tag-input/files/src/lib/kit.tsx +219 -0
- package/registry/tag-input.json +17 -0
- package/registry/textarea/files/src/components/ui/textarea.tsx +553 -0
- package/registry/textarea/files/src/lib/kit.tsx +219 -0
- package/registry/textarea.json +17 -0
- package/registry/ticket-card.json +24 -0
- package/registry/ticket-shape/files/src/lib/kit.tsx +219 -0
- package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +543 -0
- package/registry/ticket-shape.json +17 -0
- package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/tilt-card/files/src/lib/kit.tsx +219 -0
- package/registry/tilt-card.json +17 -0
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +1159 -0
- package/registry/time-picker/files/src/lib/kit.tsx +219 -0
- package/registry/time-picker.json +17 -0
- package/registry/timeline/files/src/components/ui/avatar.tsx +790 -0
- package/registry/timeline/files/src/components/ui/timeline.tsx +1059 -0
- package/registry/timeline/files/src/lib/kit.tsx +219 -0
- package/registry/timeline-chart.json +14 -0
- package/registry/timeline.json +20 -0
- package/registry/toast/files/src/components/ui/button.tsx +274 -0
- package/registry/toast/files/src/components/ui/toast.tsx +1284 -0
- package/registry/toast/files/src/lib/kit.tsx +219 -0
- package/registry/toast.json +20 -0
- package/registry/treemap.json +14 -0
- package/registry/waterfall.json +14 -0
- package/registry/wheel-list.json +17 -0
- package/schema/components.schema.json +13 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Animated,
|
|
4
|
+
Easing,
|
|
5
|
+
Image,
|
|
6
|
+
PanResponder,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
View,
|
|
9
|
+
type ImageSourcePropType,
|
|
10
|
+
type LayoutChangeEvent,
|
|
11
|
+
type StyleProp,
|
|
12
|
+
type ViewStyle,
|
|
13
|
+
} from 'react-native';
|
|
14
|
+
import Svg, { Defs, LinearGradient, RadialGradient, Rect, Stop } from 'react-native-svg';
|
|
15
|
+
|
|
16
|
+
import { motion } from '@/lib/kit';
|
|
17
|
+
|
|
18
|
+
/** `holo` adds a rainbow foil sheen that shifts as the card tilts (always faintly visible). */
|
|
19
|
+
export type TiltCardGlare = 'soft' | 'sharp' | 'holo' | 'none';
|
|
20
|
+
|
|
21
|
+
/** A rectangle in card points. */
|
|
22
|
+
export interface TiltRect {
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TiltCardProps {
|
|
30
|
+
children?: React.ReactNode;
|
|
31
|
+
/** Photo that fills the card behind the content (`require()` or `{ uri }`). */
|
|
32
|
+
image?: ImageSourcePropType;
|
|
33
|
+
/** Darkens the bottom of `image` so white text on it stays readable: 0 off … 1 strong (default 0.6). */
|
|
34
|
+
shade?: number;
|
|
35
|
+
/** Maximum tilt in degrees at the card's edge (default 12). */
|
|
36
|
+
maxTilt?: number;
|
|
37
|
+
/** Scale while touched (default 1.03). */
|
|
38
|
+
pressScale?: number;
|
|
39
|
+
/** Light reflection that slides across the card as it tilts. */
|
|
40
|
+
glare?: TiltCardGlare;
|
|
41
|
+
/**
|
|
42
|
+
* Where the reflection may fall, in card points — for cards with cut-outs (ticket notches,
|
|
43
|
+
* perforated edges), so light never shows over a hole. Default: the whole card.
|
|
44
|
+
*/
|
|
45
|
+
glareClip?: TiltRect[];
|
|
46
|
+
/** Depth of the 3D effect — smaller is more dramatic (default 800). */
|
|
47
|
+
perspective?: number;
|
|
48
|
+
/** Slowly sway on its own while untouched, so the card reads as 3D at rest. */
|
|
49
|
+
idle?: boolean;
|
|
50
|
+
/** Corner radius (default 20). */
|
|
51
|
+
radius?: number;
|
|
52
|
+
/** Soft shadow under the card that shifts as it tilts (default true). */
|
|
53
|
+
shadow?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* The part of the card that casts the shadow, in card points — e.g. leave out a ticket stub that's
|
|
56
|
+
* being torn off, so the plate never shows where it was. Default: the whole card.
|
|
57
|
+
*/
|
|
58
|
+
shadowRect?: TiltRect;
|
|
59
|
+
/**
|
|
60
|
+
* Narrows the shadow sideways (1 = full width, 0 = none) — for content that turns edge-on inside
|
|
61
|
+
* the card, like a FlipCard mid-flip, so the shadow plate never shows past it.
|
|
62
|
+
*/
|
|
63
|
+
shadowScaleX?: Animated.Value | Animated.AnimatedInterpolation<number>;
|
|
64
|
+
/** Turn touch tilting off (the card still renders, e.g. while a parent list scrolls). */
|
|
65
|
+
disabled?: boolean;
|
|
66
|
+
/** Called with a light tap (not after a drag). */
|
|
67
|
+
onPress?: () => void;
|
|
68
|
+
accessibilityLabel?: string;
|
|
69
|
+
/** Width, height, margins — applied to the card's outer box. */
|
|
70
|
+
style?: StyleProp<ViewStyle>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface TiltLayerProps {
|
|
74
|
+
children?: React.ReactNode;
|
|
75
|
+
/**
|
|
76
|
+
* How far above the card this layer floats: 0 sits on the card, 1 floats well above it
|
|
77
|
+
* and slides furthest as the card tilts (default 0.5). Negative sinks below the surface.
|
|
78
|
+
*/
|
|
79
|
+
depth?: number;
|
|
80
|
+
/** Layers fill the card by default; position content inside, or override here. */
|
|
81
|
+
style?: StyleProp<ViewStyle>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface TiltState {
|
|
85
|
+
/** -1 … 1 across the card (left → right). */
|
|
86
|
+
tx: Animated.Value;
|
|
87
|
+
/** -1 … 1 down the card (top → bottom). */
|
|
88
|
+
ty: Animated.Value;
|
|
89
|
+
/** 0 at rest … 1 while touched. */
|
|
90
|
+
press: Animated.Value;
|
|
91
|
+
width: number;
|
|
92
|
+
height: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const Ctx = createContext<TiltState | null>(null);
|
|
96
|
+
|
|
97
|
+
/** The surrounding TiltCard's live tilt (null outside one) — for your own layers and effects. */
|
|
98
|
+
export function useTilt() {
|
|
99
|
+
return useContext(Ctx);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Gradients are drawn small and scaled up: a smooth gradient looks the same at any size, and a
|
|
103
|
+
// card-sized (or larger) SVG would cost megabytes of texture per card in a list.
|
|
104
|
+
const PAINT = 96;
|
|
105
|
+
|
|
106
|
+
// Six colour cycles across the paint, so a couple of bands cross the card like real foil. Cool
|
|
107
|
+
// pinks, violets and cyans stay clean when blended over dark cards (yellow and green turn olive).
|
|
108
|
+
const HOLO = Array.from({ length: 37 }, (_, i) => ['#ff6bd6', '#b38cff', '#6fb6ff', '#5ff0ff', '#a7ffe0', '#ffc2ef'][i % 6]);
|
|
109
|
+
|
|
110
|
+
function GlarePaint({ variant }: { variant: 'soft' | 'sharp' }) {
|
|
111
|
+
const sharp = variant === 'sharp';
|
|
112
|
+
return (
|
|
113
|
+
<Svg width={PAINT} height={PAINT}>
|
|
114
|
+
<Defs>
|
|
115
|
+
<RadialGradient id={`tilt-glare-${variant}`} cx="50%" cy="50%" r="50%">
|
|
116
|
+
<Stop offset="0" stopColor="#ffffff" stopOpacity={sharp ? 0.85 : 0.6} />
|
|
117
|
+
<Stop offset={sharp ? '0.25' : '0.5'} stopColor="#ffffff" stopOpacity={0.1} />
|
|
118
|
+
<Stop offset="1" stopColor="#ffffff" stopOpacity={0} />
|
|
119
|
+
</RadialGradient>
|
|
120
|
+
</Defs>
|
|
121
|
+
<Rect width={PAINT} height={PAINT} fill={`url(#tilt-glare-${variant})`} />
|
|
122
|
+
</Svg>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function HoloPaint({ strength = 0.6 }: { strength?: number }) {
|
|
127
|
+
return (
|
|
128
|
+
<Svg width={PAINT} height={PAINT}>
|
|
129
|
+
<Defs>
|
|
130
|
+
<LinearGradient id={`tilt-holo-${Math.round(strength * 100)}`} x1="0" y1="0" x2="1" y2="1">
|
|
131
|
+
{HOLO.map((c, i) => (
|
|
132
|
+
<Stop key={i} offset={String(i / (HOLO.length - 1))} stopColor={c} stopOpacity={strength} />
|
|
133
|
+
))}
|
|
134
|
+
</LinearGradient>
|
|
135
|
+
</Defs>
|
|
136
|
+
<Rect width={PAINT} height={PAINT} fill={`url(#tilt-holo-${Math.round(strength * 100)})`} />
|
|
137
|
+
</Svg>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The reflection (and holo sheen) for a card of size w × h, in card coordinates. */
|
|
142
|
+
function Reflection({
|
|
143
|
+
variant,
|
|
144
|
+
w,
|
|
145
|
+
h,
|
|
146
|
+
tx,
|
|
147
|
+
ty,
|
|
148
|
+
press,
|
|
149
|
+
idle,
|
|
150
|
+
}: {
|
|
151
|
+
variant: Exclude<TiltCardGlare, 'none'>;
|
|
152
|
+
w: number;
|
|
153
|
+
h: number;
|
|
154
|
+
tx: Animated.Value;
|
|
155
|
+
ty: Animated.Value;
|
|
156
|
+
press: Animated.Value;
|
|
157
|
+
idle: boolean;
|
|
158
|
+
}) {
|
|
159
|
+
const G = Math.max(w, h) * 1.7;
|
|
160
|
+
const H = Math.max(w, h) * 2.6;
|
|
161
|
+
const box = {
|
|
162
|
+
position: 'absolute' as const,
|
|
163
|
+
width: PAINT,
|
|
164
|
+
height: PAINT,
|
|
165
|
+
left: (w - PAINT) / 2,
|
|
166
|
+
top: (h - PAINT) / 2,
|
|
167
|
+
};
|
|
168
|
+
const glareOpacity = press.interpolate({
|
|
169
|
+
inputRange: [0, 1],
|
|
170
|
+
outputRange: [idle ? 0.22 : 0, variant === 'sharp' ? 0.55 : 0.32],
|
|
171
|
+
});
|
|
172
|
+
return (
|
|
173
|
+
<>
|
|
174
|
+
{variant === 'holo' ? (
|
|
175
|
+
<Animated.View
|
|
176
|
+
pointerEvents="none"
|
|
177
|
+
style={{
|
|
178
|
+
...box,
|
|
179
|
+
// Overlay blending tints the card like foil instead of washing it out.
|
|
180
|
+
mixBlendMode: 'overlay',
|
|
181
|
+
opacity: press.interpolate({
|
|
182
|
+
inputRange: [0, 1],
|
|
183
|
+
outputRange: [idle ? 0.26 : 0, 0.5],
|
|
184
|
+
}),
|
|
185
|
+
transform: [
|
|
186
|
+
// Travels further than the light, so the colours sweep as the card leans.
|
|
187
|
+
{
|
|
188
|
+
translateX: tx.interpolate({
|
|
189
|
+
inputRange: [-1, 1],
|
|
190
|
+
outputRange: [w * 0.9, -w * 0.9],
|
|
191
|
+
}),
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
translateY: ty.interpolate({
|
|
195
|
+
inputRange: [-1, 1],
|
|
196
|
+
outputRange: [h * 0.6, -h * 0.6],
|
|
197
|
+
}),
|
|
198
|
+
},
|
|
199
|
+
{ scale: H / PAINT },
|
|
200
|
+
],
|
|
201
|
+
}}
|
|
202
|
+
>
|
|
203
|
+
<HoloPaint strength={0.85} />
|
|
204
|
+
</Animated.View>
|
|
205
|
+
) : null}
|
|
206
|
+
<Animated.View
|
|
207
|
+
pointerEvents="none"
|
|
208
|
+
style={{
|
|
209
|
+
...box,
|
|
210
|
+
opacity: glareOpacity,
|
|
211
|
+
transform: [
|
|
212
|
+
// The highlight sits under the finger.
|
|
213
|
+
{
|
|
214
|
+
translateX: tx.interpolate({
|
|
215
|
+
inputRange: [-1, 1],
|
|
216
|
+
outputRange: [-w * 0.5, w * 0.5],
|
|
217
|
+
}),
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
translateY: ty.interpolate({
|
|
221
|
+
inputRange: [-1, 1],
|
|
222
|
+
outputRange: [-h * 0.5, h * 0.5],
|
|
223
|
+
}),
|
|
224
|
+
},
|
|
225
|
+
{ scale: G / PAINT },
|
|
226
|
+
],
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
<GlarePaint variant={variant === 'sharp' ? 'sharp' : 'soft'} />
|
|
230
|
+
</Animated.View>
|
|
231
|
+
</>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A card that tilts toward your finger in 3D: it lifts, its shadow slides away from the light,
|
|
237
|
+
* and a reflection glides across it. Put content in `TiltLayer`s to make it float above the
|
|
238
|
+
* surface. Native-driven transforms, no extra libraries.
|
|
239
|
+
*/
|
|
240
|
+
export function TiltCard({
|
|
241
|
+
children,
|
|
242
|
+
image,
|
|
243
|
+
shade = 0.6,
|
|
244
|
+
maxTilt = 12,
|
|
245
|
+
pressScale = 1.03,
|
|
246
|
+
glare = 'soft',
|
|
247
|
+
glareClip,
|
|
248
|
+
perspective = 800,
|
|
249
|
+
idle = false,
|
|
250
|
+
radius = 20,
|
|
251
|
+
shadow = true,
|
|
252
|
+
shadowRect,
|
|
253
|
+
shadowScaleX,
|
|
254
|
+
disabled = false,
|
|
255
|
+
onPress,
|
|
256
|
+
accessibilityLabel,
|
|
257
|
+
style,
|
|
258
|
+
}: TiltCardProps) {
|
|
259
|
+
const [size, setSize] = useState({ w: 0, h: 0 });
|
|
260
|
+
const sizeRef = useRef(size);
|
|
261
|
+
sizeRef.current = size;
|
|
262
|
+
|
|
263
|
+
// -1 … 1 across the card; the card leans toward the finger.
|
|
264
|
+
const tx = useRef(new Animated.Value(0)).current;
|
|
265
|
+
const ty = useRef(new Animated.Value(0)).current;
|
|
266
|
+
const press = useRef(new Animated.Value(0)).current;
|
|
267
|
+
const touching = useRef(false);
|
|
268
|
+
const origin = useRef({ x: 0, y: 0 });
|
|
269
|
+
const moved = useRef(false);
|
|
270
|
+
const viewRef = useRef<View>(null);
|
|
271
|
+
const disabledRef = useRef(disabled);
|
|
272
|
+
disabledRef.current = disabled;
|
|
273
|
+
|
|
274
|
+
// Idle sway: a slow figure-eight while nobody is touching.
|
|
275
|
+
const idleLoop = useRef<Animated.CompositeAnimation | null>(null);
|
|
276
|
+
const startIdle = () => {
|
|
277
|
+
if (!idle) return;
|
|
278
|
+
const ease = Easing.inOut(Easing.sin);
|
|
279
|
+
const leg = (v: Animated.Value, to: number, ms: number) =>
|
|
280
|
+
Animated.timing(v, {
|
|
281
|
+
toValue: to,
|
|
282
|
+
duration: ms,
|
|
283
|
+
easing: ease,
|
|
284
|
+
useNativeDriver: true,
|
|
285
|
+
});
|
|
286
|
+
idleLoop.current = Animated.loop(
|
|
287
|
+
Animated.parallel([
|
|
288
|
+
Animated.sequence([leg(tx, 0.5, 2400), leg(tx, -0.5, 4800), leg(tx, 0, 2400)]),
|
|
289
|
+
Animated.sequence([leg(ty, -0.35, 1200), leg(ty, 0.35, 2400), leg(ty, -0.35, 2400), leg(ty, 0.35, 2400), leg(ty, 0, 1200)]),
|
|
290
|
+
])
|
|
291
|
+
);
|
|
292
|
+
idleLoop.current.start();
|
|
293
|
+
};
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
startIdle();
|
|
296
|
+
return () => idleLoop.current?.stop();
|
|
297
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
298
|
+
}, [idle]);
|
|
299
|
+
|
|
300
|
+
const aim = (pageX: number, pageY: number) => {
|
|
301
|
+
const { w, h } = sizeRef.current;
|
|
302
|
+
if (!w || !h) return;
|
|
303
|
+
tx.setValue(Math.max(-1, Math.min(1, ((pageX - origin.current.x) / w) * 2 - 1)));
|
|
304
|
+
ty.setValue(Math.max(-1, Math.min(1, ((pageY - origin.current.y) / h) * 2 - 1)));
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const release = (tapped: boolean) => {
|
|
308
|
+
touching.current = false;
|
|
309
|
+
if (tapped && !moved.current) onPressRef.current?.();
|
|
310
|
+
Animated.parallel([
|
|
311
|
+
Animated.spring(tx, {
|
|
312
|
+
toValue: 0,
|
|
313
|
+
...motion.gentle,
|
|
314
|
+
useNativeDriver: true,
|
|
315
|
+
}),
|
|
316
|
+
Animated.spring(ty, {
|
|
317
|
+
toValue: 0,
|
|
318
|
+
...motion.gentle,
|
|
319
|
+
useNativeDriver: true,
|
|
320
|
+
}),
|
|
321
|
+
Animated.spring(press, {
|
|
322
|
+
toValue: 0,
|
|
323
|
+
...motion.gentle,
|
|
324
|
+
useNativeDriver: true,
|
|
325
|
+
}),
|
|
326
|
+
]).start(({ finished }) => {
|
|
327
|
+
if (finished && !touching.current) startIdle();
|
|
328
|
+
});
|
|
329
|
+
};
|
|
330
|
+
// Read the latest onPress at release time, so the responder never has to be rebuilt mid-gesture.
|
|
331
|
+
const onPressRef = useRef(onPress);
|
|
332
|
+
onPressRef.current = onPress;
|
|
333
|
+
const releaseRef = useRef(release);
|
|
334
|
+
releaseRef.current = release;
|
|
335
|
+
|
|
336
|
+
const responder = useMemo(
|
|
337
|
+
() =>
|
|
338
|
+
PanResponder.create({
|
|
339
|
+
onStartShouldSetPanResponder: () => !disabledRef.current,
|
|
340
|
+
// Only a real drag takes the touch from a button inside the card — a tap's tiny jitter must not.
|
|
341
|
+
onMoveShouldSetPanResponder: (_e, g) => !disabledRef.current && Math.abs(g.dx) + Math.abs(g.dy) > 6,
|
|
342
|
+
// Let a parent ScrollView take over scrolls that start on the card (on Android the native
|
|
343
|
+
// scroll view can only do that when the card doesn't block it).
|
|
344
|
+
onPanResponderTerminationRequest: () => true,
|
|
345
|
+
onShouldBlockNativeResponder: () => false,
|
|
346
|
+
onPanResponderGrant: (e) => {
|
|
347
|
+
touching.current = true;
|
|
348
|
+
moved.current = false;
|
|
349
|
+
idleLoop.current?.stop();
|
|
350
|
+
const { pageX, pageY, locationX, locationY } = e.nativeEvent;
|
|
351
|
+
// locationX is relative to the touched child, so measure the card itself and correct
|
|
352
|
+
// once the measurement arrives.
|
|
353
|
+
origin.current = { x: pageX - locationX, y: pageY - locationY };
|
|
354
|
+
viewRef.current?.measure((_x, _y, _w, _h, px, py) => {
|
|
355
|
+
if (typeof px === 'number' && typeof py === 'number') origin.current = { x: px, y: py };
|
|
356
|
+
});
|
|
357
|
+
aim(pageX, pageY);
|
|
358
|
+
Animated.spring(press, {
|
|
359
|
+
toValue: 1,
|
|
360
|
+
...motion.snappy,
|
|
361
|
+
useNativeDriver: true,
|
|
362
|
+
}).start();
|
|
363
|
+
},
|
|
364
|
+
onPanResponderMove: (e, g) => {
|
|
365
|
+
if (Math.abs(g.dx) + Math.abs(g.dy) > 6) moved.current = true;
|
|
366
|
+
aim(e.nativeEvent.pageX, e.nativeEvent.pageY);
|
|
367
|
+
},
|
|
368
|
+
onPanResponderRelease: () => releaseRef.current(true),
|
|
369
|
+
onPanResponderTerminate: () => releaseRef.current(false),
|
|
370
|
+
// React Native grants before asking the current responder to let go; when a child keeps its
|
|
371
|
+
// gesture (e.g. a ticket stub being torn), the grant is rejected and no release follows.
|
|
372
|
+
onPanResponderReject: () => releaseRef.current(false),
|
|
373
|
+
}),
|
|
374
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
375
|
+
[]
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
379
|
+
const { width, height } = e.nativeEvent.layout;
|
|
380
|
+
if (width !== size.w || height !== size.h) setSize({ w: width, h: height });
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const rotateY = tx.interpolate({
|
|
384
|
+
inputRange: [-1, 1],
|
|
385
|
+
outputRange: [`${-maxTilt}deg`, `${maxTilt}deg`],
|
|
386
|
+
});
|
|
387
|
+
const rotateX = ty.interpolate({
|
|
388
|
+
inputRange: [-1, 1],
|
|
389
|
+
outputRange: [`${maxTilt}deg`, `${-maxTilt}deg`],
|
|
390
|
+
});
|
|
391
|
+
const scale = press.interpolate({
|
|
392
|
+
inputRange: [0, 1],
|
|
393
|
+
outputRange: [1, pressScale],
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const ctx = useMemo(() => ({ tx, ty, press, width: size.w, height: size.h }), [tx, ty, press, size.w, size.h]);
|
|
397
|
+
const drift = Math.min(10, size.w * 0.03);
|
|
398
|
+
// The plate sits well inside the outline — far enough that it never shows through a notch or a
|
|
399
|
+
// see-through corner, even at full tilt; the shadow offset does the downward shift.
|
|
400
|
+
const body = shadowRect ?? { x: 0, y: 0, width: size.w, height: size.h };
|
|
401
|
+
const inset = {
|
|
402
|
+
x: Math.max(18, body.width * 0.08),
|
|
403
|
+
y: Math.max(20, body.height * 0.08),
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const reflection = glare !== 'none' && size.w ? <Reflection variant={glare} w={size.w} h={size.h} tx={tx} ty={ty} press={press} idle={idle} /> : null;
|
|
407
|
+
|
|
408
|
+
return (
|
|
409
|
+
<View style={style} onLayout={onLayout}>
|
|
410
|
+
{/* Shadow plate: hidden under the card, its soft shadow drifts away from the light as the
|
|
411
|
+
card leans, and spreads when the card lifts. */}
|
|
412
|
+
{shadow && size.w ? (
|
|
413
|
+
<Animated.View
|
|
414
|
+
pointerEvents="none"
|
|
415
|
+
style={[
|
|
416
|
+
styles.plate,
|
|
417
|
+
{
|
|
418
|
+
...(shadowRect
|
|
419
|
+
? { left: body.x + inset.x, top: body.y + inset.y, width: Math.max(0, body.width - 2 * inset.x), height: Math.max(0, body.height - 2 * inset.y) }
|
|
420
|
+
: { left: inset.x, right: inset.x, top: inset.y, bottom: inset.y }),
|
|
421
|
+
borderRadius: radius,
|
|
422
|
+
opacity: press.interpolate({
|
|
423
|
+
inputRange: [0, 1],
|
|
424
|
+
outputRange: [0.75, 1],
|
|
425
|
+
}),
|
|
426
|
+
transform: [
|
|
427
|
+
{
|
|
428
|
+
translateX: tx.interpolate({
|
|
429
|
+
inputRange: [-1, 1],
|
|
430
|
+
outputRange: [drift, -drift],
|
|
431
|
+
}),
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
scale: press.interpolate({
|
|
435
|
+
inputRange: [0, 1],
|
|
436
|
+
outputRange: [1, 1.035],
|
|
437
|
+
}),
|
|
438
|
+
},
|
|
439
|
+
{ scaleX: shadowScaleX ?? 1 },
|
|
440
|
+
],
|
|
441
|
+
},
|
|
442
|
+
]}
|
|
443
|
+
/>
|
|
444
|
+
) : null}
|
|
445
|
+
{/* The card sits in its own (non-collapsing) layer: iOS intersects sibling layers in 3D, so a
|
|
446
|
+
tilted card next to the flat shadow plate would be sliced by it along a diagonal. */}
|
|
447
|
+
{/* Android draws siblings by elevation, so the wrapper (not the card inside it) carries the
|
|
448
|
+
elevation that keeps it above the plate; with no background it casts no shadow itself. */}
|
|
449
|
+
<View collapsable={false} style={{ elevation: shadow ? 15 : 0 }}>
|
|
450
|
+
<Animated.View
|
|
451
|
+
ref={viewRef}
|
|
452
|
+
{...responder.panHandlers}
|
|
453
|
+
accessible={!!onPress || !!accessibilityLabel}
|
|
454
|
+
accessibilityRole={onPress ? 'button' : undefined}
|
|
455
|
+
accessibilityLabel={accessibilityLabel}
|
|
456
|
+
// Screen readers activate the card through an action, not synthesized touches.
|
|
457
|
+
accessibilityActions={onPress ? [{ name: 'activate' }] : undefined}
|
|
458
|
+
onAccessibilityAction={(e) => {
|
|
459
|
+
if (e.nativeEvent.actionName === 'activate') onPressRef.current?.();
|
|
460
|
+
}}
|
|
461
|
+
// elevation (no background → no extra shadow) keeps the card above its shadow plate on Android.
|
|
462
|
+
style={{
|
|
463
|
+
borderRadius: radius,
|
|
464
|
+
elevation: shadow ? 15 : 0,
|
|
465
|
+
transform: [{ perspective }, { rotateX }, { rotateY }, { scale }],
|
|
466
|
+
}}
|
|
467
|
+
>
|
|
468
|
+
<View style={[styles.clip, { borderRadius: radius }]}>
|
|
469
|
+
{image ? (
|
|
470
|
+
<>
|
|
471
|
+
<Image alt="" source={image} style={StyleSheet.absoluteFill} resizeMode="cover" accessibilityIgnoresInvertColors />
|
|
472
|
+
{shade > 0 ? (
|
|
473
|
+
<Svg width="100%" height="100%" style={StyleSheet.absoluteFill} preserveAspectRatio="none" viewBox="0 0 1 1">
|
|
474
|
+
<Defs>
|
|
475
|
+
<LinearGradient id={`tilt-shade-${Math.round(shade * 100)}`} x1="0" y1="0" x2="0" y2="1">
|
|
476
|
+
<Stop offset="0.35" stopColor="#000" stopOpacity={0} />
|
|
477
|
+
<Stop offset="1" stopColor="#000" stopOpacity={shade} />
|
|
478
|
+
</LinearGradient>
|
|
479
|
+
</Defs>
|
|
480
|
+
<Rect width="1" height="1" fill={`url(#tilt-shade-${Math.round(shade * 100)})`} />
|
|
481
|
+
</Svg>
|
|
482
|
+
) : null}
|
|
483
|
+
</>
|
|
484
|
+
) : null}
|
|
485
|
+
<Ctx.Provider value={ctx}>{children}</Ctx.Provider>
|
|
486
|
+
{reflection && !glareClip ? reflection : null}
|
|
487
|
+
{reflection && glareClip
|
|
488
|
+
? glareClip.map((r, i) => (
|
|
489
|
+
// One window per rect; each holds the full-card reflection shifted back into place.
|
|
490
|
+
<View key={i} pointerEvents="none" style={[styles.window, { left: r.x, top: r.y, width: r.width, height: r.height }]}>
|
|
491
|
+
<View
|
|
492
|
+
style={{
|
|
493
|
+
position: 'absolute',
|
|
494
|
+
left: -r.x,
|
|
495
|
+
top: -r.y,
|
|
496
|
+
width: size.w,
|
|
497
|
+
height: size.h,
|
|
498
|
+
}}
|
|
499
|
+
>
|
|
500
|
+
{reflection}
|
|
501
|
+
</View>
|
|
502
|
+
</View>
|
|
503
|
+
))
|
|
504
|
+
: null}
|
|
505
|
+
</View>
|
|
506
|
+
</Animated.View>
|
|
507
|
+
</View>
|
|
508
|
+
</View>
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Content that floats above (or sinks below) a `TiltCard`'s surface: as the card tilts, a layer
|
|
514
|
+
* slides further the higher it floats, and lifts a little when the card is pressed.
|
|
515
|
+
*/
|
|
516
|
+
export function TiltLayer({ children, depth = 0.5, style }: TiltLayerProps) {
|
|
517
|
+
const ctx = useContext(Ctx);
|
|
518
|
+
if (!ctx) throw new Error('TiltLayer must be inside TiltCard');
|
|
519
|
+
const d = depth * 14;
|
|
520
|
+
return (
|
|
521
|
+
<Animated.View
|
|
522
|
+
pointerEvents="box-none"
|
|
523
|
+
style={[
|
|
524
|
+
StyleSheet.absoluteFill,
|
|
525
|
+
{
|
|
526
|
+
transform: [
|
|
527
|
+
{
|
|
528
|
+
translateX: ctx.tx.interpolate({
|
|
529
|
+
inputRange: [-1, 1],
|
|
530
|
+
outputRange: [-d, d],
|
|
531
|
+
}),
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
translateY: ctx.ty.interpolate({
|
|
535
|
+
inputRange: [-1, 1],
|
|
536
|
+
outputRange: [-d, d],
|
|
537
|
+
}),
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
scale: ctx.press.interpolate({
|
|
541
|
+
inputRange: [0, 1],
|
|
542
|
+
outputRange: [1, 1 + Math.max(0, depth) * 0.035],
|
|
543
|
+
}),
|
|
544
|
+
},
|
|
545
|
+
],
|
|
546
|
+
},
|
|
547
|
+
style,
|
|
548
|
+
]}
|
|
549
|
+
>
|
|
550
|
+
{children}
|
|
551
|
+
</Animated.View>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export interface TiltFoilProps {
|
|
556
|
+
children?: React.ReactNode;
|
|
557
|
+
/** Corner radius (default 8). */
|
|
558
|
+
radius?: number;
|
|
559
|
+
/** Base metal under the rainbow (default silver). */
|
|
560
|
+
base?: string;
|
|
561
|
+
style?: StyleProp<ViewStyle>;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* A holographic foil patch — a seal, a sticker, a security strip — whose rainbow shifts as the
|
|
566
|
+
* surrounding TiltCard leans. Outside a TiltCard it shows a still foil.
|
|
567
|
+
*/
|
|
568
|
+
export function TiltFoil({ children, radius = 8, base = '#d7dbe2', style }: TiltFoilProps) {
|
|
569
|
+
const ctx = useContext(Ctx);
|
|
570
|
+
const [size, setSize] = useState({ w: 0, h: 0 });
|
|
571
|
+
const S = Math.max(size.w, size.h) * 3.2;
|
|
572
|
+
const travel = Math.max(size.w, size.h) * 0.9;
|
|
573
|
+
return (
|
|
574
|
+
<View
|
|
575
|
+
onLayout={(e) => {
|
|
576
|
+
const { width, height } = e.nativeEvent.layout;
|
|
577
|
+
if (width !== size.w || height !== size.h) setSize({ w: width, h: height });
|
|
578
|
+
}}
|
|
579
|
+
style={[{ borderRadius: radius, overflow: 'hidden', backgroundColor: base }, style]}
|
|
580
|
+
>
|
|
581
|
+
{size.w ? (
|
|
582
|
+
<Animated.View
|
|
583
|
+
pointerEvents="none"
|
|
584
|
+
style={{
|
|
585
|
+
position: 'absolute',
|
|
586
|
+
width: PAINT,
|
|
587
|
+
height: PAINT,
|
|
588
|
+
left: (size.w - PAINT) / 2,
|
|
589
|
+
top: (size.h - PAINT) / 2,
|
|
590
|
+
transform: [
|
|
591
|
+
{
|
|
592
|
+
translateX: ctx
|
|
593
|
+
? ctx.tx.interpolate({
|
|
594
|
+
inputRange: [-1, 1],
|
|
595
|
+
outputRange: [travel, -travel],
|
|
596
|
+
})
|
|
597
|
+
: 0,
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
translateY: ctx
|
|
601
|
+
? ctx.ty.interpolate({
|
|
602
|
+
inputRange: [-1, 1],
|
|
603
|
+
outputRange: [travel * 0.6, -travel * 0.6],
|
|
604
|
+
})
|
|
605
|
+
: 0,
|
|
606
|
+
},
|
|
607
|
+
{ scale: S / PAINT },
|
|
608
|
+
],
|
|
609
|
+
}}
|
|
610
|
+
>
|
|
611
|
+
<HoloPaint strength={0.85} />
|
|
612
|
+
</Animated.View>
|
|
613
|
+
) : null}
|
|
614
|
+
{children}
|
|
615
|
+
</View>
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const styles = StyleSheet.create({
|
|
620
|
+
plate: {
|
|
621
|
+
position: 'absolute',
|
|
622
|
+
top: 0,
|
|
623
|
+
left: 0,
|
|
624
|
+
right: 0,
|
|
625
|
+
bottom: 0,
|
|
626
|
+
backgroundColor: '#000',
|
|
627
|
+
shadowColor: '#000',
|
|
628
|
+
shadowOpacity: 0.45,
|
|
629
|
+
shadowRadius: 24,
|
|
630
|
+
shadowOffset: { width: 0, height: 16 },
|
|
631
|
+
elevation: 14,
|
|
632
|
+
},
|
|
633
|
+
clip: { overflow: 'hidden' },
|
|
634
|
+
window: { position: 'absolute', overflow: 'hidden' },
|
|
635
|
+
});
|