|
import CoreMotion |
|
import SwiftUI |
|
|
|
// MARK: - Tilt Variants |
|
// |
|
// Lenticular A/B switch for SwiftUI — tilt the phone left or right to flip |
|
// between two versions of the same view, like the wiggle cards in cereal |
|
// boxes. Compare two designs in place, over real data, without screenshots |
|
// or a rebuild. |
|
// |
|
// Drop this one file in a project. iOS 17+. No dependencies. |
|
// |
|
// TiltVariants(labels: ("glass", "solid")) { |
|
// glassRow |
|
// } b: { |
|
// solidRow |
|
// } |
|
// |
|
// The whole mechanism lives behind `#if DEBUG`, so release builds render |
|
// variant A and nothing else — leaving the wrapper in shipped code is safe. |
|
|
|
struct TiltVariants<A: View, B: View>: View { |
|
/// `true` = hard lenticular flip at a tilt threshold. |
|
/// `false` = continuous crossfade tracking the tilt angle. |
|
var snap: Bool = true |
|
|
|
/// Chip captions, A then B. `nil` hides the chip — tilt becomes the only |
|
/// control, which is what you want for a clean screenshot. |
|
var labels: (String, String)? = ("V1", "V2") |
|
|
|
@ViewBuilder let a: () -> A |
|
@ViewBuilder let b: () -> B |
|
|
|
#if DEBUG |
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion |
|
|
|
@State private var motion = TiltReader() |
|
/// Which side the tilt has settled on. Only meaningful in snap mode. |
|
@State private var tiltedToB = false |
|
/// Chip-locked variant. `nil` = tilt is driving. |
|
@State private var locked: Bool? |
|
#endif |
|
|
|
var body: some View { |
|
#if DEBUG |
|
debugBody |
|
#else |
|
a() |
|
#endif |
|
} |
|
|
|
#if DEBUG |
|
|
|
/// Roll past this (of the ±1 normalized range, ≈±28°) commits the flip. |
|
/// Wide enough that a phone held near level never chatters between them. |
|
private static var snapThreshold: Double { 0.30 } |
|
|
|
private var isB: Bool { locked ?? tiltedToB } |
|
|
|
/// 0 = fully A, 1 = fully B. |
|
private var blend: Double { |
|
if let locked { return locked ? 1 : 0 } |
|
if snap { return tiltedToB ? 1 : 0 } |
|
return min(max((motion.roll + 1) / 2, 0), 1) |
|
} |
|
|
|
private var debugBody: some View { |
|
let t = blend |
|
|
|
return ZStack { |
|
// The faded-out variant keeps its layout (so the stack doesn't |
|
// resize mid-flip) but must not eat taps meant for the live one — |
|
// variants are often buttons. |
|
a() |
|
.opacity(1 - t) |
|
.allowsHitTesting(t < 0.5) |
|
.accessibilityHidden(t >= 0.5) |
|
|
|
b() |
|
.opacity(t) |
|
.allowsHitTesting(t >= 0.5) |
|
.accessibilityHidden(t < 0.5) |
|
} |
|
.animation(.easeOut(duration: 0.18), value: t) |
|
.overlay(alignment: .topTrailing) { chip } |
|
.onChange(of: motion.roll) { _, roll in |
|
guard snap, locked == nil else { return } |
|
if roll > Self.snapThreshold, !tiltedToB { tiltedToB = true } |
|
if roll < -Self.snapThreshold, tiltedToB { tiltedToB = false } |
|
} |
|
.onAppear { |
|
// Reduce Motion users get the chip only — no gyro polling. |
|
if !reduceMotion { motion.start() } |
|
} |
|
.onDisappear { motion.stop() } |
|
} |
|
|
|
@ViewBuilder |
|
private var chip: some View { |
|
if let labels { |
|
Button { |
|
switch locked { |
|
case .none: locked = false // tilt → lock A |
|
case .some(false): locked = true // lock A → lock B |
|
case .some(true): locked = nil // lock B → tilt |
|
} |
|
} label: { |
|
HStack(spacing: 3) { |
|
Text(isB ? labels.1 : labels.0) |
|
if locked != nil { |
|
Image(systemName: "lock.fill") |
|
.font(.system(size: 8, weight: .bold)) |
|
} |
|
} |
|
.font(.system(size: 10, weight: .bold, design: .monospaced)) |
|
.foregroundStyle(.white) |
|
.padding(.horizontal, 7) |
|
.padding(.vertical, 3) |
|
.background(Capsule().fill(.black.opacity(0.6))) |
|
} |
|
.buttonStyle(.plain) |
|
.offset(x: 6, y: -10) |
|
.accessibilityLabel("Variant \(isB ? labels.1 : labels.0)") |
|
.accessibilityHint("Cycles between tilt control and locking a variant") |
|
} |
|
} |
|
|
|
#endif |
|
} |
|
|
|
// MARK: - Tilt Reader |
|
|
|
#if DEBUG |
|
/// Reads device attitude and exposes roll normalized to -1...1. |
|
/// |
|
/// Energy notes: |
|
/// - Updates are OFF until `start()` — the view starts on appear, stops on disappear. |
|
/// - 20 Hz, not 60: a design A/B doesn't need more, and every tick invalidates the view. |
|
/// - Dead-band: a device that is effectively still writes no state at all, |
|
/// so a phone on a desk causes zero re-renders. |
|
@Observable |
|
final class TiltReader { |
|
/// Roll (left/right), clamped to ±0.5 rad (~±28°) and normalized to -1...1. |
|
var roll: Double = 0 |
|
|
|
@ObservationIgnored private let motion = CMMotionManager() |
|
@ObservationIgnored private let queue = OperationQueue() |
|
@ObservationIgnored private let dampening: Double = 0.2 // lower = smoother |
|
@ObservationIgnored private let deadBand: Double = 0.012 // min delta worth a re-render |
|
|
|
init() { |
|
queue.maxConcurrentOperationCount = 1 |
|
} |
|
|
|
deinit { |
|
motion.stopDeviceMotionUpdates() |
|
} |
|
|
|
func start() { |
|
guard motion.isDeviceMotionAvailable, !motion.isDeviceMotionActive else { return } |
|
motion.deviceMotionUpdateInterval = 1.0 / 20.0 |
|
|
|
motion.startDeviceMotionUpdates(to: queue) { [weak self] data, _ in |
|
guard let self, let attitude = data?.attitude else { return } |
|
let raw = max(-1, min(1, attitude.roll / 0.5)) |
|
|
|
DispatchQueue.main.async { |
|
let next = self.roll + (raw - self.roll) * self.dampening |
|
guard abs(next - self.roll) > self.deadBand else { return } |
|
|
|
withAnimation(.easeOut(duration: 0.15)) { |
|
self.roll = next |
|
} |
|
} |
|
} |
|
} |
|
|
|
func stop() { |
|
motion.stopDeviceMotionUpdates() |
|
} |
|
} |
|
#endif |
|
|
|
// MARK: - Preview |
|
|
|
#if DEBUG |
|
#Preview("Tilt A/B") { |
|
TiltVariants(labels: ("outline", "solid")) { |
|
Text("Variant A") |
|
.font(.title2.bold()) |
|
.foregroundStyle(.white) |
|
.frame(maxWidth: .infinity) |
|
.padding(28) |
|
.background( |
|
RoundedRectangle(cornerRadius: 22, style: .continuous) |
|
.strokeBorder(.white.opacity(0.6), lineWidth: 2) |
|
) |
|
} b: { |
|
Text("Variant B") |
|
.font(.title2.bold()) |
|
.frame(maxWidth: .infinity) |
|
.padding(28) |
|
.background( |
|
RoundedRectangle(cornerRadius: 22, style: .continuous) |
|
.fill(.ultraThinMaterial) |
|
) |
|
} |
|
.padding(24) |
|
.background( |
|
LinearGradient(colors: [.orange, .red], startPoint: .top, endPoint: .bottom) |
|
.ignoresSafeArea() |
|
) |
|
} |
|
#endif |