Skip to content

Commit 8a6137b

Browse files
Jakubantalikclaude
andcommitted
metal-fx: native ports (SwiftUI, React Native) + metal page and Studio platform snippets
Syncs ports/ from the metal-fx repo (MetalFxKit for iOS 17+, metal-fx-native on Skia + Reanimated 4). The metal page's Install & Usage gains React Native and Swift UI tabs, and the Studio's Metal v2 bench emits both ports' code from the live knobs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 3645e42 commit 8a6137b

54 files changed

Lines changed: 18422 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/metal-fx/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,9 @@ dist-playground
66
.DS_Store
77
*.log
88
.wrangler
9+
ports/ios/MetalFxDemo/build
10+
ports/ios/**/*.xcodeproj
11+
ports/react-native/example/ios
12+
ports/react-native/example/android
13+
ports/react-native/example/.expo
14+
ports/react-native/metal-fx-native/lib

packages/metal-fx/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,21 @@ By default `MetalFx` reads the computed `border-radius` of the wrapped child eac
169169
## License
170170

171171
MIT &copy; [Jakub Antalik](https://github.com/Jakubantalik). See [LICENSE](./LICENSE).
172+
173+
## Native ports (v2)
174+
175+
The v2 effect is also available for the phone, in `ports/`:
176+
177+
- **SwiftUI**`ports/ios/MetalFxKit` (iOS 17+, Metal shader via SwiftUI
178+
`colorEffect` / `layerEffect`). `MetalFx`, `MetalText`, `MetalBadge`,
179+
`.metalReflection(of:)`, tilt-driven bend, glow, inner shadow, and a
180+
screen-edge refraction halo (`.metalEdgeHalo()`). Demo app in
181+
`ports/ios/MetalFxDemo` (`./run.sh`).
182+
- **React Native**`ports/react-native/metal-fx-native` (react-native-skia +
183+
Reanimated 4, everything on the UI thread). Same components; `<MetalReflection>`,
184+
`<MetalReflectionText>`, `<MetalEdgeHalo>`. Expo example in
185+
`ports/react-native/example`.
186+
187+
Both ports render the same material (Paper's `liquidMetal`, ported to MSL and
188+
SkSL) with the same presets and tuning as the web; there is no cursor on a
189+
phone, so the cursor light is web-only and the phone's tilt plays the pointer.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.build/
2+
build/
3+
*.xcodeproj/
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import SwiftUI
2+
import MetalFxKit
3+
4+
/// The metal-fx v2 demo page's cards, on the phone: the composer with its
5+
/// send button (reflecting onto the Auto chip), "Plan Pro", "Live mode · New",
6+
/// and a button parked at the screen edge for the edge halo. Tilt the phone
7+
/// to bend the rings; in the Simulator, drag the tilt pad.
8+
struct ContentView: View {
9+
@State private var strength = 0.9
10+
@State private var preset: MetalPreset = .chromatic
11+
@State private var pad: CGVector = .zero
12+
@State private var holdTilt = false
13+
@State private var glowGain = 1.0
14+
/// Launch arguments for headless checks: `-focus edge -holdTilt YES -glowGain 2`.
15+
@State private var focus = UserDefaults.standard.string(forKey: "focus") ?? "all"
16+
17+
private let page = Color(hex: 0x0F0F0F)
18+
private let card = Color(hex: 0x171717)
19+
private let composer = Color(hex: 0x1D1D1D)
20+
private let chip = Color(hex: 0x262626)
21+
private let secondary = Color(hex: 0x999999)
22+
23+
var body: some View {
24+
ScrollView {
25+
VStack(spacing: 12) {
26+
if focus == "edge" {
27+
edgeCard
28+
controls
29+
} else if focus == "composer" {
30+
composerCard
31+
controls
32+
} else {
33+
header
34+
composerCard
35+
planProCard
36+
liveModeCard
37+
edgeCard
38+
controls
39+
}
40+
Spacer(minLength: 24)
41+
}
42+
.padding(.horizontal, 12)
43+
.padding(.top, 8)
44+
}
45+
.background(page.ignoresSafeArea())
46+
.onAppear {
47+
let d = UserDefaults.standard
48+
if d.object(forKey: "glowGain") != nil { glowGain = d.double(forKey: "glowGain") }
49+
if d.bool(forKey: "holdTilt") { holdTilt = true; MetalTiltSource.shared.override = CGVector(dx: 0.45, dy: 0.3) }
50+
if d.bool(forKey: "haloDebug") { MetalEdgeHaloDebug.enabled = true }
51+
}
52+
}
53+
54+
private var header: some View {
55+
VStack(spacing: 6) {
56+
Text("Liquid metal").font(.system(size: 28, weight: .semibold)).foregroundStyle(.white)
57+
Text("metal-fx v2 · SwiftUI").font(.system(size: 15)).foregroundStyle(secondary)
58+
}
59+
.padding(.vertical, 24)
60+
}
61+
62+
private var composerCard: some View {
63+
VStack(alignment: .leading, spacing: 0) {
64+
Text("Build anything...")
65+
.font(.system(size: 14))
66+
.foregroundStyle(Color(hex: 0x6B6B6B))
67+
.padding(.bottom, 16)
68+
HStack(spacing: 12) {
69+
Circle().fill(chip).frame(width: 36, height: 36)
70+
.overlay { Image(systemName: "plus").font(.system(size: 14, weight: .medium)).foregroundStyle(.white) }
71+
Spacer()
72+
chipView("Agent")
73+
chipView("Auto")
74+
.metalReflection(of: "send")
75+
MetalFx(variant: .circle, preset: preset, theme: .dark, strength: strength, innerShadow: true, glowGain: glowGain, id: "send") {
76+
Image(systemName: "arrow.up")
77+
.font(.system(size: 15, weight: .medium))
78+
.foregroundStyle(Color(hex: 0xF8F8F8))
79+
.frame(width: 40, height: 40)
80+
}
81+
}
82+
}
83+
.padding(EdgeInsets(top: 20, leading: 16, bottom: 16, trailing: 16))
84+
.background(composer, in: RoundedRectangle(cornerRadius: 20))
85+
.padding(.horizontal, 24)
86+
.frame(maxWidth: .infinity)
87+
.frame(height: 260)
88+
.background(card, in: RoundedRectangle(cornerRadius: 30))
89+
}
90+
91+
private func chipView(_ label: String) -> some View {
92+
HStack(spacing: 4) {
93+
Text(label).font(.system(size: 12)).foregroundStyle(Color(hex: 0xF8F8F8))
94+
Image(systemName: "chevron.down").font(.system(size: 9, weight: .semibold)).foregroundStyle(Color(hex: 0x8A8A8A))
95+
}
96+
.padding(.leading, 14).padding(.trailing, 10)
97+
.frame(height: 36)
98+
.background(chip, in: Capsule())
99+
}
100+
101+
private var planProCard: some View {
102+
HStack(alignment: .lastTextBaseline, spacing: 6) {
103+
Text("Plan")
104+
.font(.system(size: 24, weight: .medium))
105+
.foregroundStyle(secondary.opacity(0.6))
106+
.metalReflection(of: "pro", strength: 0.64, style: .glyphs)
107+
MetalText("Pro", font: .system(size: 24, weight: .medium), preset: preset, theme: .dark, strength: strength, id: "pro")
108+
}
109+
.frame(maxWidth: .infinity)
110+
.frame(height: 170)
111+
.background(card, in: RoundedRectangle(cornerRadius: 36))
112+
}
113+
114+
private var liveModeCard: some View {
115+
HStack(spacing: 12) {
116+
Text("Live mode").font(.system(size: 20)).foregroundStyle(secondary)
117+
MetalBadge("New", preset: preset, theme: .dark, strength: strength)
118+
}
119+
.frame(maxWidth: .infinity)
120+
.frame(height: 170)
121+
.background(card, in: RoundedRectangle(cornerRadius: 36))
122+
}
123+
124+
/// A ring parked against the right edge of the screen.
125+
private var edgeCard: some View {
126+
HStack {
127+
VStack(alignment: .leading, spacing: 4) {
128+
Text("Edge halo").font(.system(size: 17, weight: .medium)).foregroundStyle(.white)
129+
Text("Light leaves through the glass edge").font(.system(size: 13)).foregroundStyle(secondary)
130+
}
131+
Spacer()
132+
MetalFx(variant: .circle, preset: preset, theme: .dark, strength: strength, innerShadow: true, id: "edge") {
133+
Image(systemName: "arrow.up")
134+
.font(.system(size: 15, weight: .medium))
135+
.foregroundStyle(Color(hex: 0xF8F8F8))
136+
.frame(width: 40, height: 40)
137+
}
138+
.padding(.trailing, -4)
139+
}
140+
.padding(.leading, 24)
141+
.frame(maxWidth: .infinity)
142+
.frame(height: 120)
143+
.background(card, in: RoundedRectangle(cornerRadius: 30))
144+
.padding(.trailing, -12)
145+
// The card touches the screen edge, so it hosts the halo.
146+
.metalEdgeHalo()
147+
}
148+
149+
private var controls: some View {
150+
VStack(spacing: 14) {
151+
HStack(spacing: 12) {
152+
Text("Button").font(.system(size: 13)).foregroundStyle(secondary)
153+
Spacer()
154+
MetalFx(variant: .button, preset: preset, theme: .dark, strength: strength * 0.7, id: "pill") {
155+
Text("Upgrade to Pro")
156+
.font(.system(size: 14, weight: .medium))
157+
.foregroundStyle(Color(hex: 0xF8F8F8))
158+
.padding(.horizontal, 18)
159+
.frame(height: 40)
160+
}
161+
}
162+
Picker("Color", selection: $preset) {
163+
ForEach(MetalPreset.allCases) { p in Text(p.rawValue.capitalized).tag(p) }
164+
}
165+
.pickerStyle(.segmented)
166+
HStack {
167+
Text("Strength").font(.system(size: 13)).foregroundStyle(secondary)
168+
Slider(value: $strength, in: 0...1)
169+
Text("\(Int(strength * 100))%").font(.system(size: 13, design: .monospaced)).foregroundStyle(secondary).frame(width: 44)
170+
}
171+
HStack {
172+
Text("Glow").font(.system(size: 13)).foregroundStyle(secondary)
173+
Slider(value: $glowGain, in: 0...4)
174+
Text(String(format: "%.1f×", glowGain)).font(.system(size: 13, design: .monospaced)).foregroundStyle(secondary).frame(width: 44)
175+
}
176+
Toggle(isOn: $holdTilt) {
177+
Text("Hold a tilt (simulator)").font(.system(size: 13)).foregroundStyle(secondary)
178+
}
179+
.onChange(of: holdTilt) { _, on in MetalTiltSource.shared.override = on ? CGVector(dx: 0.45, dy: 0.3) : nil }
180+
HStack(spacing: 12) {
181+
VStack(alignment: .leading, spacing: 4) {
182+
Text("Tilt pad").font(.system(size: 13)).foregroundStyle(.white)
183+
Text("Drag to bend the rings by hand (the phone's tilt drives it otherwise).")
184+
.font(.system(size: 12)).foregroundStyle(secondary)
185+
}
186+
Spacer()
187+
tiltPad
188+
}
189+
}
190+
.padding(16)
191+
.background(card, in: RoundedRectangle(cornerRadius: 20))
192+
}
193+
194+
private var tiltPad: some View {
195+
let size: CGFloat = 96
196+
return ZStack {
197+
Circle().fill(chip)
198+
Circle().stroke(Color.white.opacity(0.08))
199+
Circle().fill(.white.opacity(0.8)).frame(width: 14, height: 14)
200+
.offset(x: pad.dx * size / 2, y: pad.dy * size / 2)
201+
}
202+
.frame(width: size, height: size)
203+
.gesture(
204+
DragGesture(minimumDistance: 0)
205+
.onChanged { v in
206+
let dx = max(-1, min(1, v.location.x / size * 2 - 1))
207+
let dy = max(-1, min(1, v.location.y / size * 2 - 1))
208+
pad = CGVector(dx: dx, dy: dy)
209+
MetalTiltSource.shared.override = CGVector(dx: dx * 0.6, dy: dy * 0.6)
210+
}
211+
.onEnded { _ in
212+
pad = .zero
213+
MetalTiltSource.shared.override = holdTilt ? CGVector(dx: 0.45, dy: 0.3) : nil
214+
}
215+
)
216+
}
217+
}
218+
219+
extension Color {
220+
init(hex: UInt32) {
221+
self.init(red: Double((hex >> 16) & 0xff) / 255, green: Double((hex >> 8) & 0xff) / 255, blue: Double(hex & 0xff) / 255)
222+
}
223+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import SwiftUI
2+
3+
@main
4+
struct MetalFxDemoApp: App {
5+
var body: some Scene {
6+
WindowGroup {
7+
ContentView()
8+
.preferredColorScheme(.dark)
9+
}
10+
}
11+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: MetalFxDemo
2+
options:
3+
bundleIdPrefix: com.jakubantalik
4+
deploymentTarget:
5+
iOS: "17.0"
6+
createIntermediateGroups: true
7+
8+
packages:
9+
MetalFxKit:
10+
path: ../MetalFxKit
11+
12+
targets:
13+
MetalFxDemo:
14+
type: application
15+
platform: iOS
16+
sources:
17+
- path: Sources
18+
dependencies:
19+
- package: MetalFxKit
20+
product: MetalFxKit
21+
settings:
22+
base:
23+
GENERATE_INFOPLIST_FILE: YES
24+
INFOPLIST_KEY_UILaunchScreen_Generation: YES
25+
INFOPLIST_KEY_CFBundleDisplayName: Liquid metal
26+
INFOPLIST_KEY_UIUserInterfaceStyle: Dark
27+
INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait
28+
PRODUCT_BUNDLE_IDENTIFIER: com.jakubantalik.MetalFxDemo
29+
SWIFT_VERSION: "5.0"
30+
TARGETED_DEVICE_FAMILY: "1,2"
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/bin/bash
2+
# Build and launch the MetalFxKit demo in the iOS Simulator.
3+
#
4+
# ./run.sh # default device (iPhone 16 Pro)
5+
# ./run.sh "iPhone 16e" # pick another simulator
6+
#
7+
# Requires: Xcode, xcodegen (brew install xcodegen), an installed iOS runtime.
8+
set -euo pipefail
9+
10+
DEVICE="${1:-iPhone 16 Pro}"
11+
BUNDLE_ID="com.jakubantalik.MetalFxDemo"
12+
XCODE="${XCODE_APP:-/Applications/Xcode.app}"
13+
export DEVELOPER_DIR="$XCODE/Contents/Developer"
14+
15+
cd "$(dirname "$0")"
16+
command -v xcodegen >/dev/null || { echo "error: xcodegen not found — run: brew install xcodegen" >&2; exit 1; }
17+
18+
echo "▸ Generating project…"
19+
xcodegen generate --quiet
20+
echo "▸ Booting $DEVICE"
21+
xcrun simctl boot "$DEVICE" 2>/dev/null || true
22+
open -a "$XCODE/Contents/Developer/Applications/Simulator.app"
23+
echo "▸ Building…"
24+
xcodebuild -project MetalFxDemo.xcodeproj -scheme MetalFxDemo \
25+
-destination "platform=iOS Simulator,name=$DEVICE" -derivedDataPath build \
26+
build > /tmp/metalfx-demo-build.log 2>&1 \
27+
|| { grep -E "error:" /tmp/metalfx-demo-build.log | head -20; echo "BUILD FAILED (full log: /tmp/metalfx-demo-build.log)"; exit 1; }
28+
APP="build/Build/Products/Debug-iphonesimulator/MetalFxDemo.app"
29+
echo "▸ Installing and launching…"
30+
xcrun simctl install "$DEVICE" "$APP"
31+
xcrun simctl launch "$DEVICE" "$BUNDLE_ID"
32+
echo "✓ Running on $DEVICE"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// swift-tools-version: 5.9
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "MetalFxKit",
6+
platforms: [
7+
.iOS(.v17),
8+
],
9+
products: [
10+
.library(name: "MetalFxKit", targets: ["MetalFxKit"])
11+
],
12+
targets: [
13+
.target(
14+
name: "MetalFxKit",
15+
resources: [
16+
// Compiled into the bundle's default.metallib by Xcode's build
17+
// system (SwiftPM alone leaves it uncompiled — build the
18+
// package via Xcode / xcodebuild for the shaders to work).
19+
.process("MetalFxShaders.metal"),
20+
]
21+
),
22+
]
23+
)

0 commit comments

Comments
 (0)