|
| 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 | +} |
0 commit comments