Skip to content

Instantly share code, notes, and snippets.

@emin-grbo
Created August 6, 2026 04:14
Show Gist options
  • Select an option

  • Save emin-grbo/5c568bc2d60983bc42520c5e602b90c0 to your computer and use it in GitHub Desktop.

Select an option

Save emin-grbo/5c568bc2d60983bc42520c5e602b90c0 to your computer and use it in GitHub Desktop.
Wiggle A/B Tester

TiltVariants — lenticular A/B testing for SwiftUI views

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.

One file, no dependencies, iOS 17+.

TiltVariants(labels: ("glass", "solid")) {
    glassStatusRow
} b: {
    solidStatusRow
}

Why

Comparing two designs usually means stacking them, or toggling a bool and rebuilding, or exporting screenshots and squinting at them side by side. None of those show you the thing you actually care about: how each version reads in the same slot, against the same live background.

Tilt puts both in one place and lets your wrist do the switching.

How it works

CMMotionManager device attitude → roll clamped to ±0.5 rad (~±28°) and normalized to -1...1 → drives either a hard flip or a crossfade between two @ViewBuilders stacked in a ZStack.

Options

Parameter Default Effect
snap true true = hard lenticular flip past ±0.30 roll. false = continuous crossfade tracking the angle.
labels ("V1", "V2") Corner chip captions. nil hides the chip — tilt is the only control, which is what you want for a clean screenshot.

Tap the chip to cycle tilt → lock A → lock B → tilt. That's what makes it usable on the simulator, which has no gyro.

Details that matter

  • DEBUG-gated. The whole mechanism is behind #if DEBUG; release builds render variant A and nothing else. Leaving the wrapper in shipped code is safe.
  • Hit testing. The faded-out variant gets allowsHitTesting(false) and accessibilityHidden(true). Variants are usually buttons, and an invisible one at opacity: 0 will happily swallow taps otherwise.
  • Hysteresis. The snap threshold sits at ±0.30 with no return path, so a phone held near level doesn't chatter between the two.
  • Energy. 20 Hz, not 60 — a design A/B doesn't need more, and every tick invalidates the view. A dead-band means a still device writes no state at all, so a phone on a desk causes zero re-renders. Motion starts on appear, stops on disappear.
  • Reduce Motion. Gyro never starts; the chip is the control.

Simulator

No gyro, so tilt does nothing — use the chip. Everything else works.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment