Implement the best interactions
out of the box
Implement the best interactions
out of the box
Implement the best interactions
out of the box
Implement the best interactions
out of the box
Shot
Snippet
iOS 17+ • Card flip • Motion tilt
Airbnb Identity Verification Card Flip Interaction
A SwiftUI recreation of Airbnb’s identity verification card, with tap-to-flip motion, device tilt, hologram shift, and drag fallback.
SwiftUI
import SwiftUI import CoreMotion import UIKit struct AirbnbIdentityVerificationCardFlipScanSnippet: View { @StateObject private var motion = DeviceTiltModel() @State private var isShowingVerification = false @State private var flipAngle: Double = 0 @State private var fallbackTilt: CGSize = .zero var body: some View { GeometryReader { proxy in let activeTilt = currentTilt let cardWidth = min(proxy.size.width - 40, Config.maxCardWidth) ZStack(alignment: .top) { Config.pageBackground .ignoresSafeArea() flippingCard( width: cardWidth, height: Config.cardHeight, tilt: activeTilt ) } } } }
import SwiftUI import CoreMotion import UIKit struct AirbnbIdentityVerificationCardFlipScanSnippet: View { @StateObject private var motion = DeviceTiltModel() @State private var isShowingVerification = false @State private var flipAngle: Double = 0 @State private var fallbackTilt: CGSize = .zero var body: some View { GeometryReader { proxy in let activeTilt = currentTilt let cardWidth = min(proxy.size.width - 40, Config.maxCardWidth) ZStack(alignment: .top) { Config.pageBackground .ignoresSafeArea() flippingCard( width: cardWidth, height: Config.cardHeight, tilt: activeTilt ) } } } }
import SwiftUI import CoreMotion import UIKit struct AirbnbIdentityVerificationCardFlipScanSnippet: View { @StateObject private var motion = DeviceTiltModel() @State private var isShowingVerification = false @State private var flipAngle: Double = 0 @State private var fallbackTilt: CGSize = .zero var body: some View { GeometryReader { proxy in let activeTilt = currentTilt let cardWidth = min(proxy.size.width - 40, Config.maxCardWidth) ZStack(alignment: .top) { Config.pageBackground .ignoresSafeArea() flippingCard( width: cardWidth, height: Config.cardHeight, tilt: activeTilt ) } } } }
import SwiftUI import CoreMotion import UIKit struct AirbnbIdentityVerificationCardFlipScanSnippet: View { @StateObject private var motion = DeviceTiltModel() @State private var isShowingVerification = false @State private var flipAngle: Double = 0 @State private var fallbackTilt: CGSize = .zero var body: some View { GeometryReader { proxy in let activeTilt = currentTilt let cardWidth = min(proxy.size.width - 40, Config.maxCardWidth) ZStack(alignment: .top) { Config.pageBackground .ignoresSafeArea() flippingCard( width: cardWidth, height: Config.cardHeight, tilt: activeTilt ) } } } }
import SwiftUI import CoreMotion import UIKit // AirbnbIdentityVerificationCardFlipScanSnippet // // An Airbnb-style identity sheet with a tap-to-flip profile card and a // motion-reactive verification card. Tapping the host card flips it on // the Y axis; once flipped, Core Motion drives the tilt, the portrait // hologram, and the printed logo-field color shift. In Simulator, where // there is no real motion sensor, dragging the verification card stands // in for device tilt. // // The portrait loads once through a small cached image loader (not // AsyncImage) so the photo stays resident while the card recomposes on // every tilt frame, instead of flickering back to a loading state. // // HOW TO CUSTOMIZE: everything tweakable lives in Config below: copy, // photo IDs, colors, layout, typography, and the flip/tilt motion. // // One file, Apple frameworks only. Network is required for the portrait // and listing photo. Drop it into any iOS 26+ app or Swift Playground. // MARK: - Config /// All values a copy-paster might want to tweak. The rest of the file /// reads from Config, so layout and interaction tuning stay centralized. private enum Config { // MARK: Copy /// Host name shown on both card faces and in the listings heading. static let hostName = "Patrick Mahomes" /// Subtitle under the name on the profile (front) face. static let hostRole = "Host" /// Second line on the verification (back) face. static let verificationDate = "Verified since March 2025" /// Short blurb printed on the verification face itself. static let verificationCardBody = "Trust is the cornerstone of Airbnb's community, and identity verification is part of how we build it." /// Longer explainer shown below the card once it has flipped. static let verificationBody = "Our identity verification process checks a person's information against trusted third-party sources or a government ID. The process has safeguards, but doesn't guarantee that someone is who they say they are." /// Trailing link appended to `verificationBody`. The non-breaking /// space keeps "Learn more" from wrapping mid-phrase at the edge. static let learnMore = "Learn\u{00A0}more" /// Host bio paragraph shown on the profile face's detail list. static let profileBody = "I'm Patrick Mahomes, MVP quarterback and Sunday Funday connoisseur. I've been blessed to win multiple rings and compete on the biggest stage. But real talk, every day is a competition for me. If you love a chill day with sports, friends, and trophies, come hang with me." /// Heading above the listing row. static let listingsTitle = "Patrick Mahomes's listings" /// Title and blurb for the single sample listing. static let listingName = "Modern farmhouse in Belton" static let listingBody = "Texas sunsets, a big yard, and a football-ready game room." /// The "about me" rows on the profile face. The last row is the /// verified marker, which renders underlined. static let facts: [IdentityFact] = [ .init(icon: "graduationcap", text: "Where I went to school: Texas Tech, Wreck 'Em!"), .init(icon: "clock", text: "I spend too much time: Playing golf and watching sports"), .init(icon: "heart", text: "I'm obsessed with: Competition"), .init(icon: "pawprint", text: "Pets: Two dogs, Steel and Silver"), .init(icon: "checkmark.shield", text: "Identity verified", underlined: true) ] // MARK: Photos /// Unsplash photo IDs (the part after `photo-` in any unsplash URL). /// The same portrait drives the avatar, the verification window, and /// its hologram. Swap for a real portrait and listing photo. static let hostPhotoID = "1507003211169-0a1dd7228f2d" static let listingPhotoID = "1505693416388-ac5ce068fe85" // MARK: Theme /// Page and front-face fills. Both white to match the Airbnb sheet. static let pageBackground: Color = .white static let cardFront: Color = .white /// Primary text color (near-black, slightly warm). static let ink: Color = Color(red: 0.11, green: 0.11, blue: 0.13) /// Secondary text color for roles and captions. static let mutedInk: Color = Color(red: 0.45, green: 0.45, blue: 0.48) /// Hairline rule between the bio and the listings section. static let divider: Color = Color.black.opacity(0.10) /// Fill of the small verified checkmark badge on the avatar. static let badgePink: Color = Color(red: 0.90, green: 0.10, blue: 0.38) /// The verification face's diagonal gradient, bottom-left to top-right. static let cardPurple: Color = Color(red: 0.69, green: 0.11, blue: 0.54) static let cardPink: Color = Color(red: 0.88, green: 0.10, blue: 0.48) static let cardOrange: Color = Color(red: 1.00, green: 0.25, blue: 0.22) /// Iridescent foil sheen colors that drift with tilt across the card. static let foilBlue: Color = Color(red: 0.45, green: 0.74, blue: 1.00) static let foilGold: Color = Color(red: 1.00, green: 0.74, blue: 0.24) static let foilViolet: Color = Color(red: 0.62, green: 0.42, blue: 0.96) /// Tint pair for the portrait's holographic "x-ray" wash on tilt. static let xrayLavender: Color = Color(red: 0.62, green: 0.52, blue: 0.95) static let xrayAmber: Color = Color(red: 0.94, green: 0.72, blue: 0.44) // MARK: Layout (points unless noted) /// Caps the sheet width on iPad so the card does not stretch huge. static let contentMaxWidth: CGFloat = 500 /// Side margin around the whole sheet. static let sideInset: CGFloat = 34 /// Gap from the safe area to the top bar. static let topInset: CGFloat = 16 /// Gap from the top bar down to the card. static let cardTopSpacing: CGFloat = 28 /// Gap from the card down to the detail copy. static let detailTopSpacing: CGFloat = 38 /// Card width as a fraction of the content width (1.0 = full width). static let cardWidthFraction: CGFloat = 1.0 /// Card height as a fraction of its width. 0.60 gives a credit-card /// landscape ratio. static let cardHeightFraction: CGFloat = 0.60 /// Corner radius shared by the card and its hit shape. static let cardCornerRadius: CGFloat = 22 /// Circular avatar diameter on the profile face. static let avatarSize: CGFloat = 84 /// Diameter of the verified badge overlapping the avatar. static let badgeSize: CGFloat = 34 /// Side of the square portrait window on the verification face. static let verificationPhotoSize: CGFloat = 88 static let verificationPhotoCornerRadius: CGFloat = 14 /// Text insets from the verification face's leading/top edges. static let verificationCardHorizontalInset: CGFloat = 24 static let verificationCardVerticalInset: CGFloat = 22 /// Inset of the portrait window from the card's bottom-right corner. static let verificationPhotoInset: CGFloat = 20 /// Thumbnail size for the sample listing photo. static let listingImageWidth: CGFloat = 126 static let listingImageHeight: CGFloat = 96 // MARK: Typography (point sizes) static let frontNameSize: CGFloat = 26 static let roleSize: CGFloat = 16 static let factSize: CGFloat = 13.5 static let bodySize: CGFloat = 13.5 static let cardTitleSize: CGFloat = 16 static let cardBodySize: CGFloat = 12.5 static let listingsTitleSize: CGFloat = 16.5 static let listingNameSize: CGFloat = 14 static let listingBodySize: CGFloat = 13 /// Smallest and largest belo (Airbnb logo) glyph in the printed /// spiral field. Glyphs shrink from max at the center to min at the /// outer edge of each spiral arm. static let verificationSpiralMinSize: CGFloat = 3.2 static let verificationSpiralMaxSize: CGFloat = 16 /// Line spacing and trailing inset for the explainer paragraph. static let verificationBodyLineSpacing: CGFloat = 3 static let verificationBodyTrailingInset: CGFloat = 4 // MARK: Motion /// 3D perspective for the flip. Lower values keep it from feeling too /// theatrical (closer to a flat card turning than a dramatic zoom). static let flipPerspective: CGFloat = 0.82 /// How long the Y-axis flip takes, in seconds. static let flipDuration: Double = 0.56 /// Max yaw (left/right) and pitch (up/down) the tilt adds, in degrees. static let maxYawDegrees: Double = 14 static let maxPitchDegrees: Double = 10 /// Extra shadow radius and lift at full tilt, on the verification face. static let maxShadowRadius: CGFloat = 30 static let maxLift: CGFloat = 8 /// How far the printed hologram bands slide across the portrait with /// horizontal tilt, as a fraction of the window width. static let hologramShift: CGFloat = 0.26 // MARK: Flip choreography /// The detail copy below the card fades out this fast before the flip, /// so the two transitions do not overlap. static let detailExitDuration: Double = 0.18 /// The new face's detail copy fades in this fast after the flip lands. static let detailRevealDuration: Double = 0.22 /// Small beat after the flip finishes before revealing the new copy. static let detailRevealGap: Double = 0.03 /// Beat after the copy reveal before the card is interactive again. static let flipSettleGap: Double = 0.22 /// Detail copy slides up this far and blurs while hidden, then settles. static let detailEnterOffset: CGFloat = 18 static let profileBlur: CGFloat = 3 static let verificationBlur: CGFloat = 4 /// Spring that returns the drag-preview tilt to rest in Simulator. static let fallbackSpring: Animation = .spring(duration: 0.42, bounce: 0.16) } // MARK: - IdentityFact /// One row in the profile's "about me" list: an SF Symbol plus a line of /// text. `underlined` is set only on the verified marker row. private struct IdentityFact: Identifiable { let id = UUID() let icon: String let text: String let underlined: Bool init(icon: String, text: String, underlined: Bool = false) { self.icon = icon self.text = text self.underlined = underlined } } // MARK: - Root view /// Airbnb-style identity sheet. A profile card flips on tap to reveal a /// motion-reactive verification card; the detail copy below the card /// hands off in sync with the flip. Core Motion drives the tilt on /// device, a drag stands in on Simulator. struct AirbnbIdentityVerificationCardFlipScanSnippet: View { @StateObject private var motion = DeviceTiltModel() /// Which face is showing, and whether a flip is mid-flight (locks out /// re-entry and tells the card to take taps vs. tilt drags). @State private var isShowingVerification = false @State private var isFlipAnimating = false /// Visibility of the two detail blocks below the card. @State private var showsProfileDetails = true @State private var showsVerificationDetails = false /// Current flip rotation in degrees (0 = profile, 180 = verification). @State private var flipAngle: Double = 0 /// Drag-driven tilt used only when no motion sensor is available. @State private var fallbackTilt: CGSize = .zero /// The in-flight flip sequence, kept so a reverse flip can cancel it. @State private var flipTask: Task<Void, Never>? var body: some View { GeometryReader { proxy in let contentWidth = min(proxy.size.width - Config.sideInset * 2, Config.contentMaxWidth) let cardWidth = Config.cardWidthFraction * contentWidth let cardHeight = cardWidth * Config.cardHeightFraction let activeTilt = currentTilt let tiltStrength = min(1, sqrt(activeTilt.width * activeTilt.width + activeTilt.height * activeTilt.height)) ZStack(alignment: .top) { Config.pageBackground.ignoresSafeArea() VStack(spacing: 0) { topBar .padding(.top, Config.topInset) flippingCard( width: cardWidth, height: cardHeight, tilt: activeTilt, tiltStrength: tiltStrength ) .frame(maxWidth: .infinity) .padding(.top, Config.cardTopSpacing) ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { // Both detail blocks share the slot; only one // is visible at a time, cross-fading with the flip. ZStack(alignment: .topLeading) { ProfileDetailsView() .opacity(showsProfileDetails ? 1 : 0) .offset(y: showsProfileDetails ? 0 : Config.detailEnterOffset) .blur(radius: showsProfileDetails ? 0 : Config.profileBlur) .allowsHitTesting(showsProfileDetails) VerificationDetailsView() .opacity(showsVerificationDetails ? 1 : 0) .offset(y: showsVerificationDetails ? 0 : Config.detailEnterOffset) .blur(radius: showsVerificationDetails ? 0 : Config.verificationBlur) .allowsHitTesting(showsVerificationDetails) } .padding(.top, Config.detailTopSpacing) Spacer(minLength: 36) } } } .frame(width: contentWidth, alignment: .leading) .padding(.horizontal, Config.sideInset) } } .preferredColorScheme(.light) .onAppear { motion.start() } .onDisappear { motion.stop() flipTask?.cancel() } } /// Real device tilt when the sensor is reporting, otherwise the /// drag-preview tilt used in Simulator. private var currentTilt: CGSize { motion.isUsingDeviceMotion ? motion.tilt : fallbackTilt } /// Back arrow (only live on the verification face, flips back to the /// profile) and a close button. private var topBar: some View { HStack { Button { runFlip(toVerification: false) } label: { Image(systemName: "arrow.left") .font(.system(size: 18, weight: .regular)) .foregroundStyle(Config.ink.opacity(isShowingVerification ? 1 : 0)) .frame(width: 28, height: 28) } .disabled(!isShowingVerification) Spacer() Button { // TODO: dismiss the sheet } label: { Image(systemName: "xmark") .font(.system(size: 18, weight: .regular)) .foregroundStyle(Config.ink) .frame(width: 28, height: 28) } .buttonStyle(.plain) } } /// Front/back face swap stays tied to the card rotation, not opacity. @ViewBuilder private func flippingCard(width: CGFloat, height: CGFloat, tilt: CGSize, tiltStrength: CGFloat) -> some View { let showsBackFace = flipAngle >= 90 let cardFace = ZStack { if !showsBackFace { ProfileIdentityCard() } else { VerificationIdentityCard(tilt: tilt, tiltStrength: tiltStrength) .rotation3DEffect( .degrees(180), axis: (x: 0, y: 1, z: 0), perspective: Config.flipPerspective ) } } .frame(width: width, height: height) .contentShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) .rotation3DEffect( .degrees(flipAngle), axis: (x: 0, y: 1, z: 0), perspective: Config.flipPerspective ) .rotation3DEffect( .degrees(-Double(tilt.height) * Config.maxPitchDegrees), axis: (x: 1, y: 0, z: 0), perspective: 0.85 ) .rotation3DEffect( .degrees(Double(tilt.width) * Config.maxYawDegrees), axis: (x: 0, y: 1, z: 0), perspective: 0.85 ) .offset(y: -tiltStrength * Config.maxLift) .shadow( color: .black.opacity(isShowingVerification ? 0.16 + tiltStrength * 0.10 : 0.09), radius: isShowingVerification ? 18 + tiltStrength * Config.maxShadowRadius : 18, y: isShowingVerification ? 14 + tiltStrength * 12 : 12 ) .accessibilityAddTraits(.isButton) .background { if !showsBackFace { RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill(Color.black.opacity(0.001)) } } if isShowingVerification && !isFlipAnimating { cardFace .gesture(tiltPreviewGesture(width: width, height: height)) } else { cardFace .highPriorityGesture( TapGesture() .onEnded { runFlip(toVerification: true) } ) } } /// Runs one flip in either direction. The choreography is symmetric: /// the outgoing face's detail copy fades out first so it does not /// overlap the flip, the underlying face swaps at the halfway point /// (when the card is edge-on), and the incoming copy fades in once the /// flip lands. Driven by `Task.sleep` rather than dispatched delays so /// a reverse flip can cancel an in-flight one. private func runFlip(toVerification: Bool) { // Ignore taps that would flip toward the face already showing, or // that land mid-flip. guard isShowingVerification != toVerification, !isFlipAnimating else { return } flipTask?.cancel() flipTask = Task { @MainActor in isFlipAnimating = true withAnimation(.easeInOut(duration: Config.detailExitDuration)) { if toVerification { showsProfileDetails = false } else { showsVerificationDetails = false } } withAnimation(.smooth(duration: Config.flipDuration)) { flipAngle = toVerification ? 180 : 0 } // Swap the live state at the midpoint, when the card is edge-on // and neither face is readable. try? await Task.sleep(for: .seconds(Config.flipDuration / 2)) guard !Task.isCancelled else { return } isShowingVerification = toVerification // Let the flip finish, then bring in the new face's copy. try? await Task.sleep(for: .seconds(Config.flipDuration / 2 + Config.detailRevealGap)) guard !Task.isCancelled else { return } withAnimation(.easeOut(duration: Config.detailRevealDuration)) { if toVerification { showsVerificationDetails = true } else { showsProfileDetails = true } } // Hold the lock a beat past the reveal so a stray tap during // the settle does not start another flip. try? await Task.sleep(for: .seconds(Config.flipSettleGap)) guard !Task.isCancelled else { return } isFlipAnimating = false } } /// Drag previews the motion-driven tilt on Simulator. private func tiltPreviewGesture(width: CGFloat, height: CGFloat) -> some Gesture { DragGesture(minimumDistance: 0) .onChanged { value in guard !motion.isUsingDeviceMotion, isShowingVerification else { return } let x = ((value.location.x / width) - 0.5) * 2 let y = ((value.location.y / height) - 0.5) * 2 fallbackTilt = CGSize( width: x.clamped(to: -1...1), height: y.clamped(to: -1...1) ) } .onEnded { _ in guard !motion.isUsingDeviceMotion else { return } withAnimation(Config.fallbackSpring) { fallbackTilt = .zero } } } } // MARK: - Profile card /// The card's front (profile) face: avatar with a verified badge, name, /// and role on a plain white surface. private struct ProfileIdentityCard: View { var body: some View { VStack(spacing: 12) { ZStack(alignment: .bottomTrailing) { RemoteImage(photoID: Config.hostPhotoID, style: .portrait) .frame(width: Config.avatarSize, height: Config.avatarSize) .clipShape(Circle()) Circle() .fill(Config.badgePink) .frame(width: Config.badgeSize, height: Config.badgeSize) .overlay { Image(systemName: "checkmark.shield.fill") .font(.system(size: 14, weight: .bold)) .foregroundStyle(.white) } .offset(x: 4, y: 2) } Text(Config.hostName) .font(.system(size: Config.frontNameSize, weight: .semibold)) .foregroundStyle(Config.ink) Text(Config.hostRole) .font(.system(size: Config.roleSize, weight: .regular)) .foregroundStyle(Config.mutedInk) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.vertical, 24) .background( RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill(Config.cardFront) ) } } // MARK: - Verification card /// The card's back (verification) face: a gradient surface printed with a /// field of tiny logos, the name and verified date, a short blurb, the /// holographic portrait window, and a tilt-reactive foil sheen on top. private struct VerificationIdentityCard: View { let tilt: CGSize let tiltStrength: CGFloat var body: some View { GeometryReader { proxy in let size = proxy.size ZStack { RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill( LinearGradient( colors: [Config.cardPurple, Config.cardPink, Config.cardOrange], startPoint: .bottomLeading, endPoint: .topTrailing ) ) VerificationPattern(tilt: tilt) .clipShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) VStack(alignment: .leading, spacing: 6) { Text(Config.hostName) .font(.system(size: Config.cardTitleSize, weight: .semibold)) .foregroundStyle(.white) Text(Config.verificationDate) .font(.system(size: Config.cardTitleSize, weight: .medium)) .foregroundStyle(.white.opacity(0.92)) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.leading, Config.verificationCardHorizontalInset) .padding(.top, Config.verificationCardVerticalInset) Text(Config.verificationCardBody) .font(.system(size: Config.cardBodySize, weight: .regular)) .foregroundStyle(.white.opacity(0.88)) .lineSpacing(2) .frame(width: size.width * 0.52, alignment: .leading) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) .padding(.leading, Config.verificationCardHorizontalInset) .padding(.bottom, Config.verificationCardVerticalInset) VerificationPhotoWindow(tilt: tilt, tiltStrength: tiltStrength) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) .padding(.trailing, Config.verificationPhotoInset) .padding(.bottom, Config.verificationPhotoInset) FoilOverlay(tilt: tilt, size: size) .blendMode(.screen) } .clipShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) } } } // MARK: - Verification photo /// The small portrait window on the verification face. At rest it is a /// plain photo; as the card tilts left/right an inverted copy and a stack /// of tinted gradients fade in to read as a holographic security image. private struct VerificationPhotoWindow: View { let tilt: CGSize let tiltStrength: CGFloat var body: some View { // Strength and band positions are driven by horizontal tilt only; // the hologram peaks at full left/right and vanishes at center. let horizontalTilt = tilt.width.clamped(to: -1...1) let hologramStrength = abs(horizontalTilt) let hologramStartX = 0.14 + horizontalTilt * Config.hologramShift let hologramEndX = 0.86 - horizontalTilt * Config.hologramShift CachedRemotePhoto(photoID: Config.hostPhotoID) { image in photoLayers( base: image, hologramStrength: hologramStrength, horizontalTilt: horizontalTilt, hologramStartX: hologramStartX, hologramEndX: hologramEndX ) } fallback: { verificationFallback } .frame(width: Config.verificationPhotoSize, height: Config.verificationPhotoSize) .clipShape(RoundedRectangle(cornerRadius: Config.verificationPhotoCornerRadius, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: Config.verificationPhotoCornerRadius, style: .continuous) .stroke(Color.white.opacity(0.12 + tiltStrength * 0.08), lineWidth: 1) } } /// Stacks the base photo with the tilt-driven hologram layers: an /// inverted ghost, a diagonal color wash, a moving white streak, a /// soft-light cross gradient, and a bright spot. Every overlay's /// opacity tracks `hologramStrength`, so all of it disappears at rest. @ViewBuilder private func photoLayers( base image: Image, hologramStrength: CGFloat, horizontalTilt: CGFloat, hologramStartX: CGFloat, hologramEndX: CGFloat ) -> some View { ZStack { image .resizable() .scaledToFill() .saturation(1) .contrast(1.02) image .resizable() .scaledToFill() .grayscale(1) .contrast(1.18) .brightness(-0.03) .colorInvert() .opacity(hologramStrength) LinearGradient( colors: [ Config.xrayLavender.opacity(0.18 + hologramStrength * 0.68), Config.xrayAmber.opacity(0.14 + hologramStrength * 0.64), Config.foilBlue.opacity(0.10 + hologramStrength * 0.54) ], startPoint: UnitPoint(x: hologramStartX, y: 0.14), endPoint: UnitPoint(x: hologramEndX, y: 0.88) ) .blendMode(.color) .opacity(hologramStrength) LinearGradient( colors: [ .clear, Color.white.opacity(0.06 + hologramStrength * 0.28), .clear ], startPoint: UnitPoint(x: 0.18 + horizontalTilt * 0.10, y: 0), endPoint: UnitPoint(x: 0.82 + horizontalTilt * 0.22, y: 1) ) .blendMode(.overlay) .opacity(hologramStrength) LinearGradient( colors: [ Config.xrayAmber.opacity(0.04 + hologramStrength * 0.20), .clear, Config.xrayLavender.opacity(0.06 + hologramStrength * 0.24) ], startPoint: UnitPoint(x: 0.10 + horizontalTilt * 0.08, y: 0.10), endPoint: UnitPoint(x: 0.90 - horizontalTilt * 0.08, y: 0.92) ) .blendMode(.softLight) .opacity(hologramStrength) RadialGradient( colors: [ Color.white.opacity(0.04 + hologramStrength * 0.14), .clear ], center: UnitPoint(x: 0.50 + horizontalTilt * 0.18, y: 0.46), startRadius: 4, endRadius: Config.verificationPhotoSize * 0.72 ) .blendMode(.screen) .opacity(hologramStrength) } } /// Shown while the portrait is loading or if it fails: a soft frosted /// rectangle so the window never reads as empty. private var verificationFallback: some View { LinearGradient( colors: [ Color.white.opacity(0.28), Color.white.opacity(0.14) ], startPoint: .topLeading, endPoint: .bottomTrailing ) .overlay { RoundedRectangle(cornerRadius: Config.verificationPhotoCornerRadius, style: .continuous) .fill(Color.black.opacity(0.04)) } } } // MARK: - Foil overlay /// The iridescent sheen drawn over the whole verification face. A blurred /// diagonal gradient plus a radial highlight, both anchored to tilt so the /// shine slides as the card moves. Screen-blended and non-interactive. private struct FoilOverlay: View { let tilt: CGSize let size: CGSize var body: some View { ZStack { LinearGradient( colors: [ Color.white.opacity(0.02), Config.foilBlue.opacity(0.08), Config.foilViolet.opacity(0.10), Config.foilGold.opacity(0.08), Color.white.opacity(0.04) ], startPoint: UnitPoint(x: 0.08 + tilt.width * 0.24, y: 0.12 + tilt.height * 0.18), endPoint: UnitPoint(x: 0.92 - tilt.width * 0.24, y: 0.90 - tilt.height * 0.18) ) .blur(radius: 14) RadialGradient( colors: [ Color.white.opacity(0.12), Config.foilGold.opacity(0.08), .clear ], center: UnitPoint(x: 0.45 + tilt.width * 0.20, y: 0.38 + tilt.height * 0.14), startRadius: 8, endRadius: max(size.width, size.height) * 0.55 ) .blendMode(.screen) } .allowsHitTesting(false) } } // MARK: - Profile details /// Content below the profile face: the "about me" fact rows, the bio /// paragraph, a divider, and one sample listing. private struct ProfileDetailsView: View { var body: some View { VStack(alignment: .leading, spacing: 18) { ForEach(Config.facts) { fact in HStack(alignment: .center, spacing: 12) { Image(systemName: fact.icon) .font(.system(size: 18, weight: .regular)) .foregroundStyle(Config.ink) .frame(width: 22) if fact.underlined { Text(fact.text) .font(.system(size: Config.factSize, weight: .regular)) .foregroundStyle(Config.ink) .underline() } else { Text(fact.text) .font(.system(size: Config.factSize, weight: .regular)) .foregroundStyle(Config.ink) .fixedSize(horizontal: false, vertical: true) } } } Text(Config.profileBody) .font(.system(size: Config.bodySize, weight: .regular)) .foregroundStyle(Config.ink) .lineSpacing(3) .padding(.top, 8) Rectangle() .fill(Config.divider) .frame(height: 1) .padding(.top, 6) Text(Config.listingsTitle) .font(.system(size: Config.listingsTitleSize, weight: .semibold)) .foregroundStyle(Config.ink) .padding(.top, 4) HStack(spacing: 12) { RemoteImage(photoID: Config.listingPhotoID, style: .listing) .frame(width: Config.listingImageWidth, height: Config.listingImageHeight) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) VStack(alignment: .leading, spacing: 6) { Text(Config.listingName) .font(.system(size: Config.listingNameSize, weight: .semibold)) .foregroundStyle(Config.ink) Text(Config.listingBody) .font(.system(size: Config.listingBodySize, weight: .regular)) .foregroundStyle(Config.mutedInk) .lineSpacing(2) } } } } } // MARK: - Verification details /// Explainer paragraph shown below the card after the flip, ending in an /// underlined "Learn more" link. private struct VerificationDetailsView: View { var body: some View { // Combine the plain body with the underlined link using Text string // interpolation, the supported replacement for the deprecated // `Text + Text` and for AttributedString.underlineStyle (whose key // path is not concurrency-safe under Swift 6). let lead = Text(Config.verificationBody + " ") let link = Text(Config.learnMore).underline() Text("\(lead)\(link)") .font(.system(size: Config.factSize, weight: .regular)) .foregroundStyle(Config.ink) .lineSpacing(Config.verificationBodyLineSpacing) .fixedSize(horizontal: false, vertical: true) .padding(.trailing, Config.verificationBodyTrailingInset) } } // MARK: - Verification pattern /// The field of tiny Airbnb belo logos printed across the verification /// face, arranged as four nested spirals. With tilt the field shifts from /// dark ink (tilted left) to light ink (tilted right), like a printed /// security pattern catching the light. private struct VerificationPattern: View { let tilt: CGSize /// The printed field reads a touch low of true center once the card /// tilts in perspective; used for the highlight that tracks the spiral. private let spiralCenter = CGPoint(x: 0.50, y: 0.54) /// Four spiral arms from widest/densest to tightest/faintest. Each one /// shares the same center and winds the same direction; they differ in /// radius, glyph count, glyph scale, and opacity. private let spiralSpecs: [VerificationSpiralSpec] = [ .init(center: CGPoint(x: 0.50, y: 0.54), turns: 2.9, maxRadius: 0.50, count: 58, rotation: -.pi * 0.02, sizeScale: 1.00, opacity: 1.00, clockwise: true), .init(center: CGPoint(x: 0.50, y: 0.54), turns: 2.55, maxRadius: 0.38, count: 46, rotation: .pi * 0.06, sizeScale: 0.82, opacity: 0.86, clockwise: true), .init(center: CGPoint(x: 0.50, y: 0.54), turns: 2.15, maxRadius: 0.28, count: 34, rotation: .pi * 0.12, sizeScale: 0.66, opacity: 0.72, clockwise: true), .init(center: CGPoint(x: 0.50, y: 0.54), turns: 1.75, maxRadius: 0.20, count: 24, rotation: .pi * 0.18, sizeScale: 0.54, opacity: 0.58, clockwise: true) ] var body: some View { GeometryReader { proxy in let size = proxy.size let horizontalTilt = tilt.width.clamped(to: -1...1) // A small dead zone keeps the pattern neutral when the card is // near level, so it only "inks up" once you tilt with intent. let deadZone: CGFloat = 0.14 let leftStrength = max(0, -horizontalTilt - deadZone) / (1 - deadZone) let rightStrength = max(0, horizontalTilt - deadZone) / (1 - deadZone) let spiralStrength = max(leftStrength, rightStrength) // Tilt left inks the logos dark (multiply), tilt right inks them // light (screen); both are drawn so the field flips polarity. let leftInk = Color.black.opacity(leftStrength * 0.62) let rightInk = Color.white.opacity(rightStrength * 0.64) ZStack { spiralLayer(specs: spiralSpecs, size: size, horizontalTilt: horizontalTilt, strokeStyle: leftInk) .blendMode(.multiply) spiralLayer(specs: spiralSpecs, size: size, horizontalTilt: horizontalTilt, strokeStyle: rightInk) .blendMode(.screen) RadialGradient( colors: [ Color.white.opacity(spiralStrength * 0.10), .clear ], center: UnitPoint(x: spiralCenter.x, y: spiralCenter.y), startRadius: 10, endRadius: size.width * 0.38 ) .blendMode(.screen) } } } /// Draws all four spiral arms in one ink color. Called twice (dark and /// light) so the field can carry both polarities at once. @ViewBuilder private func spiralLayer<StrokeStyle: ShapeStyle>( specs: [VerificationSpiralSpec], size: CGSize, horizontalTilt: CGFloat, strokeStyle: StrokeStyle ) -> some View { ForEach(Array(specs.enumerated()), id: \.offset) { _, spec in SpiralLogoField( spec: spec, canvasSize: size, strokeStyle: strokeStyle, tilt: horizontalTilt, minWidth: Config.verificationSpiralMinSize * spec.sizeScale, maxWidth: Config.verificationSpiralMaxSize * spec.sizeScale ) .opacity(spec.opacity) } } } /// One spiral arm of belo logos. Places `spec.count` glyphs along an /// Archimedean-style spiral, shrinking them from `maxWidth` at the center /// to `minWidth` at the outer end and rotating each to face along the arm. private struct SpiralLogoField<StrokeStyle: ShapeStyle>: View { let spec: VerificationSpiralSpec let canvasSize: CGSize let strokeStyle: StrokeStyle let tilt: CGFloat let minWidth: CGFloat let maxWidth: CGFloat var body: some View { ZStack { ForEach(0..<spec.count, id: \.self) { index in let progress = CGFloat(index) / CGFloat(max(spec.count - 1, 1)) let spiralProgress = 1 - progress let angleDirection: CGFloat = spec.clockwise ? 1 : -1 let angle = spec.rotation + angleDirection * progress * spec.turns * .pi * 2 let radius = canvasSize.width * spec.maxRadius * pow(spiralProgress, 0.86) let center = CGPoint( x: canvasSize.width * spec.center.x, y: canvasSize.height * spec.center.y ) let x = center.x + cos(angle) * radius let y = center.y + sin(angle) * radius let width = maxWidth - progress * (maxWidth - minWidth) let height = width * 1.34 let rotation = Angle(radians: Double(angle + .pi / 2 + tilt * 0.08)) AirbnbBeloShape() .stroke(strokeStyle, lineWidth: 1.05) .frame(width: width, height: height) .opacity(0.90 - progress * 0.26) .rotationEffect(rotation) .position(x: x, y: y) } } } } /// Parameters for one spiral arm in the logo field. private struct VerificationSpiralSpec { /// Arm center in unit coordinates (0...1 of the canvas). let center: CGPoint /// Number of full turns from center to outer end. let turns: CGFloat /// Outer radius as a fraction of the canvas width. let maxRadius: CGFloat /// How many glyphs to place along the arm. let count: Int /// Starting angle offset, in radians, so the arms do not all align. let rotation: CGFloat /// Per-arm glyph size multiplier (inner arms run smaller). let sizeScale: CGFloat /// Per-arm opacity (inner arms run fainter). let opacity: CGFloat /// Winding direction. let clockwise: Bool } /// The Airbnb "belo" mark as a stroked `Shape`: the looping outline plus /// the small inner pin, drawn in a unit rect so it scales to any size. private struct AirbnbBeloShape: Shape { func path(in rect: CGRect) -> Path { var path = Path() let width = rect.width let height = rect.height func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: rect.minX + x * width, y: rect.minY + y * height) } path.move(to: point(0.50, 0.95)) path.addCurve( to: point(0.24, 0.43), control1: point(0.32, 0.82), control2: point(0.18, 0.63) ) path.addCurve( to: point(0.50, 0.10), control1: point(0.24, 0.22), control2: point(0.40, 0.07) ) path.addCurve( to: point(0.76, 0.43), control1: point(0.60, 0.07), control2: point(0.76, 0.22) ) path.addCurve( to: point(0.50, 0.95), control1: point(0.82, 0.63), control2: point(0.68, 0.82) ) path.move(to: point(0.50, 0.61)) path.addCurve( to: point(0.39, 0.49), control1: point(0.45, 0.61), control2: point(0.39, 0.56) ) path.addCurve( to: point(0.50, 0.33), control1: point(0.39, 0.41), control2: point(0.44, 0.33) ) path.addCurve( to: point(0.61, 0.49), control1: point(0.56, 0.33), control2: point(0.61, 0.41) ) path.addCurve( to: point(0.50, 0.61), control1: point(0.61, 0.56), control2: point(0.55, 0.61) ) return path } } // MARK: - Device tilt model /// Publishes a normalized tilt (each axis in -1...1) from Core Motion. /// `isUsingDeviceMotion` stays false until the first real sample arrives, /// which is how the view knows to fall back to drag-preview tilt in /// Simulator. All published changes are hopped back to the main actor. @MainActor private final class DeviceTiltModel: ObservableObject { @Published private(set) var tilt: CGSize = .zero @Published private(set) var isUsingDeviceMotion = false private let motionManager = CMMotionManager() private let motionQueue: OperationQueue = { let queue = OperationQueue() queue.name = "AirbnbIdentityVerificationCardFlipScanSnippet.DeviceMotion" return queue }() /// Begins motion updates if a sensor is present. No-op on Simulator, /// which leaves `isUsingDeviceMotion` false so the drag fallback runs. func start() { guard motionManager.isDeviceMotionAvailable else { return } // Prefer the drift-corrected frame when available for a steadier // resting attitude. let frames = CMMotionManager.availableAttitudeReferenceFrames() let referenceFrame: CMAttitudeReferenceFrame if frames.contains(.xArbitraryCorrectedZVertical) { referenceFrame = .xArbitraryCorrectedZVertical } else { referenceFrame = .xArbitraryZVertical } motionManager.deviceMotionUpdateInterval = 1 / 60 motionManager.startDeviceMotionUpdates(using: referenceFrame, to: motionQueue) { [weak self] motion, _ in guard let self, let motion else { return } // Normalize to -1...1 over a comfortable wrist range: +-45deg of // roll and +-36deg of pitch reach full tilt. let roll = (motion.attitude.roll / (.pi / 4)).clamped(to: -1...1) let pitch = (motion.attitude.pitch / (.pi / 5)).clamped(to: -1...1) Task { @MainActor in self.tilt = CGSize(width: roll, height: pitch) self.isUsingDeviceMotion = true } } } /// Stops updates and resets to a neutral, sensorless state. func stop() { motionManager.stopDeviceMotionUpdates() isUsingDeviceMotion = false tilt = .zero } } // MARK: - Remote image /// A cached remote photo filling its frame, with a style-specific /// placeholder shown while loading or on failure so a tile is never blank. private struct RemoteImage: View { let photoID: String let style: RemoteImageStyle var body: some View { CachedRemotePhoto(photoID: photoID) { image in image .resizable() .scaledToFill() } fallback: { fallbackView } } /// A simple gradient skeleton: a head-and-shoulders silhouette for the /// portrait, a card-like block layout for the listing. private var fallbackView: some View { ZStack { switch style { case .portrait: LinearGradient( colors: [Color(red: 0.83, green: 0.89, blue: 0.97), Color(red: 0.62, green: 0.74, blue: 0.88)], startPoint: .top, endPoint: .bottom ) VStack(spacing: 0) { Circle() .fill(Color.white.opacity(0.70)) .frame(width: 34, height: 34) RoundedRectangle(cornerRadius: 22, style: .continuous) .fill(Color.white.opacity(0.62)) .frame(width: 62, height: 44) .offset(y: -6) } .offset(y: 10) case .listing: LinearGradient( colors: [Color(red: 0.85, green: 0.92, blue: 0.82), Color(red: 0.70, green: 0.80, blue: 0.66)], startPoint: .topLeading, endPoint: .bottomTrailing ) VStack(spacing: 10) { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color.white.opacity(0.78)) .frame(height: 28) .padding(.horizontal, 16) HStack(spacing: 10) { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color.white.opacity(0.74)) RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color.white.opacity(0.54)) } .frame(height: 32) .padding(.horizontal, 16) } } } } } /// Picks which placeholder skeleton `RemoteImage` draws. private enum RemoteImageStyle { case portrait case listing } /// Renders a remote photo once it has loaded, otherwise its fallback. /// Backed by `RemoteImageLoader`, so the image survives view recomposition /// (every tilt frame) without dropping back to a loading state. private struct CachedRemotePhoto<Content: View, Fallback: View>: View { @StateObject private var loader: RemoteImageLoader private let content: (Image) -> Content private let fallback: Fallback init( photoID: String, @ViewBuilder content: @escaping (Image) -> Content, @ViewBuilder fallback: () -> Fallback ) { _loader = StateObject(wrappedValue: RemoteImageLoader(urlString: Self.unsplash(photoID))) self.content = content self.fallback = fallback() } var body: some View { Group { if let image = loader.image { content(Image(uiImage: image)) } else { fallback } } } /// Shared Unsplash URL builder so the cached loader and the rest of /// the file resolve the exact same remote asset. private static func unsplash(_ id: String) -> String { "https://images.unsplash.com/photo-\(id)?w=800&h=800&fit=crop&crop=faces&auto=format&q=80" } } /// Loads one image URL and publishes the result, backed by a process-wide /// `NSCache`. A second view asking for the same URL gets the cached image /// immediately, so flipping and tilting never re-fetch the portrait. @MainActor private final class RemoteImageLoader: ObservableObject { private static let cache = NSCache<NSString, UIImage>() @Published private(set) var image: UIImage? private let urlString: String private var task: Task<Void, Never>? init(urlString: String) { self.urlString = urlString if let cached = Self.cache.object(forKey: urlString as NSString) { image = cached } else { load() } } deinit { task?.cancel() } /// Fetches the image off the main actor and stores it in the shared /// cache before publishing. Skips work if the task was cancelled. private func load() { guard let url = URL(string: urlString) else { return } task = Task { guard let (data, _) = try? await URLSession.shared.data(from: url), !Task.isCancelled, let image = UIImage(data: data) else { return } Self.cache.setObject(image, forKey: urlString as NSString) self.image = image } } } // MARK: - Helpers private extension CGFloat { /// Constrains the value to `range`. Used to keep tilt and gradient /// positions inside their expected bounds. func clamped(to range: ClosedRange<CGFloat>) -> CGFloat { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } #Preview { AirbnbIdentityVerificationCardFlipScanSnippet() }
import SwiftUI import CoreMotion import UIKit // AirbnbIdentityVerificationCardFlipScanSnippet // // An Airbnb-style identity sheet with a tap-to-flip profile card and a // motion-reactive verification card. Tapping the host card flips it on // the Y axis; once flipped, Core Motion drives the tilt, the portrait // hologram, and the printed logo-field color shift. In Simulator, where // there is no real motion sensor, dragging the verification card stands // in for device tilt. // // The portrait loads once through a small cached image loader (not // AsyncImage) so the photo stays resident while the card recomposes on // every tilt frame, instead of flickering back to a loading state. // // HOW TO CUSTOMIZE: everything tweakable lives in Config below: copy, // photo IDs, colors, layout, typography, and the flip/tilt motion. // // One file, Apple frameworks only. Network is required for the portrait // and listing photo. Drop it into any iOS 26+ app or Swift Playground. // MARK: - Config /// All values a copy-paster might want to tweak. The rest of the file /// reads from Config, so layout and interaction tuning stay centralized. private enum Config { // MARK: Copy /// Host name shown on both card faces and in the listings heading. static let hostName = "Patrick Mahomes" /// Subtitle under the name on the profile (front) face. static let hostRole = "Host" /// Second line on the verification (back) face. static let verificationDate = "Verified since March 2025" /// Short blurb printed on the verification face itself. static let verificationCardBody = "Trust is the cornerstone of Airbnb's community, and identity verification is part of how we build it." /// Longer explainer shown below the card once it has flipped. static let verificationBody = "Our identity verification process checks a person's information against trusted third-party sources or a government ID. The process has safeguards, but doesn't guarantee that someone is who they say they are." /// Trailing link appended to `verificationBody`. The non-breaking /// space keeps "Learn more" from wrapping mid-phrase at the edge. static let learnMore = "Learn\u{00A0}more" /// Host bio paragraph shown on the profile face's detail list. static let profileBody = "I'm Patrick Mahomes, MVP quarterback and Sunday Funday connoisseur. I've been blessed to win multiple rings and compete on the biggest stage. But real talk, every day is a competition for me. If you love a chill day with sports, friends, and trophies, come hang with me." /// Heading above the listing row. static let listingsTitle = "Patrick Mahomes's listings" /// Title and blurb for the single sample listing. static let listingName = "Modern farmhouse in Belton" static let listingBody = "Texas sunsets, a big yard, and a football-ready game room." /// The "about me" rows on the profile face. The last row is the /// verified marker, which renders underlined. static let facts: [IdentityFact] = [ .init(icon: "graduationcap", text: "Where I went to school: Texas Tech, Wreck 'Em!"), .init(icon: "clock", text: "I spend too much time: Playing golf and watching sports"), .init(icon: "heart", text: "I'm obsessed with: Competition"), .init(icon: "pawprint", text: "Pets: Two dogs, Steel and Silver"), .init(icon: "checkmark.shield", text: "Identity verified", underlined: true) ] // MARK: Photos /// Unsplash photo IDs (the part after `photo-` in any unsplash URL). /// The same portrait drives the avatar, the verification window, and /// its hologram. Swap for a real portrait and listing photo. static let hostPhotoID = "1507003211169-0a1dd7228f2d" static let listingPhotoID = "1505693416388-ac5ce068fe85" // MARK: Theme /// Page and front-face fills. Both white to match the Airbnb sheet. static let pageBackground: Color = .white static let cardFront: Color = .white /// Primary text color (near-black, slightly warm). static let ink: Color = Color(red: 0.11, green: 0.11, blue: 0.13) /// Secondary text color for roles and captions. static let mutedInk: Color = Color(red: 0.45, green: 0.45, blue: 0.48) /// Hairline rule between the bio and the listings section. static let divider: Color = Color.black.opacity(0.10) /// Fill of the small verified checkmark badge on the avatar. static let badgePink: Color = Color(red: 0.90, green: 0.10, blue: 0.38) /// The verification face's diagonal gradient, bottom-left to top-right. static let cardPurple: Color = Color(red: 0.69, green: 0.11, blue: 0.54) static let cardPink: Color = Color(red: 0.88, green: 0.10, blue: 0.48) static let cardOrange: Color = Color(red: 1.00, green: 0.25, blue: 0.22) /// Iridescent foil sheen colors that drift with tilt across the card. static let foilBlue: Color = Color(red: 0.45, green: 0.74, blue: 1.00) static let foilGold: Color = Color(red: 1.00, green: 0.74, blue: 0.24) static let foilViolet: Color = Color(red: 0.62, green: 0.42, blue: 0.96) /// Tint pair for the portrait's holographic "x-ray" wash on tilt. static let xrayLavender: Color = Color(red: 0.62, green: 0.52, blue: 0.95) static let xrayAmber: Color = Color(red: 0.94, green: 0.72, blue: 0.44) // MARK: Layout (points unless noted) /// Caps the sheet width on iPad so the card does not stretch huge. static let contentMaxWidth: CGFloat = 500 /// Side margin around the whole sheet. static let sideInset: CGFloat = 34 /// Gap from the safe area to the top bar. static let topInset: CGFloat = 16 /// Gap from the top bar down to the card. static let cardTopSpacing: CGFloat = 28 /// Gap from the card down to the detail copy. static let detailTopSpacing: CGFloat = 38 /// Card width as a fraction of the content width (1.0 = full width). static let cardWidthFraction: CGFloat = 1.0 /// Card height as a fraction of its width. 0.60 gives a credit-card /// landscape ratio. static let cardHeightFraction: CGFloat = 0.60 /// Corner radius shared by the card and its hit shape. static let cardCornerRadius: CGFloat = 22 /// Circular avatar diameter on the profile face. static let avatarSize: CGFloat = 84 /// Diameter of the verified badge overlapping the avatar. static let badgeSize: CGFloat = 34 /// Side of the square portrait window on the verification face. static let verificationPhotoSize: CGFloat = 88 static let verificationPhotoCornerRadius: CGFloat = 14 /// Text insets from the verification face's leading/top edges. static let verificationCardHorizontalInset: CGFloat = 24 static let verificationCardVerticalInset: CGFloat = 22 /// Inset of the portrait window from the card's bottom-right corner. static let verificationPhotoInset: CGFloat = 20 /// Thumbnail size for the sample listing photo. static let listingImageWidth: CGFloat = 126 static let listingImageHeight: CGFloat = 96 // MARK: Typography (point sizes) static let frontNameSize: CGFloat = 26 static let roleSize: CGFloat = 16 static let factSize: CGFloat = 13.5 static let bodySize: CGFloat = 13.5 static let cardTitleSize: CGFloat = 16 static let cardBodySize: CGFloat = 12.5 static let listingsTitleSize: CGFloat = 16.5 static let listingNameSize: CGFloat = 14 static let listingBodySize: CGFloat = 13 /// Smallest and largest belo (Airbnb logo) glyph in the printed /// spiral field. Glyphs shrink from max at the center to min at the /// outer edge of each spiral arm. static let verificationSpiralMinSize: CGFloat = 3.2 static let verificationSpiralMaxSize: CGFloat = 16 /// Line spacing and trailing inset for the explainer paragraph. static let verificationBodyLineSpacing: CGFloat = 3 static let verificationBodyTrailingInset: CGFloat = 4 // MARK: Motion /// 3D perspective for the flip. Lower values keep it from feeling too /// theatrical (closer to a flat card turning than a dramatic zoom). static let flipPerspective: CGFloat = 0.82 /// How long the Y-axis flip takes, in seconds. static let flipDuration: Double = 0.56 /// Max yaw (left/right) and pitch (up/down) the tilt adds, in degrees. static let maxYawDegrees: Double = 14 static let maxPitchDegrees: Double = 10 /// Extra shadow radius and lift at full tilt, on the verification face. static let maxShadowRadius: CGFloat = 30 static let maxLift: CGFloat = 8 /// How far the printed hologram bands slide across the portrait with /// horizontal tilt, as a fraction of the window width. static let hologramShift: CGFloat = 0.26 // MARK: Flip choreography /// The detail copy below the card fades out this fast before the flip, /// so the two transitions do not overlap. static let detailExitDuration: Double = 0.18 /// The new face's detail copy fades in this fast after the flip lands. static let detailRevealDuration: Double = 0.22 /// Small beat after the flip finishes before revealing the new copy. static let detailRevealGap: Double = 0.03 /// Beat after the copy reveal before the card is interactive again. static let flipSettleGap: Double = 0.22 /// Detail copy slides up this far and blurs while hidden, then settles. static let detailEnterOffset: CGFloat = 18 static let profileBlur: CGFloat = 3 static let verificationBlur: CGFloat = 4 /// Spring that returns the drag-preview tilt to rest in Simulator. static let fallbackSpring: Animation = .spring(duration: 0.42, bounce: 0.16) } // MARK: - IdentityFact /// One row in the profile's "about me" list: an SF Symbol plus a line of /// text. `underlined` is set only on the verified marker row. private struct IdentityFact: Identifiable { let id = UUID() let icon: String let text: String let underlined: Bool init(icon: String, text: String, underlined: Bool = false) { self.icon = icon self.text = text self.underlined = underlined } } // MARK: - Root view /// Airbnb-style identity sheet. A profile card flips on tap to reveal a /// motion-reactive verification card; the detail copy below the card /// hands off in sync with the flip. Core Motion drives the tilt on /// device, a drag stands in on Simulator. struct AirbnbIdentityVerificationCardFlipScanSnippet: View { @StateObject private var motion = DeviceTiltModel() /// Which face is showing, and whether a flip is mid-flight (locks out /// re-entry and tells the card to take taps vs. tilt drags). @State private var isShowingVerification = false @State private var isFlipAnimating = false /// Visibility of the two detail blocks below the card. @State private var showsProfileDetails = true @State private var showsVerificationDetails = false /// Current flip rotation in degrees (0 = profile, 180 = verification). @State private var flipAngle: Double = 0 /// Drag-driven tilt used only when no motion sensor is available. @State private var fallbackTilt: CGSize = .zero /// The in-flight flip sequence, kept so a reverse flip can cancel it. @State private var flipTask: Task<Void, Never>? var body: some View { GeometryReader { proxy in let contentWidth = min(proxy.size.width - Config.sideInset * 2, Config.contentMaxWidth) let cardWidth = Config.cardWidthFraction * contentWidth let cardHeight = cardWidth * Config.cardHeightFraction let activeTilt = currentTilt let tiltStrength = min(1, sqrt(activeTilt.width * activeTilt.width + activeTilt.height * activeTilt.height)) ZStack(alignment: .top) { Config.pageBackground.ignoresSafeArea() VStack(spacing: 0) { topBar .padding(.top, Config.topInset) flippingCard( width: cardWidth, height: cardHeight, tilt: activeTilt, tiltStrength: tiltStrength ) .frame(maxWidth: .infinity) .padding(.top, Config.cardTopSpacing) ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { // Both detail blocks share the slot; only one // is visible at a time, cross-fading with the flip. ZStack(alignment: .topLeading) { ProfileDetailsView() .opacity(showsProfileDetails ? 1 : 0) .offset(y: showsProfileDetails ? 0 : Config.detailEnterOffset) .blur(radius: showsProfileDetails ? 0 : Config.profileBlur) .allowsHitTesting(showsProfileDetails) VerificationDetailsView() .opacity(showsVerificationDetails ? 1 : 0) .offset(y: showsVerificationDetails ? 0 : Config.detailEnterOffset) .blur(radius: showsVerificationDetails ? 0 : Config.verificationBlur) .allowsHitTesting(showsVerificationDetails) } .padding(.top, Config.detailTopSpacing) Spacer(minLength: 36) } } } .frame(width: contentWidth, alignment: .leading) .padding(.horizontal, Config.sideInset) } } .preferredColorScheme(.light) .onAppear { motion.start() } .onDisappear { motion.stop() flipTask?.cancel() } } /// Real device tilt when the sensor is reporting, otherwise the /// drag-preview tilt used in Simulator. private var currentTilt: CGSize { motion.isUsingDeviceMotion ? motion.tilt : fallbackTilt } /// Back arrow (only live on the verification face, flips back to the /// profile) and a close button. private var topBar: some View { HStack { Button { runFlip(toVerification: false) } label: { Image(systemName: "arrow.left") .font(.system(size: 18, weight: .regular)) .foregroundStyle(Config.ink.opacity(isShowingVerification ? 1 : 0)) .frame(width: 28, height: 28) } .disabled(!isShowingVerification) Spacer() Button { // TODO: dismiss the sheet } label: { Image(systemName: "xmark") .font(.system(size: 18, weight: .regular)) .foregroundStyle(Config.ink) .frame(width: 28, height: 28) } .buttonStyle(.plain) } } /// Front/back face swap stays tied to the card rotation, not opacity. @ViewBuilder private func flippingCard(width: CGFloat, height: CGFloat, tilt: CGSize, tiltStrength: CGFloat) -> some View { let showsBackFace = flipAngle >= 90 let cardFace = ZStack { if !showsBackFace { ProfileIdentityCard() } else { VerificationIdentityCard(tilt: tilt, tiltStrength: tiltStrength) .rotation3DEffect( .degrees(180), axis: (x: 0, y: 1, z: 0), perspective: Config.flipPerspective ) } } .frame(width: width, height: height) .contentShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) .rotation3DEffect( .degrees(flipAngle), axis: (x: 0, y: 1, z: 0), perspective: Config.flipPerspective ) .rotation3DEffect( .degrees(-Double(tilt.height) * Config.maxPitchDegrees), axis: (x: 1, y: 0, z: 0), perspective: 0.85 ) .rotation3DEffect( .degrees(Double(tilt.width) * Config.maxYawDegrees), axis: (x: 0, y: 1, z: 0), perspective: 0.85 ) .offset(y: -tiltStrength * Config.maxLift) .shadow( color: .black.opacity(isShowingVerification ? 0.16 + tiltStrength * 0.10 : 0.09), radius: isShowingVerification ? 18 + tiltStrength * Config.maxShadowRadius : 18, y: isShowingVerification ? 14 + tiltStrength * 12 : 12 ) .accessibilityAddTraits(.isButton) .background { if !showsBackFace { RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill(Color.black.opacity(0.001)) } } if isShowingVerification && !isFlipAnimating { cardFace .gesture(tiltPreviewGesture(width: width, height: height)) } else { cardFace .highPriorityGesture( TapGesture() .onEnded { runFlip(toVerification: true) } ) } } /// Runs one flip in either direction. The choreography is symmetric: /// the outgoing face's detail copy fades out first so it does not /// overlap the flip, the underlying face swaps at the halfway point /// (when the card is edge-on), and the incoming copy fades in once the /// flip lands. Driven by `Task.sleep` rather than dispatched delays so /// a reverse flip can cancel an in-flight one. private func runFlip(toVerification: Bool) { // Ignore taps that would flip toward the face already showing, or // that land mid-flip. guard isShowingVerification != toVerification, !isFlipAnimating else { return } flipTask?.cancel() flipTask = Task { @MainActor in isFlipAnimating = true withAnimation(.easeInOut(duration: Config.detailExitDuration)) { if toVerification { showsProfileDetails = false } else { showsVerificationDetails = false } } withAnimation(.smooth(duration: Config.flipDuration)) { flipAngle = toVerification ? 180 : 0 } // Swap the live state at the midpoint, when the card is edge-on // and neither face is readable. try? await Task.sleep(for: .seconds(Config.flipDuration / 2)) guard !Task.isCancelled else { return } isShowingVerification = toVerification // Let the flip finish, then bring in the new face's copy. try? await Task.sleep(for: .seconds(Config.flipDuration / 2 + Config.detailRevealGap)) guard !Task.isCancelled else { return } withAnimation(.easeOut(duration: Config.detailRevealDuration)) { if toVerification { showsVerificationDetails = true } else { showsProfileDetails = true } } // Hold the lock a beat past the reveal so a stray tap during // the settle does not start another flip. try? await Task.sleep(for: .seconds(Config.flipSettleGap)) guard !Task.isCancelled else { return } isFlipAnimating = false } } /// Drag previews the motion-driven tilt on Simulator. private func tiltPreviewGesture(width: CGFloat, height: CGFloat) -> some Gesture { DragGesture(minimumDistance: 0) .onChanged { value in guard !motion.isUsingDeviceMotion, isShowingVerification else { return } let x = ((value.location.x / width) - 0.5) * 2 let y = ((value.location.y / height) - 0.5) * 2 fallbackTilt = CGSize( width: x.clamped(to: -1...1), height: y.clamped(to: -1...1) ) } .onEnded { _ in guard !motion.isUsingDeviceMotion else { return } withAnimation(Config.fallbackSpring) { fallbackTilt = .zero } } } } // MARK: - Profile card /// The card's front (profile) face: avatar with a verified badge, name, /// and role on a plain white surface. private struct ProfileIdentityCard: View { var body: some View { VStack(spacing: 12) { ZStack(alignment: .bottomTrailing) { RemoteImage(photoID: Config.hostPhotoID, style: .portrait) .frame(width: Config.avatarSize, height: Config.avatarSize) .clipShape(Circle()) Circle() .fill(Config.badgePink) .frame(width: Config.badgeSize, height: Config.badgeSize) .overlay { Image(systemName: "checkmark.shield.fill") .font(.system(size: 14, weight: .bold)) .foregroundStyle(.white) } .offset(x: 4, y: 2) } Text(Config.hostName) .font(.system(size: Config.frontNameSize, weight: .semibold)) .foregroundStyle(Config.ink) Text(Config.hostRole) .font(.system(size: Config.roleSize, weight: .regular)) .foregroundStyle(Config.mutedInk) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.vertical, 24) .background( RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill(Config.cardFront) ) } } // MARK: - Verification card /// The card's back (verification) face: a gradient surface printed with a /// field of tiny logos, the name and verified date, a short blurb, the /// holographic portrait window, and a tilt-reactive foil sheen on top. private struct VerificationIdentityCard: View { let tilt: CGSize let tiltStrength: CGFloat var body: some View { GeometryReader { proxy in let size = proxy.size ZStack { RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill( LinearGradient( colors: [Config.cardPurple, Config.cardPink, Config.cardOrange], startPoint: .bottomLeading, endPoint: .topTrailing ) ) VerificationPattern(tilt: tilt) .clipShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) VStack(alignment: .leading, spacing: 6) { Text(Config.hostName) .font(.system(size: Config.cardTitleSize, weight: .semibold)) .foregroundStyle(.white) Text(Config.verificationDate) .font(.system(size: Config.cardTitleSize, weight: .medium)) .foregroundStyle(.white.opacity(0.92)) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.leading, Config.verificationCardHorizontalInset) .padding(.top, Config.verificationCardVerticalInset) Text(Config.verificationCardBody) .font(.system(size: Config.cardBodySize, weight: .regular)) .foregroundStyle(.white.opacity(0.88)) .lineSpacing(2) .frame(width: size.width * 0.52, alignment: .leading) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) .padding(.leading, Config.verificationCardHorizontalInset) .padding(.bottom, Config.verificationCardVerticalInset) VerificationPhotoWindow(tilt: tilt, tiltStrength: tiltStrength) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) .padding(.trailing, Config.verificationPhotoInset) .padding(.bottom, Config.verificationPhotoInset) FoilOverlay(tilt: tilt, size: size) .blendMode(.screen) } .clipShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) } } } // MARK: - Verification photo /// The small portrait window on the verification face. At rest it is a /// plain photo; as the card tilts left/right an inverted copy and a stack /// of tinted gradients fade in to read as a holographic security image. private struct VerificationPhotoWindow: View { let tilt: CGSize let tiltStrength: CGFloat var body: some View { // Strength and band positions are driven by horizontal tilt only; // the hologram peaks at full left/right and vanishes at center. let horizontalTilt = tilt.width.clamped(to: -1...1) let hologramStrength = abs(horizontalTilt) let hologramStartX = 0.14 + horizontalTilt * Config.hologramShift let hologramEndX = 0.86 - horizontalTilt * Config.hologramShift CachedRemotePhoto(photoID: Config.hostPhotoID) { image in photoLayers( base: image, hologramStrength: hologramStrength, horizontalTilt: horizontalTilt, hologramStartX: hologramStartX, hologramEndX: hologramEndX ) } fallback: { verificationFallback } .frame(width: Config.verificationPhotoSize, height: Config.verificationPhotoSize) .clipShape(RoundedRectangle(cornerRadius: Config.verificationPhotoCornerRadius, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: Config.verificationPhotoCornerRadius, style: .continuous) .stroke(Color.white.opacity(0.12 + tiltStrength * 0.08), lineWidth: 1) } } /// Stacks the base photo with the tilt-driven hologram layers: an /// inverted ghost, a diagonal color wash, a moving white streak, a /// soft-light cross gradient, and a bright spot. Every overlay's /// opacity tracks `hologramStrength`, so all of it disappears at rest. @ViewBuilder private func photoLayers( base image: Image, hologramStrength: CGFloat, horizontalTilt: CGFloat, hologramStartX: CGFloat, hologramEndX: CGFloat ) -> some View { ZStack { image .resizable() .scaledToFill() .saturation(1) .contrast(1.02) image .resizable() .scaledToFill() .grayscale(1) .contrast(1.18) .brightness(-0.03) .colorInvert() .opacity(hologramStrength) LinearGradient( colors: [ Config.xrayLavender.opacity(0.18 + hologramStrength * 0.68), Config.xrayAmber.opacity(0.14 + hologramStrength * 0.64), Config.foilBlue.opacity(0.10 + hologramStrength * 0.54) ], startPoint: UnitPoint(x: hologramStartX, y: 0.14), endPoint: UnitPoint(x: hologramEndX, y: 0.88) ) .blendMode(.color) .opacity(hologramStrength) LinearGradient( colors: [ .clear, Color.white.opacity(0.06 + hologramStrength * 0.28), .clear ], startPoint: UnitPoint(x: 0.18 + horizontalTilt * 0.10, y: 0), endPoint: UnitPoint(x: 0.82 + horizontalTilt * 0.22, y: 1) ) .blendMode(.overlay) .opacity(hologramStrength) LinearGradient( colors: [ Config.xrayAmber.opacity(0.04 + hologramStrength * 0.20), .clear, Config.xrayLavender.opacity(0.06 + hologramStrength * 0.24) ], startPoint: UnitPoint(x: 0.10 + horizontalTilt * 0.08, y: 0.10), endPoint: UnitPoint(x: 0.90 - horizontalTilt * 0.08, y: 0.92) ) .blendMode(.softLight) .opacity(hologramStrength) RadialGradient( colors: [ Color.white.opacity(0.04 + hologramStrength * 0.14), .clear ], center: UnitPoint(x: 0.50 + horizontalTilt * 0.18, y: 0.46), startRadius: 4, endRadius: Config.verificationPhotoSize * 0.72 ) .blendMode(.screen) .opacity(hologramStrength) } } /// Shown while the portrait is loading or if it fails: a soft frosted /// rectangle so the window never reads as empty. private var verificationFallback: some View { LinearGradient( colors: [ Color.white.opacity(0.28), Color.white.opacity(0.14) ], startPoint: .topLeading, endPoint: .bottomTrailing ) .overlay { RoundedRectangle(cornerRadius: Config.verificationPhotoCornerRadius, style: .continuous) .fill(Color.black.opacity(0.04)) } } } // MARK: - Foil overlay /// The iridescent sheen drawn over the whole verification face. A blurred /// diagonal gradient plus a radial highlight, both anchored to tilt so the /// shine slides as the card moves. Screen-blended and non-interactive. private struct FoilOverlay: View { let tilt: CGSize let size: CGSize var body: some View { ZStack { LinearGradient( colors: [ Color.white.opacity(0.02), Config.foilBlue.opacity(0.08), Config.foilViolet.opacity(0.10), Config.foilGold.opacity(0.08), Color.white.opacity(0.04) ], startPoint: UnitPoint(x: 0.08 + tilt.width * 0.24, y: 0.12 + tilt.height * 0.18), endPoint: UnitPoint(x: 0.92 - tilt.width * 0.24, y: 0.90 - tilt.height * 0.18) ) .blur(radius: 14) RadialGradient( colors: [ Color.white.opacity(0.12), Config.foilGold.opacity(0.08), .clear ], center: UnitPoint(x: 0.45 + tilt.width * 0.20, y: 0.38 + tilt.height * 0.14), startRadius: 8, endRadius: max(size.width, size.height) * 0.55 ) .blendMode(.screen) } .allowsHitTesting(false) } } // MARK: - Profile details /// Content below the profile face: the "about me" fact rows, the bio /// paragraph, a divider, and one sample listing. private struct ProfileDetailsView: View { var body: some View { VStack(alignment: .leading, spacing: 18) { ForEach(Config.facts) { fact in HStack(alignment: .center, spacing: 12) { Image(systemName: fact.icon) .font(.system(size: 18, weight: .regular)) .foregroundStyle(Config.ink) .frame(width: 22) if fact.underlined { Text(fact.text) .font(.system(size: Config.factSize, weight: .regular)) .foregroundStyle(Config.ink) .underline() } else { Text(fact.text) .font(.system(size: Config.factSize, weight: .regular)) .foregroundStyle(Config.ink) .fixedSize(horizontal: false, vertical: true) } } } Text(Config.profileBody) .font(.system(size: Config.bodySize, weight: .regular)) .foregroundStyle(Config.ink) .lineSpacing(3) .padding(.top, 8) Rectangle() .fill(Config.divider) .frame(height: 1) .padding(.top, 6) Text(Config.listingsTitle) .font(.system(size: Config.listingsTitleSize, weight: .semibold)) .foregroundStyle(Config.ink) .padding(.top, 4) HStack(spacing: 12) { RemoteImage(photoID: Config.listingPhotoID, style: .listing) .frame(width: Config.listingImageWidth, height: Config.listingImageHeight) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) VStack(alignment: .leading, spacing: 6) { Text(Config.listingName) .font(.system(size: Config.listingNameSize, weight: .semibold)) .foregroundStyle(Config.ink) Text(Config.listingBody) .font(.system(size: Config.listingBodySize, weight: .regular)) .foregroundStyle(Config.mutedInk) .lineSpacing(2) } } } } } // MARK: - Verification details /// Explainer paragraph shown below the card after the flip, ending in an /// underlined "Learn more" link. private struct VerificationDetailsView: View { var body: some View { // Combine the plain body with the underlined link using Text string // interpolation, the supported replacement for the deprecated // `Text + Text` and for AttributedString.underlineStyle (whose key // path is not concurrency-safe under Swift 6). let lead = Text(Config.verificationBody + " ") let link = Text(Config.learnMore).underline() Text("\(lead)\(link)") .font(.system(size: Config.factSize, weight: .regular)) .foregroundStyle(Config.ink) .lineSpacing(Config.verificationBodyLineSpacing) .fixedSize(horizontal: false, vertical: true) .padding(.trailing, Config.verificationBodyTrailingInset) } } // MARK: - Verification pattern /// The field of tiny Airbnb belo logos printed across the verification /// face, arranged as four nested spirals. With tilt the field shifts from /// dark ink (tilted left) to light ink (tilted right), like a printed /// security pattern catching the light. private struct VerificationPattern: View { let tilt: CGSize /// The printed field reads a touch low of true center once the card /// tilts in perspective; used for the highlight that tracks the spiral. private let spiralCenter = CGPoint(x: 0.50, y: 0.54) /// Four spiral arms from widest/densest to tightest/faintest. Each one /// shares the same center and winds the same direction; they differ in /// radius, glyph count, glyph scale, and opacity. private let spiralSpecs: [VerificationSpiralSpec] = [ .init(center: CGPoint(x: 0.50, y: 0.54), turns: 2.9, maxRadius: 0.50, count: 58, rotation: -.pi * 0.02, sizeScale: 1.00, opacity: 1.00, clockwise: true), .init(center: CGPoint(x: 0.50, y: 0.54), turns: 2.55, maxRadius: 0.38, count: 46, rotation: .pi * 0.06, sizeScale: 0.82, opacity: 0.86, clockwise: true), .init(center: CGPoint(x: 0.50, y: 0.54), turns: 2.15, maxRadius: 0.28, count: 34, rotation: .pi * 0.12, sizeScale: 0.66, opacity: 0.72, clockwise: true), .init(center: CGPoint(x: 0.50, y: 0.54), turns: 1.75, maxRadius: 0.20, count: 24, rotation: .pi * 0.18, sizeScale: 0.54, opacity: 0.58, clockwise: true) ] var body: some View { GeometryReader { proxy in let size = proxy.size let horizontalTilt = tilt.width.clamped(to: -1...1) // A small dead zone keeps the pattern neutral when the card is // near level, so it only "inks up" once you tilt with intent. let deadZone: CGFloat = 0.14 let leftStrength = max(0, -horizontalTilt - deadZone) / (1 - deadZone) let rightStrength = max(0, horizontalTilt - deadZone) / (1 - deadZone) let spiralStrength = max(leftStrength, rightStrength) // Tilt left inks the logos dark (multiply), tilt right inks them // light (screen); both are drawn so the field flips polarity. let leftInk = Color.black.opacity(leftStrength * 0.62) let rightInk = Color.white.opacity(rightStrength * 0.64) ZStack { spiralLayer(specs: spiralSpecs, size: size, horizontalTilt: horizontalTilt, strokeStyle: leftInk) .blendMode(.multiply) spiralLayer(specs: spiralSpecs, size: size, horizontalTilt: horizontalTilt, strokeStyle: rightInk) .blendMode(.screen) RadialGradient( colors: [ Color.white.opacity(spiralStrength * 0.10), .clear ], center: UnitPoint(x: spiralCenter.x, y: spiralCenter.y), startRadius: 10, endRadius: size.width * 0.38 ) .blendMode(.screen) } } } /// Draws all four spiral arms in one ink color. Called twice (dark and /// light) so the field can carry both polarities at once. @ViewBuilder private func spiralLayer<StrokeStyle: ShapeStyle>( specs: [VerificationSpiralSpec], size: CGSize, horizontalTilt: CGFloat, strokeStyle: StrokeStyle ) -> some View { ForEach(Array(specs.enumerated()), id: \.offset) { _, spec in SpiralLogoField( spec: spec, canvasSize: size, strokeStyle: strokeStyle, tilt: horizontalTilt, minWidth: Config.verificationSpiralMinSize * spec.sizeScale, maxWidth: Config.verificationSpiralMaxSize * spec.sizeScale ) .opacity(spec.opacity) } } } /// One spiral arm of belo logos. Places `spec.count` glyphs along an /// Archimedean-style spiral, shrinking them from `maxWidth` at the center /// to `minWidth` at the outer end and rotating each to face along the arm. private struct SpiralLogoField<StrokeStyle: ShapeStyle>: View { let spec: VerificationSpiralSpec let canvasSize: CGSize let strokeStyle: StrokeStyle let tilt: CGFloat let minWidth: CGFloat let maxWidth: CGFloat var body: some View { ZStack { ForEach(0..<spec.count, id: \.self) { index in let progress = CGFloat(index) / CGFloat(max(spec.count - 1, 1)) let spiralProgress = 1 - progress let angleDirection: CGFloat = spec.clockwise ? 1 : -1 let angle = spec.rotation + angleDirection * progress * spec.turns * .pi * 2 let radius = canvasSize.width * spec.maxRadius * pow(spiralProgress, 0.86) let center = CGPoint( x: canvasSize.width * spec.center.x, y: canvasSize.height * spec.center.y ) let x = center.x + cos(angle) * radius let y = center.y + sin(angle) * radius let width = maxWidth - progress * (maxWidth - minWidth) let height = width * 1.34 let rotation = Angle(radians: Double(angle + .pi / 2 + tilt * 0.08)) AirbnbBeloShape() .stroke(strokeStyle, lineWidth: 1.05) .frame(width: width, height: height) .opacity(0.90 - progress * 0.26) .rotationEffect(rotation) .position(x: x, y: y) } } } } /// Parameters for one spiral arm in the logo field. private struct VerificationSpiralSpec { /// Arm center in unit coordinates (0...1 of the canvas). let center: CGPoint /// Number of full turns from center to outer end. let turns: CGFloat /// Outer radius as a fraction of the canvas width. let maxRadius: CGFloat /// How many glyphs to place along the arm. let count: Int /// Starting angle offset, in radians, so the arms do not all align. let rotation: CGFloat /// Per-arm glyph size multiplier (inner arms run smaller). let sizeScale: CGFloat /// Per-arm opacity (inner arms run fainter). let opacity: CGFloat /// Winding direction. let clockwise: Bool } /// The Airbnb "belo" mark as a stroked `Shape`: the looping outline plus /// the small inner pin, drawn in a unit rect so it scales to any size. private struct AirbnbBeloShape: Shape { func path(in rect: CGRect) -> Path { var path = Path() let width = rect.width let height = rect.height func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: rect.minX + x * width, y: rect.minY + y * height) } path.move(to: point(0.50, 0.95)) path.addCurve( to: point(0.24, 0.43), control1: point(0.32, 0.82), control2: point(0.18, 0.63) ) path.addCurve( to: point(0.50, 0.10), control1: point(0.24, 0.22), control2: point(0.40, 0.07) ) path.addCurve( to: point(0.76, 0.43), control1: point(0.60, 0.07), control2: point(0.76, 0.22) ) path.addCurve( to: point(0.50, 0.95), control1: point(0.82, 0.63), control2: point(0.68, 0.82) ) path.move(to: point(0.50, 0.61)) path.addCurve( to: point(0.39, 0.49), control1: point(0.45, 0.61), control2: point(0.39, 0.56) ) path.addCurve( to: point(0.50, 0.33), control1: point(0.39, 0.41), control2: point(0.44, 0.33) ) path.addCurve( to: point(0.61, 0.49), control1: point(0.56, 0.33), control2: point(0.61, 0.41) ) path.addCurve( to: point(0.50, 0.61), control1: point(0.61, 0.56), control2: point(0.55, 0.61) ) return path } } // MARK: - Device tilt model /// Publishes a normalized tilt (each axis in -1...1) from Core Motion. /// `isUsingDeviceMotion` stays false until the first real sample arrives, /// which is how the view knows to fall back to drag-preview tilt in /// Simulator. All published changes are hopped back to the main actor. @MainActor private final class DeviceTiltModel: ObservableObject { @Published private(set) var tilt: CGSize = .zero @Published private(set) var isUsingDeviceMotion = false private let motionManager = CMMotionManager() private let motionQueue: OperationQueue = { let queue = OperationQueue() queue.name = "AirbnbIdentityVerificationCardFlipScanSnippet.DeviceMotion" return queue }() /// Begins motion updates if a sensor is present. No-op on Simulator, /// which leaves `isUsingDeviceMotion` false so the drag fallback runs. func start() { guard motionManager.isDeviceMotionAvailable else { return } // Prefer the drift-corrected frame when available for a steadier // resting attitude. let frames = CMMotionManager.availableAttitudeReferenceFrames() let referenceFrame: CMAttitudeReferenceFrame if frames.contains(.xArbitraryCorrectedZVertical) { referenceFrame = .xArbitraryCorrectedZVertical } else { referenceFrame = .xArbitraryZVertical } motionManager.deviceMotionUpdateInterval = 1 / 60 motionManager.startDeviceMotionUpdates(using: referenceFrame, to: motionQueue) { [weak self] motion, _ in guard let self, let motion else { return } // Normalize to -1...1 over a comfortable wrist range: +-45deg of // roll and +-36deg of pitch reach full tilt. let roll = (motion.attitude.roll / (.pi / 4)).clamped(to: -1...1) let pitch = (motion.attitude.pitch / (.pi / 5)).clamped(to: -1...1) Task { @MainActor in self.tilt = CGSize(width: roll, height: pitch) self.isUsingDeviceMotion = true } } } /// Stops updates and resets to a neutral, sensorless state. func stop() { motionManager.stopDeviceMotionUpdates() isUsingDeviceMotion = false tilt = .zero } } // MARK: - Remote image /// A cached remote photo filling its frame, with a style-specific /// placeholder shown while loading or on failure so a tile is never blank. private struct RemoteImage: View { let photoID: String let style: RemoteImageStyle var body: some View { CachedRemotePhoto(photoID: photoID) { image in image .resizable() .scaledToFill() } fallback: { fallbackView } } /// A simple gradient skeleton: a head-and-shoulders silhouette for the /// portrait, a card-like block layout for the listing. private var fallbackView: some View { ZStack { switch style { case .portrait: LinearGradient( colors: [Color(red: 0.83, green: 0.89, blue: 0.97), Color(red: 0.62, green: 0.74, blue: 0.88)], startPoint: .top, endPoint: .bottom ) VStack(spacing: 0) { Circle() .fill(Color.white.opacity(0.70)) .frame(width: 34, height: 34) RoundedRectangle(cornerRadius: 22, style: .continuous) .fill(Color.white.opacity(0.62)) .frame(width: 62, height: 44) .offset(y: -6) } .offset(y: 10) case .listing: LinearGradient( colors: [Color(red: 0.85, green: 0.92, blue: 0.82), Color(red: 0.70, green: 0.80, blue: 0.66)], startPoint: .topLeading, endPoint: .bottomTrailing ) VStack(spacing: 10) { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color.white.opacity(0.78)) .frame(height: 28) .padding(.horizontal, 16) HStack(spacing: 10) { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color.white.opacity(0.74)) RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color.white.opacity(0.54)) } .frame(height: 32) .padding(.horizontal, 16) } } } } } /// Picks which placeholder skeleton `RemoteImage` draws. private enum RemoteImageStyle { case portrait case listing } /// Renders a remote photo once it has loaded, otherwise its fallback. /// Backed by `RemoteImageLoader`, so the image survives view recomposition /// (every tilt frame) without dropping back to a loading state. private struct CachedRemotePhoto<Content: View, Fallback: View>: View { @StateObject private var loader: RemoteImageLoader private let content: (Image) -> Content private let fallback: Fallback init( photoID: String, @ViewBuilder content: @escaping (Image) -> Content, @ViewBuilder fallback: () -> Fallback ) { _loader = StateObject(wrappedValue: RemoteImageLoader(urlString: Self.unsplash(photoID))) self.content = content self.fallback = fallback() } var body: some View { Group { if let image = loader.image { content(Image(uiImage: image)) } else { fallback } } } /// Shared Unsplash URL builder so the cached loader and the rest of /// the file resolve the exact same remote asset. private static func unsplash(_ id: String) -> String { "https://images.unsplash.com/photo-\(id)?w=800&h=800&fit=crop&crop=faces&auto=format&q=80" } } /// Loads one image URL and publishes the result, backed by a process-wide /// `NSCache`. A second view asking for the same URL gets the cached image /// immediately, so flipping and tilting never re-fetch the portrait. @MainActor private final class RemoteImageLoader: ObservableObject { private static let cache = NSCache<NSString, UIImage>() @Published private(set) var image: UIImage? private let urlString: String private var task: Task<Void, Never>? init(urlString: String) { self.urlString = urlString if let cached = Self.cache.object(forKey: urlString as NSString) { image = cached } else { load() } } deinit { task?.cancel() } /// Fetches the image off the main actor and stores it in the shared /// cache before publishing. Skips work if the task was cancelled. private func load() { guard let url = URL(string: urlString) else { return } task = Task { guard let (data, _) = try? await URLSession.shared.data(from: url), !Task.isCancelled, let image = UIImage(data: data) else { return } Self.cache.setObject(image, forKey: urlString as NSString) self.image = image } } } // MARK: - Helpers private extension CGFloat { /// Constrains the value to `range`. Used to keep tilt and gradient /// positions inside their expected bounds. func clamped(to range: ClosedRange<CGFloat>) -> CGFloat { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } #Preview { AirbnbIdentityVerificationCardFlipScanSnippet() }
import SwiftUI import CoreMotion import UIKit // AirbnbIdentityVerificationCardFlipScanSnippet // // An Airbnb-style identity sheet with a tap-to-flip profile card and a // motion-reactive verification card. Tapping the host card flips it on // the Y axis; once flipped, Core Motion drives the tilt, the portrait // hologram, and the printed logo-field color shift. In Simulator, where // there is no real motion sensor, dragging the verification card stands // in for device tilt. // // The portrait loads once through a small cached image loader (not // AsyncImage) so the photo stays resident while the card recomposes on // every tilt frame, instead of flickering back to a loading state. // // HOW TO CUSTOMIZE: everything tweakable lives in Config below: copy, // photo IDs, colors, layout, typography, and the flip/tilt motion. // // One file, Apple frameworks only. Network is required for the portrait // and listing photo. Drop it into any iOS 26+ app or Swift Playground. // MARK: - Config /// All values a copy-paster might want to tweak. The rest of the file /// reads from Config, so layout and interaction tuning stay centralized. private enum Config { // MARK: Copy /// Host name shown on both card faces and in the listings heading. static let hostName = "Patrick Mahomes" /// Subtitle under the name on the profile (front) face. static let hostRole = "Host" /// Second line on the verification (back) face. static let verificationDate = "Verified since March 2025" /// Short blurb printed on the verification face itself. static let verificationCardBody = "Trust is the cornerstone of Airbnb's community, and identity verification is part of how we build it." /// Longer explainer shown below the card once it has flipped. static let verificationBody = "Our identity verification process checks a person's information against trusted third-party sources or a government ID. The process has safeguards, but doesn't guarantee that someone is who they say they are." /// Trailing link appended to `verificationBody`. The non-breaking /// space keeps "Learn more" from wrapping mid-phrase at the edge. static let learnMore = "Learn\u{00A0}more" /// Host bio paragraph shown on the profile face's detail list. static let profileBody = "I'm Patrick Mahomes, MVP quarterback and Sunday Funday connoisseur. I've been blessed to win multiple rings and compete on the biggest stage. But real talk, every day is a competition for me. If you love a chill day with sports, friends, and trophies, come hang with me." /// Heading above the listing row. static let listingsTitle = "Patrick Mahomes's listings" /// Title and blurb for the single sample listing. static let listingName = "Modern farmhouse in Belton" static let listingBody = "Texas sunsets, a big yard, and a football-ready game room." /// The "about me" rows on the profile face. The last row is the /// verified marker, which renders underlined. static let facts: [IdentityFact] = [ .init(icon: "graduationcap", text: "Where I went to school: Texas Tech, Wreck 'Em!"), .init(icon: "clock", text: "I spend too much time: Playing golf and watching sports"), .init(icon: "heart", text: "I'm obsessed with: Competition"), .init(icon: "pawprint", text: "Pets: Two dogs, Steel and Silver"), .init(icon: "checkmark.shield", text: "Identity verified", underlined: true) ] // MARK: Photos /// Unsplash photo IDs (the part after `photo-` in any unsplash URL). /// The same portrait drives the avatar, the verification window, and /// its hologram. Swap for a real portrait and listing photo. static let hostPhotoID = "1507003211169-0a1dd7228f2d" static let listingPhotoID = "1505693416388-ac5ce068fe85" // MARK: Theme /// Page and front-face fills. Both white to match the Airbnb sheet. static let pageBackground: Color = .white static let cardFront: Color = .white /// Primary text color (near-black, slightly warm). static let ink: Color = Color(red: 0.11, green: 0.11, blue: 0.13) /// Secondary text color for roles and captions. static let mutedInk: Color = Color(red: 0.45, green: 0.45, blue: 0.48) /// Hairline rule between the bio and the listings section. static let divider: Color = Color.black.opacity(0.10) /// Fill of the small verified checkmark badge on the avatar. static let badgePink: Color = Color(red: 0.90, green: 0.10, blue: 0.38) /// The verification face's diagonal gradient, bottom-left to top-right. static let cardPurple: Color = Color(red: 0.69, green: 0.11, blue: 0.54) static let cardPink: Color = Color(red: 0.88, green: 0.10, blue: 0.48) static let cardOrange: Color = Color(red: 1.00, green: 0.25, blue: 0.22) /// Iridescent foil sheen colors that drift with tilt across the card. static let foilBlue: Color = Color(red: 0.45, green: 0.74, blue: 1.00) static let foilGold: Color = Color(red: 1.00, green: 0.74, blue: 0.24) static let foilViolet: Color = Color(red: 0.62, green: 0.42, blue: 0.96) /// Tint pair for the portrait's holographic "x-ray" wash on tilt. static let xrayLavender: Color = Color(red: 0.62, green: 0.52, blue: 0.95) static let xrayAmber: Color = Color(red: 0.94, green: 0.72, blue: 0.44) // MARK: Layout (points unless noted) /// Caps the sheet width on iPad so the card does not stretch huge. static let contentMaxWidth: CGFloat = 500 /// Side margin around the whole sheet. static let sideInset: CGFloat = 34 /// Gap from the safe area to the top bar. static let topInset: CGFloat = 16 /// Gap from the top bar down to the card. static let cardTopSpacing: CGFloat = 28 /// Gap from the card down to the detail copy. static let detailTopSpacing: CGFloat = 38 /// Card width as a fraction of the content width (1.0 = full width). static let cardWidthFraction: CGFloat = 1.0 /// Card height as a fraction of its width. 0.60 gives a credit-card /// landscape ratio. static let cardHeightFraction: CGFloat = 0.60 /// Corner radius shared by the card and its hit shape. static let cardCornerRadius: CGFloat = 22 /// Circular avatar diameter on the profile face. static let avatarSize: CGFloat = 84 /// Diameter of the verified badge overlapping the avatar. static let badgeSize: CGFloat = 34 /// Side of the square portrait window on the verification face. static let verificationPhotoSize: CGFloat = 88 static let verificationPhotoCornerRadius: CGFloat = 14 /// Text insets from the verification face's leading/top edges. static let verificationCardHorizontalInset: CGFloat = 24 static let verificationCardVerticalInset: CGFloat = 22 /// Inset of the portrait window from the card's bottom-right corner. static let verificationPhotoInset: CGFloat = 20 /// Thumbnail size for the sample listing photo. static let listingImageWidth: CGFloat = 126 static let listingImageHeight: CGFloat = 96 // MARK: Typography (point sizes) static let frontNameSize: CGFloat = 26 static let roleSize: CGFloat = 16 static let factSize: CGFloat = 13.5 static let bodySize: CGFloat = 13.5 static let cardTitleSize: CGFloat = 16 static let cardBodySize: CGFloat = 12.5 static let listingsTitleSize: CGFloat = 16.5 static let listingNameSize: CGFloat = 14 static let listingBodySize: CGFloat = 13 /// Smallest and largest belo (Airbnb logo) glyph in the printed /// spiral field. Glyphs shrink from max at the center to min at the /// outer edge of each spiral arm. static let verificationSpiralMinSize: CGFloat = 3.2 static let verificationSpiralMaxSize: CGFloat = 16 /// Line spacing and trailing inset for the explainer paragraph. static let verificationBodyLineSpacing: CGFloat = 3 static let verificationBodyTrailingInset: CGFloat = 4 // MARK: Motion /// 3D perspective for the flip. Lower values keep it from feeling too /// theatrical (closer to a flat card turning than a dramatic zoom). static let flipPerspective: CGFloat = 0.82 /// How long the Y-axis flip takes, in seconds. static let flipDuration: Double = 0.56 /// Max yaw (left/right) and pitch (up/down) the tilt adds, in degrees. static let maxYawDegrees: Double = 14 static let maxPitchDegrees: Double = 10 /// Extra shadow radius and lift at full tilt, on the verification face. static let maxShadowRadius: CGFloat = 30 static let maxLift: CGFloat = 8 /// How far the printed hologram bands slide across the portrait with /// horizontal tilt, as a fraction of the window width. static let hologramShift: CGFloat = 0.26 // MARK: Flip choreography /// The detail copy below the card fades out this fast before the flip, /// so the two transitions do not overlap. static let detailExitDuration: Double = 0.18 /// The new face's detail copy fades in this fast after the flip lands. static let detailRevealDuration: Double = 0.22 /// Small beat after the flip finishes before revealing the new copy. static let detailRevealGap: Double = 0.03 /// Beat after the copy reveal before the card is interactive again. static let flipSettleGap: Double = 0.22 /// Detail copy slides up this far and blurs while hidden, then settles. static let detailEnterOffset: CGFloat = 18 static let profileBlur: CGFloat = 3 static let verificationBlur: CGFloat = 4 /// Spring that returns the drag-preview tilt to rest in Simulator. static let fallbackSpring: Animation = .spring(duration: 0.42, bounce: 0.16) } // MARK: - IdentityFact /// One row in the profile's "about me" list: an SF Symbol plus a line of /// text. `underlined` is set only on the verified marker row. private struct IdentityFact: Identifiable { let id = UUID() let icon: String let text: String let underlined: Bool init(icon: String, text: String, underlined: Bool = false) { self.icon = icon self.text = text self.underlined = underlined } } // MARK: - Root view /// Airbnb-style identity sheet. A profile card flips on tap to reveal a /// motion-reactive verification card; the detail copy below the card /// hands off in sync with the flip. Core Motion drives the tilt on /// device, a drag stands in on Simulator. struct AirbnbIdentityVerificationCardFlipScanSnippet: View { @StateObject private var motion = DeviceTiltModel() /// Which face is showing, and whether a flip is mid-flight (locks out /// re-entry and tells the card to take taps vs. tilt drags). @State private var isShowingVerification = false @State private var isFlipAnimating = false /// Visibility of the two detail blocks below the card. @State private var showsProfileDetails = true @State private var showsVerificationDetails = false /// Current flip rotation in degrees (0 = profile, 180 = verification). @State private var flipAngle: Double = 0 /// Drag-driven tilt used only when no motion sensor is available. @State private var fallbackTilt: CGSize = .zero /// The in-flight flip sequence, kept so a reverse flip can cancel it. @State private var flipTask: Task<Void, Never>? var body: some View { GeometryReader { proxy in let contentWidth = min(proxy.size.width - Config.sideInset * 2, Config.contentMaxWidth) let cardWidth = Config.cardWidthFraction * contentWidth let cardHeight = cardWidth * Config.cardHeightFraction let activeTilt = currentTilt let tiltStrength = min(1, sqrt(activeTilt.width * activeTilt.width + activeTilt.height * activeTilt.height)) ZStack(alignment: .top) { Config.pageBackground.ignoresSafeArea() VStack(spacing: 0) { topBar .padding(.top, Config.topInset) flippingCard( width: cardWidth, height: cardHeight, tilt: activeTilt, tiltStrength: tiltStrength ) .frame(maxWidth: .infinity) .padding(.top, Config.cardTopSpacing) ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { // Both detail blocks share the slot; only one // is visible at a time, cross-fading with the flip. ZStack(alignment: .topLeading) { ProfileDetailsView() .opacity(showsProfileDetails ? 1 : 0) .offset(y: showsProfileDetails ? 0 : Config.detailEnterOffset) .blur(radius: showsProfileDetails ? 0 : Config.profileBlur) .allowsHitTesting(showsProfileDetails) VerificationDetailsView() .opacity(showsVerificationDetails ? 1 : 0) .offset(y: showsVerificationDetails ? 0 : Config.detailEnterOffset) .blur(radius: showsVerificationDetails ? 0 : Config.verificationBlur) .allowsHitTesting(showsVerificationDetails) } .padding(.top, Config.detailTopSpacing) Spacer(minLength: 36) } } } .frame(width: contentWidth, alignment: .leading) .padding(.horizontal, Config.sideInset) } } .preferredColorScheme(.light) .onAppear { motion.start() } .onDisappear { motion.stop() flipTask?.cancel() } } /// Real device tilt when the sensor is reporting, otherwise the /// drag-preview tilt used in Simulator. private var currentTilt: CGSize { motion.isUsingDeviceMotion ? motion.tilt : fallbackTilt } /// Back arrow (only live on the verification face, flips back to the /// profile) and a close button. private var topBar: some View { HStack { Button { runFlip(toVerification: false) } label: { Image(systemName: "arrow.left") .font(.system(size: 18, weight: .regular)) .foregroundStyle(Config.ink.opacity(isShowingVerification ? 1 : 0)) .frame(width: 28, height: 28) } .disabled(!isShowingVerification) Spacer() Button { // TODO: dismiss the sheet } label: { Image(systemName: "xmark") .font(.system(size: 18, weight: .regular)) .foregroundStyle(Config.ink) .frame(width: 28, height: 28) } .buttonStyle(.plain) } } /// Front/back face swap stays tied to the card rotation, not opacity. @ViewBuilder private func flippingCard(width: CGFloat, height: CGFloat, tilt: CGSize, tiltStrength: CGFloat) -> some View { let showsBackFace = flipAngle >= 90 let cardFace = ZStack { if !showsBackFace { ProfileIdentityCard() } else { VerificationIdentityCard(tilt: tilt, tiltStrength: tiltStrength) .rotation3DEffect( .degrees(180), axis: (x: 0, y: 1, z: 0), perspective: Config.flipPerspective ) } } .frame(width: width, height: height) .contentShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) .rotation3DEffect( .degrees(flipAngle), axis: (x: 0, y: 1, z: 0), perspective: Config.flipPerspective ) .rotation3DEffect( .degrees(-Double(tilt.height) * Config.maxPitchDegrees), axis: (x: 1, y: 0, z: 0), perspective: 0.85 ) .rotation3DEffect( .degrees(Double(tilt.width) * Config.maxYawDegrees), axis: (x: 0, y: 1, z: 0), perspective: 0.85 ) .offset(y: -tiltStrength * Config.maxLift) .shadow( color: .black.opacity(isShowingVerification ? 0.16 + tiltStrength * 0.10 : 0.09), radius: isShowingVerification ? 18 + tiltStrength * Config.maxShadowRadius : 18, y: isShowingVerification ? 14 + tiltStrength * 12 : 12 ) .accessibilityAddTraits(.isButton) .background { if !showsBackFace { RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill(Color.black.opacity(0.001)) } } if isShowingVerification && !isFlipAnimating { cardFace .gesture(tiltPreviewGesture(width: width, height: height)) } else { cardFace .highPriorityGesture( TapGesture() .onEnded { runFlip(toVerification: true) } ) } } /// Runs one flip in either direction. The choreography is symmetric: /// the outgoing face's detail copy fades out first so it does not /// overlap the flip, the underlying face swaps at the halfway point /// (when the card is edge-on), and the incoming copy fades in once the /// flip lands. Driven by `Task.sleep` rather than dispatched delays so /// a reverse flip can cancel an in-flight one. private func runFlip(toVerification: Bool) { // Ignore taps that would flip toward the face already showing, or // that land mid-flip. guard isShowingVerification != toVerification, !isFlipAnimating else { return } flipTask?.cancel() flipTask = Task { @MainActor in isFlipAnimating = true withAnimation(.easeInOut(duration: Config.detailExitDuration)) { if toVerification { showsProfileDetails = false } else { showsVerificationDetails = false } } withAnimation(.smooth(duration: Config.flipDuration)) { flipAngle = toVerification ? 180 : 0 } // Swap the live state at the midpoint, when the card is edge-on // and neither face is readable. try? await Task.sleep(for: .seconds(Config.flipDuration / 2)) guard !Task.isCancelled else { return } isShowingVerification = toVerification // Let the flip finish, then bring in the new face's copy. try? await Task.sleep(for: .seconds(Config.flipDuration / 2 + Config.detailRevealGap)) guard !Task.isCancelled else { return } withAnimation(.easeOut(duration: Config.detailRevealDuration)) { if toVerification { showsVerificationDetails = true } else { showsProfileDetails = true } } // Hold the lock a beat past the reveal so a stray tap during // the settle does not start another flip. try? await Task.sleep(for: .seconds(Config.flipSettleGap)) guard !Task.isCancelled else { return } isFlipAnimating = false } } /// Drag previews the motion-driven tilt on Simulator. private func tiltPreviewGesture(width: CGFloat, height: CGFloat) -> some Gesture { DragGesture(minimumDistance: 0) .onChanged { value in guard !motion.isUsingDeviceMotion, isShowingVerification else { return } let x = ((value.location.x / width) - 0.5) * 2 let y = ((value.location.y / height) - 0.5) * 2 fallbackTilt = CGSize( width: x.clamped(to: -1...1), height: y.clamped(to: -1...1) ) } .onEnded { _ in guard !motion.isUsingDeviceMotion else { return } withAnimation(Config.fallbackSpring) { fallbackTilt = .zero } } } } // MARK: - Profile card /// The card's front (profile) face: avatar with a verified badge, name, /// and role on a plain white surface. private struct ProfileIdentityCard: View { var body: some View { VStack(spacing: 12) { ZStack(alignment: .bottomTrailing) { RemoteImage(photoID: Config.hostPhotoID, style: .portrait) .frame(width: Config.avatarSize, height: Config.avatarSize) .clipShape(Circle()) Circle() .fill(Config.badgePink) .frame(width: Config.badgeSize, height: Config.badgeSize) .overlay { Image(systemName: "checkmark.shield.fill") .font(.system(size: 14, weight: .bold)) .foregroundStyle(.white) } .offset(x: 4, y: 2) } Text(Config.hostName) .font(.system(size: Config.frontNameSize, weight: .semibold)) .foregroundStyle(Config.ink) Text(Config.hostRole) .font(.system(size: Config.roleSize, weight: .regular)) .foregroundStyle(Config.mutedInk) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.vertical, 24) .background( RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill(Config.cardFront) ) } } // MARK: - Verification card /// The card's back (verification) face: a gradient surface printed with a /// field of tiny logos, the name and verified date, a short blurb, the /// holographic portrait window, and a tilt-reactive foil sheen on top. private struct VerificationIdentityCard: View { let tilt: CGSize let tiltStrength: CGFloat var body: some View { GeometryReader { proxy in let size = proxy.size ZStack { RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill( LinearGradient( colors: [Config.cardPurple, Config.cardPink, Config.cardOrange], startPoint: .bottomLeading, endPoint: .topTrailing ) ) VerificationPattern(tilt: tilt) .clipShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) VStack(alignment: .leading, spacing: 6) { Text(Config.hostName) .font(.system(size: Config.cardTitleSize, weight: .semibold)) .foregroundStyle(.white) Text(Config.verificationDate) .font(.system(size: Config.cardTitleSize, weight: .medium)) .foregroundStyle(.white.opacity(0.92)) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.leading, Config.verificationCardHorizontalInset) .padding(.top, Config.verificationCardVerticalInset) Text(Config.verificationCardBody) .font(.system(size: Config.cardBodySize, weight: .regular)) .foregroundStyle(.white.opacity(0.88)) .lineSpacing(2) .frame(width: size.width * 0.52, alignment: .leading) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) .padding(.leading, Config.verificationCardHorizontalInset) .padding(.bottom, Config.verificationCardVerticalInset) VerificationPhotoWindow(tilt: tilt, tiltStrength: tiltStrength) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) .padding(.trailing, Config.verificationPhotoInset) .padding(.bottom, Config.verificationPhotoInset) FoilOverlay(tilt: tilt, size: size) .blendMode(.screen) } .clipShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) } } } // MARK: - Verification photo /// The small portrait window on the verification face. At rest it is a /// plain photo; as the card tilts left/right an inverted copy and a stack /// of tinted gradients fade in to read as a holographic security image. private struct VerificationPhotoWindow: View { let tilt: CGSize let tiltStrength: CGFloat var body: some View { // Strength and band positions are driven by horizontal tilt only; // the hologram peaks at full left/right and vanishes at center. let horizontalTilt = tilt.width.clamped(to: -1...1) let hologramStrength = abs(horizontalTilt) let hologramStartX = 0.14 + horizontalTilt * Config.hologramShift let hologramEndX = 0.86 - horizontalTilt * Config.hologramShift CachedRemotePhoto(photoID: Config.hostPhotoID) { image in photoLayers( base: image, hologramStrength: hologramStrength, horizontalTilt: horizontalTilt, hologramStartX: hologramStartX, hologramEndX: hologramEndX ) } fallback: { verificationFallback } .frame(width: Config.verificationPhotoSize, height: Config.verificationPhotoSize) .clipShape(RoundedRectangle(cornerRadius: Config.verificationPhotoCornerRadius, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: Config.verificationPhotoCornerRadius, style: .continuous) .stroke(Color.white.opacity(0.12 + tiltStrength * 0.08), lineWidth: 1) } } /// Stacks the base photo with the tilt-driven hologram layers: an /// inverted ghost, a diagonal color wash, a moving white streak, a /// soft-light cross gradient, and a bright spot. Every overlay's /// opacity tracks `hologramStrength`, so all of it disappears at rest. @ViewBuilder private func photoLayers( base image: Image, hologramStrength: CGFloat, horizontalTilt: CGFloat, hologramStartX: CGFloat, hologramEndX: CGFloat ) -> some View { ZStack { image .resizable() .scaledToFill() .saturation(1) .contrast(1.02) image .resizable() .scaledToFill() .grayscale(1) .contrast(1.18) .brightness(-0.03) .colorInvert() .opacity(hologramStrength) LinearGradient( colors: [ Config.xrayLavender.opacity(0.18 + hologramStrength * 0.68), Config.xrayAmber.opacity(0.14 + hologramStrength * 0.64), Config.foilBlue.opacity(0.10 + hologramStrength * 0.54) ], startPoint: UnitPoint(x: hologramStartX, y: 0.14), endPoint: UnitPoint(x: hologramEndX, y: 0.88) ) .blendMode(.color) .opacity(hologramStrength) LinearGradient( colors: [ .clear, Color.white.opacity(0.06 + hologramStrength * 0.28), .clear ], startPoint: UnitPoint(x: 0.18 + horizontalTilt * 0.10, y: 0), endPoint: UnitPoint(x: 0.82 + horizontalTilt * 0.22, y: 1) ) .blendMode(.overlay) .opacity(hologramStrength) LinearGradient( colors: [ Config.xrayAmber.opacity(0.04 + hologramStrength * 0.20), .clear, Config.xrayLavender.opacity(0.06 + hologramStrength * 0.24) ], startPoint: UnitPoint(x: 0.10 + horizontalTilt * 0.08, y: 0.10), endPoint: UnitPoint(x: 0.90 - horizontalTilt * 0.08, y: 0.92) ) .blendMode(.softLight) .opacity(hologramStrength) RadialGradient( colors: [ Color.white.opacity(0.04 + hologramStrength * 0.14), .clear ], center: UnitPoint(x: 0.50 + horizontalTilt * 0.18, y: 0.46), startRadius: 4, endRadius: Config.verificationPhotoSize * 0.72 ) .blendMode(.screen) .opacity(hologramStrength) } } /// Shown while the portrait is loading or if it fails: a soft frosted /// rectangle so the window never reads as empty. private var verificationFallback: some View { LinearGradient( colors: [ Color.white.opacity(0.28), Color.white.opacity(0.14) ], startPoint: .topLeading, endPoint: .bottomTrailing ) .overlay { RoundedRectangle(cornerRadius: Config.verificationPhotoCornerRadius, style: .continuous) .fill(Color.black.opacity(0.04)) } } } // MARK: - Foil overlay /// The iridescent sheen drawn over the whole verification face. A blurred /// diagonal gradient plus a radial highlight, both anchored to tilt so the /// shine slides as the card moves. Screen-blended and non-interactive. private struct FoilOverlay: View { let tilt: CGSize let size: CGSize var body: some View { ZStack { LinearGradient( colors: [ Color.white.opacity(0.02), Config.foilBlue.opacity(0.08), Config.foilViolet.opacity(0.10), Config.foilGold.opacity(0.08), Color.white.opacity(0.04) ], startPoint: UnitPoint(x: 0.08 + tilt.width * 0.24, y: 0.12 + tilt.height * 0.18), endPoint: UnitPoint(x: 0.92 - tilt.width * 0.24, y: 0.90 - tilt.height * 0.18) ) .blur(radius: 14) RadialGradient( colors: [ Color.white.opacity(0.12), Config.foilGold.opacity(0.08), .clear ], center: UnitPoint(x: 0.45 + tilt.width * 0.20, y: 0.38 + tilt.height * 0.14), startRadius: 8, endRadius: max(size.width, size.height) * 0.55 ) .blendMode(.screen) } .allowsHitTesting(false) } } // MARK: - Profile details /// Content below the profile face: the "about me" fact rows, the bio /// paragraph, a divider, and one sample listing. private struct ProfileDetailsView: View { var body: some View { VStack(alignment: .leading, spacing: 18) { ForEach(Config.facts) { fact in HStack(alignment: .center, spacing: 12) { Image(systemName: fact.icon) .font(.system(size: 18, weight: .regular)) .foregroundStyle(Config.ink) .frame(width: 22) if fact.underlined { Text(fact.text) .font(.system(size: Config.factSize, weight: .regular)) .foregroundStyle(Config.ink) .underline() } else { Text(fact.text) .font(.system(size: Config.factSize, weight: .regular)) .foregroundStyle(Config.ink) .fixedSize(horizontal: false, vertical: true) } } } Text(Config.profileBody) .font(.system(size: Config.bodySize, weight: .regular)) .foregroundStyle(Config.ink) .lineSpacing(3) .padding(.top, 8) Rectangle() .fill(Config.divider) .frame(height: 1) .padding(.top, 6) Text(Config.listingsTitle) .font(.system(size: Config.listingsTitleSize, weight: .semibold)) .foregroundStyle(Config.ink) .padding(.top, 4) HStack(spacing: 12) { RemoteImage(photoID: Config.listingPhotoID, style: .listing) .frame(width: Config.listingImageWidth, height: Config.listingImageHeight) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) VStack(alignment: .leading, spacing: 6) { Text(Config.listingName) .font(.system(size: Config.listingNameSize, weight: .semibold)) .foregroundStyle(Config.ink) Text(Config.listingBody) .font(.system(size: Config.listingBodySize, weight: .regular)) .foregroundStyle(Config.mutedInk) .lineSpacing(2) } } } } } // MARK: - Verification details /// Explainer paragraph shown below the card after the flip, ending in an /// underlined "Learn more" link. private struct VerificationDetailsView: View { var body: some View { // Combine the plain body with the underlined link using Text string // interpolation, the supported replacement for the deprecated // `Text + Text` and for AttributedString.underlineStyle (whose key // path is not concurrency-safe under Swift 6). let lead = Text(Config.verificationBody + " ") let link = Text(Config.learnMore).underline() Text("\(lead)\(link)") .font(.system(size: Config.factSize, weight: .regular)) .foregroundStyle(Config.ink) .lineSpacing(Config.verificationBodyLineSpacing) .fixedSize(horizontal: false, vertical: true) .padding(.trailing, Config.verificationBodyTrailingInset) } } // MARK: - Verification pattern /// The field of tiny Airbnb belo logos printed across the verification /// face, arranged as four nested spirals. With tilt the field shifts from /// dark ink (tilted left) to light ink (tilted right), like a printed /// security pattern catching the light. private struct VerificationPattern: View { let tilt: CGSize /// The printed field reads a touch low of true center once the card /// tilts in perspective; used for the highlight that tracks the spiral. private let spiralCenter = CGPoint(x: 0.50, y: 0.54) /// Four spiral arms from widest/densest to tightest/faintest. Each one /// shares the same center and winds the same direction; they differ in /// radius, glyph count, glyph scale, and opacity. private let spiralSpecs: [VerificationSpiralSpec] = [ .init(center: CGPoint(x: 0.50, y: 0.54), turns: 2.9, maxRadius: 0.50, count: 58, rotation: -.pi * 0.02, sizeScale: 1.00, opacity: 1.00, clockwise: true), .init(center: CGPoint(x: 0.50, y: 0.54), turns: 2.55, maxRadius: 0.38, count: 46, rotation: .pi * 0.06, sizeScale: 0.82, opacity: 0.86, clockwise: true), .init(center: CGPoint(x: 0.50, y: 0.54), turns: 2.15, maxRadius: 0.28, count: 34, rotation: .pi * 0.12, sizeScale: 0.66, opacity: 0.72, clockwise: true), .init(center: CGPoint(x: 0.50, y: 0.54), turns: 1.75, maxRadius: 0.20, count: 24, rotation: .pi * 0.18, sizeScale: 0.54, opacity: 0.58, clockwise: true) ] var body: some View { GeometryReader { proxy in let size = proxy.size let horizontalTilt = tilt.width.clamped(to: -1...1) // A small dead zone keeps the pattern neutral when the card is // near level, so it only "inks up" once you tilt with intent. let deadZone: CGFloat = 0.14 let leftStrength = max(0, -horizontalTilt - deadZone) / (1 - deadZone) let rightStrength = max(0, horizontalTilt - deadZone) / (1 - deadZone) let spiralStrength = max(leftStrength, rightStrength) // Tilt left inks the logos dark (multiply), tilt right inks them // light (screen); both are drawn so the field flips polarity. let leftInk = Color.black.opacity(leftStrength * 0.62) let rightInk = Color.white.opacity(rightStrength * 0.64) ZStack { spiralLayer(specs: spiralSpecs, size: size, horizontalTilt: horizontalTilt, strokeStyle: leftInk) .blendMode(.multiply) spiralLayer(specs: spiralSpecs, size: size, horizontalTilt: horizontalTilt, strokeStyle: rightInk) .blendMode(.screen) RadialGradient( colors: [ Color.white.opacity(spiralStrength * 0.10), .clear ], center: UnitPoint(x: spiralCenter.x, y: spiralCenter.y), startRadius: 10, endRadius: size.width * 0.38 ) .blendMode(.screen) } } } /// Draws all four spiral arms in one ink color. Called twice (dark and /// light) so the field can carry both polarities at once. @ViewBuilder private func spiralLayer<StrokeStyle: ShapeStyle>( specs: [VerificationSpiralSpec], size: CGSize, horizontalTilt: CGFloat, strokeStyle: StrokeStyle ) -> some View { ForEach(Array(specs.enumerated()), id: \.offset) { _, spec in SpiralLogoField( spec: spec, canvasSize: size, strokeStyle: strokeStyle, tilt: horizontalTilt, minWidth: Config.verificationSpiralMinSize * spec.sizeScale, maxWidth: Config.verificationSpiralMaxSize * spec.sizeScale ) .opacity(spec.opacity) } } } /// One spiral arm of belo logos. Places `spec.count` glyphs along an /// Archimedean-style spiral, shrinking them from `maxWidth` at the center /// to `minWidth` at the outer end and rotating each to face along the arm. private struct SpiralLogoField<StrokeStyle: ShapeStyle>: View { let spec: VerificationSpiralSpec let canvasSize: CGSize let strokeStyle: StrokeStyle let tilt: CGFloat let minWidth: CGFloat let maxWidth: CGFloat var body: some View { ZStack { ForEach(0..<spec.count, id: \.self) { index in let progress = CGFloat(index) / CGFloat(max(spec.count - 1, 1)) let spiralProgress = 1 - progress let angleDirection: CGFloat = spec.clockwise ? 1 : -1 let angle = spec.rotation + angleDirection * progress * spec.turns * .pi * 2 let radius = canvasSize.width * spec.maxRadius * pow(spiralProgress, 0.86) let center = CGPoint( x: canvasSize.width * spec.center.x, y: canvasSize.height * spec.center.y ) let x = center.x + cos(angle) * radius let y = center.y + sin(angle) * radius let width = maxWidth - progress * (maxWidth - minWidth) let height = width * 1.34 let rotation = Angle(radians: Double(angle + .pi / 2 + tilt * 0.08)) AirbnbBeloShape() .stroke(strokeStyle, lineWidth: 1.05) .frame(width: width, height: height) .opacity(0.90 - progress * 0.26) .rotationEffect(rotation) .position(x: x, y: y) } } } } /// Parameters for one spiral arm in the logo field. private struct VerificationSpiralSpec { /// Arm center in unit coordinates (0...1 of the canvas). let center: CGPoint /// Number of full turns from center to outer end. let turns: CGFloat /// Outer radius as a fraction of the canvas width. let maxRadius: CGFloat /// How many glyphs to place along the arm. let count: Int /// Starting angle offset, in radians, so the arms do not all align. let rotation: CGFloat /// Per-arm glyph size multiplier (inner arms run smaller). let sizeScale: CGFloat /// Per-arm opacity (inner arms run fainter). let opacity: CGFloat /// Winding direction. let clockwise: Bool } /// The Airbnb "belo" mark as a stroked `Shape`: the looping outline plus /// the small inner pin, drawn in a unit rect so it scales to any size. private struct AirbnbBeloShape: Shape { func path(in rect: CGRect) -> Path { var path = Path() let width = rect.width let height = rect.height func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: rect.minX + x * width, y: rect.minY + y * height) } path.move(to: point(0.50, 0.95)) path.addCurve( to: point(0.24, 0.43), control1: point(0.32, 0.82), control2: point(0.18, 0.63) ) path.addCurve( to: point(0.50, 0.10), control1: point(0.24, 0.22), control2: point(0.40, 0.07) ) path.addCurve( to: point(0.76, 0.43), control1: point(0.60, 0.07), control2: point(0.76, 0.22) ) path.addCurve( to: point(0.50, 0.95), control1: point(0.82, 0.63), control2: point(0.68, 0.82) ) path.move(to: point(0.50, 0.61)) path.addCurve( to: point(0.39, 0.49), control1: point(0.45, 0.61), control2: point(0.39, 0.56) ) path.addCurve( to: point(0.50, 0.33), control1: point(0.39, 0.41), control2: point(0.44, 0.33) ) path.addCurve( to: point(0.61, 0.49), control1: point(0.56, 0.33), control2: point(0.61, 0.41) ) path.addCurve( to: point(0.50, 0.61), control1: point(0.61, 0.56), control2: point(0.55, 0.61) ) return path } } // MARK: - Device tilt model /// Publishes a normalized tilt (each axis in -1...1) from Core Motion. /// `isUsingDeviceMotion` stays false until the first real sample arrives, /// which is how the view knows to fall back to drag-preview tilt in /// Simulator. All published changes are hopped back to the main actor. @MainActor private final class DeviceTiltModel: ObservableObject { @Published private(set) var tilt: CGSize = .zero @Published private(set) var isUsingDeviceMotion = false private let motionManager = CMMotionManager() private let motionQueue: OperationQueue = { let queue = OperationQueue() queue.name = "AirbnbIdentityVerificationCardFlipScanSnippet.DeviceMotion" return queue }() /// Begins motion updates if a sensor is present. No-op on Simulator, /// which leaves `isUsingDeviceMotion` false so the drag fallback runs. func start() { guard motionManager.isDeviceMotionAvailable else { return } // Prefer the drift-corrected frame when available for a steadier // resting attitude. let frames = CMMotionManager.availableAttitudeReferenceFrames() let referenceFrame: CMAttitudeReferenceFrame if frames.contains(.xArbitraryCorrectedZVertical) { referenceFrame = .xArbitraryCorrectedZVertical } else { referenceFrame = .xArbitraryZVertical } motionManager.deviceMotionUpdateInterval = 1 / 60 motionManager.startDeviceMotionUpdates(using: referenceFrame, to: motionQueue) { [weak self] motion, _ in guard let self, let motion else { return } // Normalize to -1...1 over a comfortable wrist range: +-45deg of // roll and +-36deg of pitch reach full tilt. let roll = (motion.attitude.roll / (.pi / 4)).clamped(to: -1...1) let pitch = (motion.attitude.pitch / (.pi / 5)).clamped(to: -1...1) Task { @MainActor in self.tilt = CGSize(width: roll, height: pitch) self.isUsingDeviceMotion = true } } } /// Stops updates and resets to a neutral, sensorless state. func stop() { motionManager.stopDeviceMotionUpdates() isUsingDeviceMotion = false tilt = .zero } } // MARK: - Remote image /// A cached remote photo filling its frame, with a style-specific /// placeholder shown while loading or on failure so a tile is never blank. private struct RemoteImage: View { let photoID: String let style: RemoteImageStyle var body: some View { CachedRemotePhoto(photoID: photoID) { image in image .resizable() .scaledToFill() } fallback: { fallbackView } } /// A simple gradient skeleton: a head-and-shoulders silhouette for the /// portrait, a card-like block layout for the listing. private var fallbackView: some View { ZStack { switch style { case .portrait: LinearGradient( colors: [Color(red: 0.83, green: 0.89, blue: 0.97), Color(red: 0.62, green: 0.74, blue: 0.88)], startPoint: .top, endPoint: .bottom ) VStack(spacing: 0) { Circle() .fill(Color.white.opacity(0.70)) .frame(width: 34, height: 34) RoundedRectangle(cornerRadius: 22, style: .continuous) .fill(Color.white.opacity(0.62)) .frame(width: 62, height: 44) .offset(y: -6) } .offset(y: 10) case .listing: LinearGradient( colors: [Color(red: 0.85, green: 0.92, blue: 0.82), Color(red: 0.70, green: 0.80, blue: 0.66)], startPoint: .topLeading, endPoint: .bottomTrailing ) VStack(spacing: 10) { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color.white.opacity(0.78)) .frame(height: 28) .padding(.horizontal, 16) HStack(spacing: 10) { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color.white.opacity(0.74)) RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color.white.opacity(0.54)) } .frame(height: 32) .padding(.horizontal, 16) } } } } } /// Picks which placeholder skeleton `RemoteImage` draws. private enum RemoteImageStyle { case portrait case listing } /// Renders a remote photo once it has loaded, otherwise its fallback. /// Backed by `RemoteImageLoader`, so the image survives view recomposition /// (every tilt frame) without dropping back to a loading state. private struct CachedRemotePhoto<Content: View, Fallback: View>: View { @StateObject private var loader: RemoteImageLoader private let content: (Image) -> Content private let fallback: Fallback init( photoID: String, @ViewBuilder content: @escaping (Image) -> Content, @ViewBuilder fallback: () -> Fallback ) { _loader = StateObject(wrappedValue: RemoteImageLoader(urlString: Self.unsplash(photoID))) self.content = content self.fallback = fallback() } var body: some View { Group { if let image = loader.image { content(Image(uiImage: image)) } else { fallback } } } /// Shared Unsplash URL builder so the cached loader and the rest of /// the file resolve the exact same remote asset. private static func unsplash(_ id: String) -> String { "https://images.unsplash.com/photo-\(id)?w=800&h=800&fit=crop&crop=faces&auto=format&q=80" } } /// Loads one image URL and publishes the result, backed by a process-wide /// `NSCache`. A second view asking for the same URL gets the cached image /// immediately, so flipping and tilting never re-fetch the portrait. @MainActor private final class RemoteImageLoader: ObservableObject { private static let cache = NSCache<NSString, UIImage>() @Published private(set) var image: UIImage? private let urlString: String private var task: Task<Void, Never>? init(urlString: String) { self.urlString = urlString if let cached = Self.cache.object(forKey: urlString as NSString) { image = cached } else { load() } } deinit { task?.cancel() } /// Fetches the image off the main actor and stores it in the shared /// cache before publishing. Skips work if the task was cancelled. private func load() { guard let url = URL(string: urlString) else { return } task = Task { guard let (data, _) = try? await URLSession.shared.data(from: url), !Task.isCancelled, let image = UIImage(data: data) else { return } Self.cache.setObject(image, forKey: urlString as NSString) self.image = image } } } // MARK: - Helpers private extension CGFloat { /// Constrains the value to `range`. Used to keep tilt and gradient /// positions inside their expected bounds. func clamped(to range: ClosedRange<CGFloat>) -> CGFloat { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } #Preview { AirbnbIdentityVerificationCardFlipScanSnippet() }
import SwiftUI import CoreMotion import UIKit // AirbnbIdentityVerificationCardFlipScanSnippet // // An Airbnb-style identity sheet with a tap-to-flip profile card and a // motion-reactive verification card. Tapping the host card flips it on // the Y axis; once flipped, Core Motion drives the tilt, the portrait // hologram, and the printed logo-field color shift. In Simulator, where // there is no real motion sensor, dragging the verification card stands // in for device tilt. // // The portrait loads once through a small cached image loader (not // AsyncImage) so the photo stays resident while the card recomposes on // every tilt frame, instead of flickering back to a loading state. // // HOW TO CUSTOMIZE: everything tweakable lives in Config below: copy, // photo IDs, colors, layout, typography, and the flip/tilt motion. // // One file, Apple frameworks only. Network is required for the portrait // and listing photo. Drop it into any iOS 26+ app or Swift Playground. // MARK: - Config /// All values a copy-paster might want to tweak. The rest of the file /// reads from Config, so layout and interaction tuning stay centralized. private enum Config { // MARK: Copy /// Host name shown on both card faces and in the listings heading. static let hostName = "Patrick Mahomes" /// Subtitle under the name on the profile (front) face. static let hostRole = "Host" /// Second line on the verification (back) face. static let verificationDate = "Verified since March 2025" /// Short blurb printed on the verification face itself. static let verificationCardBody = "Trust is the cornerstone of Airbnb's community, and identity verification is part of how we build it." /// Longer explainer shown below the card once it has flipped. static let verificationBody = "Our identity verification process checks a person's information against trusted third-party sources or a government ID. The process has safeguards, but doesn't guarantee that someone is who they say they are." /// Trailing link appended to `verificationBody`. The non-breaking /// space keeps "Learn more" from wrapping mid-phrase at the edge. static let learnMore = "Learn\u{00A0}more" /// Host bio paragraph shown on the profile face's detail list. static let profileBody = "I'm Patrick Mahomes, MVP quarterback and Sunday Funday connoisseur. I've been blessed to win multiple rings and compete on the biggest stage. But real talk, every day is a competition for me. If you love a chill day with sports, friends, and trophies, come hang with me." /// Heading above the listing row. static let listingsTitle = "Patrick Mahomes's listings" /// Title and blurb for the single sample listing. static let listingName = "Modern farmhouse in Belton" static let listingBody = "Texas sunsets, a big yard, and a football-ready game room." /// The "about me" rows on the profile face. The last row is the /// verified marker, which renders underlined. static let facts: [IdentityFact] = [ .init(icon: "graduationcap", text: "Where I went to school: Texas Tech, Wreck 'Em!"), .init(icon: "clock", text: "I spend too much time: Playing golf and watching sports"), .init(icon: "heart", text: "I'm obsessed with: Competition"), .init(icon: "pawprint", text: "Pets: Two dogs, Steel and Silver"), .init(icon: "checkmark.shield", text: "Identity verified", underlined: true) ] // MARK: Photos /// Unsplash photo IDs (the part after `photo-` in any unsplash URL). /// The same portrait drives the avatar, the verification window, and /// its hologram. Swap for a real portrait and listing photo. static let hostPhotoID = "1507003211169-0a1dd7228f2d" static let listingPhotoID = "1505693416388-ac5ce068fe85" // MARK: Theme /// Page and front-face fills. Both white to match the Airbnb sheet. static let pageBackground: Color = .white static let cardFront: Color = .white /// Primary text color (near-black, slightly warm). static let ink: Color = Color(red: 0.11, green: 0.11, blue: 0.13) /// Secondary text color for roles and captions. static let mutedInk: Color = Color(red: 0.45, green: 0.45, blue: 0.48) /// Hairline rule between the bio and the listings section. static let divider: Color = Color.black.opacity(0.10) /// Fill of the small verified checkmark badge on the avatar. static let badgePink: Color = Color(red: 0.90, green: 0.10, blue: 0.38) /// The verification face's diagonal gradient, bottom-left to top-right. static let cardPurple: Color = Color(red: 0.69, green: 0.11, blue: 0.54) static let cardPink: Color = Color(red: 0.88, green: 0.10, blue: 0.48) static let cardOrange: Color = Color(red: 1.00, green: 0.25, blue: 0.22) /// Iridescent foil sheen colors that drift with tilt across the card. static let foilBlue: Color = Color(red: 0.45, green: 0.74, blue: 1.00) static let foilGold: Color = Color(red: 1.00, green: 0.74, blue: 0.24) static let foilViolet: Color = Color(red: 0.62, green: 0.42, blue: 0.96) /// Tint pair for the portrait's holographic "x-ray" wash on tilt. static let xrayLavender: Color = Color(red: 0.62, green: 0.52, blue: 0.95) static let xrayAmber: Color = Color(red: 0.94, green: 0.72, blue: 0.44) // MARK: Layout (points unless noted) /// Caps the sheet width on iPad so the card does not stretch huge. static let contentMaxWidth: CGFloat = 500 /// Side margin around the whole sheet. static let sideInset: CGFloat = 34 /// Gap from the safe area to the top bar. static let topInset: CGFloat = 16 /// Gap from the top bar down to the card. static let cardTopSpacing: CGFloat = 28 /// Gap from the card down to the detail copy. static let detailTopSpacing: CGFloat = 38 /// Card width as a fraction of the content width (1.0 = full width). static let cardWidthFraction: CGFloat = 1.0 /// Card height as a fraction of its width. 0.60 gives a credit-card /// landscape ratio. static let cardHeightFraction: CGFloat = 0.60 /// Corner radius shared by the card and its hit shape. static let cardCornerRadius: CGFloat = 22 /// Circular avatar diameter on the profile face. static let avatarSize: CGFloat = 84 /// Diameter of the verified badge overlapping the avatar. static let badgeSize: CGFloat = 34 /// Side of the square portrait window on the verification face. static let verificationPhotoSize: CGFloat = 88 static let verificationPhotoCornerRadius: CGFloat = 14 /// Text insets from the verification face's leading/top edges. static let verificationCardHorizontalInset: CGFloat = 24 static let verificationCardVerticalInset: CGFloat = 22 /// Inset of the portrait window from the card's bottom-right corner. static let verificationPhotoInset: CGFloat = 20 /// Thumbnail size for the sample listing photo. static let listingImageWidth: CGFloat = 126 static let listingImageHeight: CGFloat = 96 // MARK: Typography (point sizes) static let frontNameSize: CGFloat = 26 static let roleSize: CGFloat = 16 static let factSize: CGFloat = 13.5 static let bodySize: CGFloat = 13.5 static let cardTitleSize: CGFloat = 16 static let cardBodySize: CGFloat = 12.5 static let listingsTitleSize: CGFloat = 16.5 static let listingNameSize: CGFloat = 14 static let listingBodySize: CGFloat = 13 /// Smallest and largest belo (Airbnb logo) glyph in the printed /// spiral field. Glyphs shrink from max at the center to min at the /// outer edge of each spiral arm. static let verificationSpiralMinSize: CGFloat = 3.2 static let verificationSpiralMaxSize: CGFloat = 16 /// Line spacing and trailing inset for the explainer paragraph. static let verificationBodyLineSpacing: CGFloat = 3 static let verificationBodyTrailingInset: CGFloat = 4 // MARK: Motion /// 3D perspective for the flip. Lower values keep it from feeling too /// theatrical (closer to a flat card turning than a dramatic zoom). static let flipPerspective: CGFloat = 0.82 /// How long the Y-axis flip takes, in seconds. static let flipDuration: Double = 0.56 /// Max yaw (left/right) and pitch (up/down) the tilt adds, in degrees. static let maxYawDegrees: Double = 14 static let maxPitchDegrees: Double = 10 /// Extra shadow radius and lift at full tilt, on the verification face. static let maxShadowRadius: CGFloat = 30 static let maxLift: CGFloat = 8 /// How far the printed hologram bands slide across the portrait with /// horizontal tilt, as a fraction of the window width. static let hologramShift: CGFloat = 0.26 // MARK: Flip choreography /// The detail copy below the card fades out this fast before the flip, /// so the two transitions do not overlap. static let detailExitDuration: Double = 0.18 /// The new face's detail copy fades in this fast after the flip lands. static let detailRevealDuration: Double = 0.22 /// Small beat after the flip finishes before revealing the new copy. static let detailRevealGap: Double = 0.03 /// Beat after the copy reveal before the card is interactive again. static let flipSettleGap: Double = 0.22 /// Detail copy slides up this far and blurs while hidden, then settles. static let detailEnterOffset: CGFloat = 18 static let profileBlur: CGFloat = 3 static let verificationBlur: CGFloat = 4 /// Spring that returns the drag-preview tilt to rest in Simulator. static let fallbackSpring: Animation = .spring(duration: 0.42, bounce: 0.16) } // MARK: - IdentityFact /// One row in the profile's "about me" list: an SF Symbol plus a line of /// text. `underlined` is set only on the verified marker row. private struct IdentityFact: Identifiable { let id = UUID() let icon: String let text: String let underlined: Bool init(icon: String, text: String, underlined: Bool = false) { self.icon = icon self.text = text self.underlined = underlined } } // MARK: - Root view /// Airbnb-style identity sheet. A profile card flips on tap to reveal a /// motion-reactive verification card; the detail copy below the card /// hands off in sync with the flip. Core Motion drives the tilt on /// device, a drag stands in on Simulator. struct AirbnbIdentityVerificationCardFlipScanSnippet: View { @StateObject private var motion = DeviceTiltModel() /// Which face is showing, and whether a flip is mid-flight (locks out /// re-entry and tells the card to take taps vs. tilt drags). @State private var isShowingVerification = false @State private var isFlipAnimating = false /// Visibility of the two detail blocks below the card. @State private var showsProfileDetails = true @State private var showsVerificationDetails = false /// Current flip rotation in degrees (0 = profile, 180 = verification). @State private var flipAngle: Double = 0 /// Drag-driven tilt used only when no motion sensor is available. @State private var fallbackTilt: CGSize = .zero /// The in-flight flip sequence, kept so a reverse flip can cancel it. @State private var flipTask: Task<Void, Never>? var body: some View { GeometryReader { proxy in let contentWidth = min(proxy.size.width - Config.sideInset * 2, Config.contentMaxWidth) let cardWidth = Config.cardWidthFraction * contentWidth let cardHeight = cardWidth * Config.cardHeightFraction let activeTilt = currentTilt let tiltStrength = min(1, sqrt(activeTilt.width * activeTilt.width + activeTilt.height * activeTilt.height)) ZStack(alignment: .top) { Config.pageBackground.ignoresSafeArea() VStack(spacing: 0) { topBar .padding(.top, Config.topInset) flippingCard( width: cardWidth, height: cardHeight, tilt: activeTilt, tiltStrength: tiltStrength ) .frame(maxWidth: .infinity) .padding(.top, Config.cardTopSpacing) ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { // Both detail blocks share the slot; only one // is visible at a time, cross-fading with the flip. ZStack(alignment: .topLeading) { ProfileDetailsView() .opacity(showsProfileDetails ? 1 : 0) .offset(y: showsProfileDetails ? 0 : Config.detailEnterOffset) .blur(radius: showsProfileDetails ? 0 : Config.profileBlur) .allowsHitTesting(showsProfileDetails) VerificationDetailsView() .opacity(showsVerificationDetails ? 1 : 0) .offset(y: showsVerificationDetails ? 0 : Config.detailEnterOffset) .blur(radius: showsVerificationDetails ? 0 : Config.verificationBlur) .allowsHitTesting(showsVerificationDetails) } .padding(.top, Config.detailTopSpacing) Spacer(minLength: 36) } } } .frame(width: contentWidth, alignment: .leading) .padding(.horizontal, Config.sideInset) } } .preferredColorScheme(.light) .onAppear { motion.start() } .onDisappear { motion.stop() flipTask?.cancel() } } /// Real device tilt when the sensor is reporting, otherwise the /// drag-preview tilt used in Simulator. private var currentTilt: CGSize { motion.isUsingDeviceMotion ? motion.tilt : fallbackTilt } /// Back arrow (only live on the verification face, flips back to the /// profile) and a close button. private var topBar: some View { HStack { Button { runFlip(toVerification: false) } label: { Image(systemName: "arrow.left") .font(.system(size: 18, weight: .regular)) .foregroundStyle(Config.ink.opacity(isShowingVerification ? 1 : 0)) .frame(width: 28, height: 28) } .disabled(!isShowingVerification) Spacer() Button { // TODO: dismiss the sheet } label: { Image(systemName: "xmark") .font(.system(size: 18, weight: .regular)) .foregroundStyle(Config.ink) .frame(width: 28, height: 28) } .buttonStyle(.plain) } } /// Front/back face swap stays tied to the card rotation, not opacity. @ViewBuilder private func flippingCard(width: CGFloat, height: CGFloat, tilt: CGSize, tiltStrength: CGFloat) -> some View { let showsBackFace = flipAngle >= 90 let cardFace = ZStack { if !showsBackFace { ProfileIdentityCard() } else { VerificationIdentityCard(tilt: tilt, tiltStrength: tiltStrength) .rotation3DEffect( .degrees(180), axis: (x: 0, y: 1, z: 0), perspective: Config.flipPerspective ) } } .frame(width: width, height: height) .contentShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) .rotation3DEffect( .degrees(flipAngle), axis: (x: 0, y: 1, z: 0), perspective: Config.flipPerspective ) .rotation3DEffect( .degrees(-Double(tilt.height) * Config.maxPitchDegrees), axis: (x: 1, y: 0, z: 0), perspective: 0.85 ) .rotation3DEffect( .degrees(Double(tilt.width) * Config.maxYawDegrees), axis: (x: 0, y: 1, z: 0), perspective: 0.85 ) .offset(y: -tiltStrength * Config.maxLift) .shadow( color: .black.opacity(isShowingVerification ? 0.16 + tiltStrength * 0.10 : 0.09), radius: isShowingVerification ? 18 + tiltStrength * Config.maxShadowRadius : 18, y: isShowingVerification ? 14 + tiltStrength * 12 : 12 ) .accessibilityAddTraits(.isButton) .background { if !showsBackFace { RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill(Color.black.opacity(0.001)) } } if isShowingVerification && !isFlipAnimating { cardFace .gesture(tiltPreviewGesture(width: width, height: height)) } else { cardFace .highPriorityGesture( TapGesture() .onEnded { runFlip(toVerification: true) } ) } } /// Runs one flip in either direction. The choreography is symmetric: /// the outgoing face's detail copy fades out first so it does not /// overlap the flip, the underlying face swaps at the halfway point /// (when the card is edge-on), and the incoming copy fades in once the /// flip lands. Driven by `Task.sleep` rather than dispatched delays so /// a reverse flip can cancel an in-flight one. private func runFlip(toVerification: Bool) { // Ignore taps that would flip toward the face already showing, or // that land mid-flip. guard isShowingVerification != toVerification, !isFlipAnimating else { return } flipTask?.cancel() flipTask = Task { @MainActor in isFlipAnimating = true withAnimation(.easeInOut(duration: Config.detailExitDuration)) { if toVerification { showsProfileDetails = false } else { showsVerificationDetails = false } } withAnimation(.smooth(duration: Config.flipDuration)) { flipAngle = toVerification ? 180 : 0 } // Swap the live state at the midpoint, when the card is edge-on // and neither face is readable. try? await Task.sleep(for: .seconds(Config.flipDuration / 2)) guard !Task.isCancelled else { return } isShowingVerification = toVerification // Let the flip finish, then bring in the new face's copy. try? await Task.sleep(for: .seconds(Config.flipDuration / 2 + Config.detailRevealGap)) guard !Task.isCancelled else { return } withAnimation(.easeOut(duration: Config.detailRevealDuration)) { if toVerification { showsVerificationDetails = true } else { showsProfileDetails = true } } // Hold the lock a beat past the reveal so a stray tap during // the settle does not start another flip. try? await Task.sleep(for: .seconds(Config.flipSettleGap)) guard !Task.isCancelled else { return } isFlipAnimating = false } } /// Drag previews the motion-driven tilt on Simulator. private func tiltPreviewGesture(width: CGFloat, height: CGFloat) -> some Gesture { DragGesture(minimumDistance: 0) .onChanged { value in guard !motion.isUsingDeviceMotion, isShowingVerification else { return } let x = ((value.location.x / width) - 0.5) * 2 let y = ((value.location.y / height) - 0.5) * 2 fallbackTilt = CGSize( width: x.clamped(to: -1...1), height: y.clamped(to: -1...1) ) } .onEnded { _ in guard !motion.isUsingDeviceMotion else { return } withAnimation(Config.fallbackSpring) { fallbackTilt = .zero } } } } // MARK: - Profile card /// The card's front (profile) face: avatar with a verified badge, name, /// and role on a plain white surface. private struct ProfileIdentityCard: View { var body: some View { VStack(spacing: 12) { ZStack(alignment: .bottomTrailing) { RemoteImage(photoID: Config.hostPhotoID, style: .portrait) .frame(width: Config.avatarSize, height: Config.avatarSize) .clipShape(Circle()) Circle() .fill(Config.badgePink) .frame(width: Config.badgeSize, height: Config.badgeSize) .overlay { Image(systemName: "checkmark.shield.fill") .font(.system(size: 14, weight: .bold)) .foregroundStyle(.white) } .offset(x: 4, y: 2) } Text(Config.hostName) .font(.system(size: Config.frontNameSize, weight: .semibold)) .foregroundStyle(Config.ink) Text(Config.hostRole) .font(.system(size: Config.roleSize, weight: .regular)) .foregroundStyle(Config.mutedInk) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.vertical, 24) .background( RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill(Config.cardFront) ) } } // MARK: - Verification card /// The card's back (verification) face: a gradient surface printed with a /// field of tiny logos, the name and verified date, a short blurb, the /// holographic portrait window, and a tilt-reactive foil sheen on top. private struct VerificationIdentityCard: View { let tilt: CGSize let tiltStrength: CGFloat var body: some View { GeometryReader { proxy in let size = proxy.size ZStack { RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill( LinearGradient( colors: [Config.cardPurple, Config.cardPink, Config.cardOrange], startPoint: .bottomLeading, endPoint: .topTrailing ) ) VerificationPattern(tilt: tilt) .clipShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) VStack(alignment: .leading, spacing: 6) { Text(Config.hostName) .font(.system(size: Config.cardTitleSize, weight: .semibold)) .foregroundStyle(.white) Text(Config.verificationDate) .font(.system(size: Config.cardTitleSize, weight: .medium)) .foregroundStyle(.white.opacity(0.92)) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.leading, Config.verificationCardHorizontalInset) .padding(.top, Config.verificationCardVerticalInset) Text(Config.verificationCardBody) .font(.system(size: Config.cardBodySize, weight: .regular)) .foregroundStyle(.white.opacity(0.88)) .lineSpacing(2) .frame(width: size.width * 0.52, alignment: .leading) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) .padding(.leading, Config.verificationCardHorizontalInset) .padding(.bottom, Config.verificationCardVerticalInset) VerificationPhotoWindow(tilt: tilt, tiltStrength: tiltStrength) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) .padding(.trailing, Config.verificationPhotoInset) .padding(.bottom, Config.verificationPhotoInset) FoilOverlay(tilt: tilt, size: size) .blendMode(.screen) } .clipShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) } } } // MARK: - Verification photo /// The small portrait window on the verification face. At rest it is a /// plain photo; as the card tilts left/right an inverted copy and a stack /// of tinted gradients fade in to read as a holographic security image. private struct VerificationPhotoWindow: View { let tilt: CGSize let tiltStrength: CGFloat var body: some View { // Strength and band positions are driven by horizontal tilt only; // the hologram peaks at full left/right and vanishes at center. let horizontalTilt = tilt.width.clamped(to: -1...1) let hologramStrength = abs(horizontalTilt) let hologramStartX = 0.14 + horizontalTilt * Config.hologramShift let hologramEndX = 0.86 - horizontalTilt * Config.hologramShift CachedRemotePhoto(photoID: Config.hostPhotoID) { image in photoLayers( base: image, hologramStrength: hologramStrength, horizontalTilt: horizontalTilt, hologramStartX: hologramStartX, hologramEndX: hologramEndX ) } fallback: { verificationFallback } .frame(width: Config.verificationPhotoSize, height: Config.verificationPhotoSize) .clipShape(RoundedRectangle(cornerRadius: Config.verificationPhotoCornerRadius, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: Config.verificationPhotoCornerRadius, style: .continuous) .stroke(Color.white.opacity(0.12 + tiltStrength * 0.08), lineWidth: 1) } } /// Stacks the base photo with the tilt-driven hologram layers: an /// inverted ghost, a diagonal color wash, a moving white streak, a /// soft-light cross gradient, and a bright spot. Every overlay's /// opacity tracks `hologramStrength`, so all of it disappears at rest. @ViewBuilder private func photoLayers( base image: Image, hologramStrength: CGFloat, horizontalTilt: CGFloat, hologramStartX: CGFloat, hologramEndX: CGFloat ) -> some View { ZStack { image .resizable() .scaledToFill() .saturation(1) .contrast(1.02) image .resizable() .scaledToFill() .grayscale(1) .contrast(1.18) .brightness(-0.03) .colorInvert() .opacity(hologramStrength) LinearGradient( colors: [ Config.xrayLavender.opacity(0.18 + hologramStrength * 0.68), Config.xrayAmber.opacity(0.14 + hologramStrength * 0.64), Config.foilBlue.opacity(0.10 + hologramStrength * 0.54) ], startPoint: UnitPoint(x: hologramStartX, y: 0.14), endPoint: UnitPoint(x: hologramEndX, y: 0.88) ) .blendMode(.color) .opacity(hologramStrength) LinearGradient( colors: [ .clear, Color.white.opacity(0.06 + hologramStrength * 0.28), .clear ], startPoint: UnitPoint(x: 0.18 + horizontalTilt * 0.10, y: 0), endPoint: UnitPoint(x: 0.82 + horizontalTilt * 0.22, y: 1) ) .blendMode(.overlay) .opacity(hologramStrength) LinearGradient( colors: [ Config.xrayAmber.opacity(0.04 + hologramStrength * 0.20), .clear, Config.xrayLavender.opacity(0.06 + hologramStrength * 0.24) ], startPoint: UnitPoint(x: 0.10 + horizontalTilt * 0.08, y: 0.10), endPoint: UnitPoint(x: 0.90 - horizontalTilt * 0.08, y: 0.92) ) .blendMode(.softLight) .opacity(hologramStrength) RadialGradient( colors: [ Color.white.opacity(0.04 + hologramStrength * 0.14), .clear ], center: UnitPoint(x: 0.50 + horizontalTilt * 0.18, y: 0.46), startRadius: 4, endRadius: Config.verificationPhotoSize * 0.72 ) .blendMode(.screen) .opacity(hologramStrength) } } /// Shown while the portrait is loading or if it fails: a soft frosted /// rectangle so the window never reads as empty. private var verificationFallback: some View { LinearGradient( colors: [ Color.white.opacity(0.28), Color.white.opacity(0.14) ], startPoint: .topLeading, endPoint: .bottomTrailing ) .overlay { RoundedRectangle(cornerRadius: Config.verificationPhotoCornerRadius, style: .continuous) .fill(Color.black.opacity(0.04)) } } } // MARK: - Foil overlay /// The iridescent sheen drawn over the whole verification face. A blurred /// diagonal gradient plus a radial highlight, both anchored to tilt so the /// shine slides as the card moves. Screen-blended and non-interactive. private struct FoilOverlay: View { let tilt: CGSize let size: CGSize var body: some View { ZStack { LinearGradient( colors: [ Color.white.opacity(0.02), Config.foilBlue.opacity(0.08), Config.foilViolet.opacity(0.10), Config.foilGold.opacity(0.08), Color.white.opacity(0.04) ], startPoint: UnitPoint(x: 0.08 + tilt.width * 0.24, y: 0.12 + tilt.height * 0.18), endPoint: UnitPoint(x: 0.92 - tilt.width * 0.24, y: 0.90 - tilt.height * 0.18) ) .blur(radius: 14) RadialGradient( colors: [ Color.white.opacity(0.12), Config.foilGold.opacity(0.08), .clear ], center: UnitPoint(x: 0.45 + tilt.width * 0.20, y: 0.38 + tilt.height * 0.14), startRadius: 8, endRadius: max(size.width, size.height) * 0.55 ) .blendMode(.screen) } .allowsHitTesting(false) } } // MARK: - Profile details /// Content below the profile face: the "about me" fact rows, the bio /// paragraph, a divider, and one sample listing. private struct ProfileDetailsView: View { var body: some View { VStack(alignment: .leading, spacing: 18) { ForEach(Config.facts) { fact in HStack(alignment: .center, spacing: 12) { Image(systemName: fact.icon) .font(.system(size: 18, weight: .regular)) .foregroundStyle(Config.ink) .frame(width: 22) if fact.underlined { Text(fact.text) .font(.system(size: Config.factSize, weight: .regular)) .foregroundStyle(Config.ink) .underline() } else { Text(fact.text) .font(.system(size: Config.factSize, weight: .regular)) .foregroundStyle(Config.ink) .fixedSize(horizontal: false, vertical: true) } } } Text(Config.profileBody) .font(.system(size: Config.bodySize, weight: .regular)) .foregroundStyle(Config.ink) .lineSpacing(3) .padding(.top, 8) Rectangle() .fill(Config.divider) .frame(height: 1) .padding(.top, 6) Text(Config.listingsTitle) .font(.system(size: Config.listingsTitleSize, weight: .semibold)) .foregroundStyle(Config.ink) .padding(.top, 4) HStack(spacing: 12) { RemoteImage(photoID: Config.listingPhotoID, style: .listing) .frame(width: Config.listingImageWidth, height: Config.listingImageHeight) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) VStack(alignment: .leading, spacing: 6) { Text(Config.listingName) .font(.system(size: Config.listingNameSize, weight: .semibold)) .foregroundStyle(Config.ink) Text(Config.listingBody) .font(.system(size: Config.listingBodySize, weight: .regular)) .foregroundStyle(Config.mutedInk) .lineSpacing(2) } } } } } // MARK: - Verification details /// Explainer paragraph shown below the card after the flip, ending in an /// underlined "Learn more" link. private struct VerificationDetailsView: View { var body: some View { // Combine the plain body with the underlined link using Text string // interpolation, the supported replacement for the deprecated // `Text + Text` and for AttributedString.underlineStyle (whose key // path is not concurrency-safe under Swift 6). let lead = Text(Config.verificationBody + " ") let link = Text(Config.learnMore).underline() Text("\(lead)\(link)") .font(.system(size: Config.factSize, weight: .regular)) .foregroundStyle(Config.ink) .lineSpacing(Config.verificationBodyLineSpacing) .fixedSize(horizontal: false, vertical: true) .padding(.trailing, Config.verificationBodyTrailingInset) } } // MARK: - Verification pattern /// The field of tiny Airbnb belo logos printed across the verification /// face, arranged as four nested spirals. With tilt the field shifts from /// dark ink (tilted left) to light ink (tilted right), like a printed /// security pattern catching the light. private struct VerificationPattern: View { let tilt: CGSize /// The printed field reads a touch low of true center once the card /// tilts in perspective; used for the highlight that tracks the spiral. private let spiralCenter = CGPoint(x: 0.50, y: 0.54) /// Four spiral arms from widest/densest to tightest/faintest. Each one /// shares the same center and winds the same direction; they differ in /// radius, glyph count, glyph scale, and opacity. private let spiralSpecs: [VerificationSpiralSpec] = [ .init(center: CGPoint(x: 0.50, y: 0.54), turns: 2.9, maxRadius: 0.50, count: 58, rotation: -.pi * 0.02, sizeScale: 1.00, opacity: 1.00, clockwise: true), .init(center: CGPoint(x: 0.50, y: 0.54), turns: 2.55, maxRadius: 0.38, count: 46, rotation: .pi * 0.06, sizeScale: 0.82, opacity: 0.86, clockwise: true), .init(center: CGPoint(x: 0.50, y: 0.54), turns: 2.15, maxRadius: 0.28, count: 34, rotation: .pi * 0.12, sizeScale: 0.66, opacity: 0.72, clockwise: true), .init(center: CGPoint(x: 0.50, y: 0.54), turns: 1.75, maxRadius: 0.20, count: 24, rotation: .pi * 0.18, sizeScale: 0.54, opacity: 0.58, clockwise: true) ] var body: some View { GeometryReader { proxy in let size = proxy.size let horizontalTilt = tilt.width.clamped(to: -1...1) // A small dead zone keeps the pattern neutral when the card is // near level, so it only "inks up" once you tilt with intent. let deadZone: CGFloat = 0.14 let leftStrength = max(0, -horizontalTilt - deadZone) / (1 - deadZone) let rightStrength = max(0, horizontalTilt - deadZone) / (1 - deadZone) let spiralStrength = max(leftStrength, rightStrength) // Tilt left inks the logos dark (multiply), tilt right inks them // light (screen); both are drawn so the field flips polarity. let leftInk = Color.black.opacity(leftStrength * 0.62) let rightInk = Color.white.opacity(rightStrength * 0.64) ZStack { spiralLayer(specs: spiralSpecs, size: size, horizontalTilt: horizontalTilt, strokeStyle: leftInk) .blendMode(.multiply) spiralLayer(specs: spiralSpecs, size: size, horizontalTilt: horizontalTilt, strokeStyle: rightInk) .blendMode(.screen) RadialGradient( colors: [ Color.white.opacity(spiralStrength * 0.10), .clear ], center: UnitPoint(x: spiralCenter.x, y: spiralCenter.y), startRadius: 10, endRadius: size.width * 0.38 ) .blendMode(.screen) } } } /// Draws all four spiral arms in one ink color. Called twice (dark and /// light) so the field can carry both polarities at once. @ViewBuilder private func spiralLayer<StrokeStyle: ShapeStyle>( specs: [VerificationSpiralSpec], size: CGSize, horizontalTilt: CGFloat, strokeStyle: StrokeStyle ) -> some View { ForEach(Array(specs.enumerated()), id: \.offset) { _, spec in SpiralLogoField( spec: spec, canvasSize: size, strokeStyle: strokeStyle, tilt: horizontalTilt, minWidth: Config.verificationSpiralMinSize * spec.sizeScale, maxWidth: Config.verificationSpiralMaxSize * spec.sizeScale ) .opacity(spec.opacity) } } } /// One spiral arm of belo logos. Places `spec.count` glyphs along an /// Archimedean-style spiral, shrinking them from `maxWidth` at the center /// to `minWidth` at the outer end and rotating each to face along the arm. private struct SpiralLogoField<StrokeStyle: ShapeStyle>: View { let spec: VerificationSpiralSpec let canvasSize: CGSize let strokeStyle: StrokeStyle let tilt: CGFloat let minWidth: CGFloat let maxWidth: CGFloat var body: some View { ZStack { ForEach(0..<spec.count, id: \.self) { index in let progress = CGFloat(index) / CGFloat(max(spec.count - 1, 1)) let spiralProgress = 1 - progress let angleDirection: CGFloat = spec.clockwise ? 1 : -1 let angle = spec.rotation + angleDirection * progress * spec.turns * .pi * 2 let radius = canvasSize.width * spec.maxRadius * pow(spiralProgress, 0.86) let center = CGPoint( x: canvasSize.width * spec.center.x, y: canvasSize.height * spec.center.y ) let x = center.x + cos(angle) * radius let y = center.y + sin(angle) * radius let width = maxWidth - progress * (maxWidth - minWidth) let height = width * 1.34 let rotation = Angle(radians: Double(angle + .pi / 2 + tilt * 0.08)) AirbnbBeloShape() .stroke(strokeStyle, lineWidth: 1.05) .frame(width: width, height: height) .opacity(0.90 - progress * 0.26) .rotationEffect(rotation) .position(x: x, y: y) } } } } /// Parameters for one spiral arm in the logo field. private struct VerificationSpiralSpec { /// Arm center in unit coordinates (0...1 of the canvas). let center: CGPoint /// Number of full turns from center to outer end. let turns: CGFloat /// Outer radius as a fraction of the canvas width. let maxRadius: CGFloat /// How many glyphs to place along the arm. let count: Int /// Starting angle offset, in radians, so the arms do not all align. let rotation: CGFloat /// Per-arm glyph size multiplier (inner arms run smaller). let sizeScale: CGFloat /// Per-arm opacity (inner arms run fainter). let opacity: CGFloat /// Winding direction. let clockwise: Bool } /// The Airbnb "belo" mark as a stroked `Shape`: the looping outline plus /// the small inner pin, drawn in a unit rect so it scales to any size. private struct AirbnbBeloShape: Shape { func path(in rect: CGRect) -> Path { var path = Path() let width = rect.width let height = rect.height func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: rect.minX + x * width, y: rect.minY + y * height) } path.move(to: point(0.50, 0.95)) path.addCurve( to: point(0.24, 0.43), control1: point(0.32, 0.82), control2: point(0.18, 0.63) ) path.addCurve( to: point(0.50, 0.10), control1: point(0.24, 0.22), control2: point(0.40, 0.07) ) path.addCurve( to: point(0.76, 0.43), control1: point(0.60, 0.07), control2: point(0.76, 0.22) ) path.addCurve( to: point(0.50, 0.95), control1: point(0.82, 0.63), control2: point(0.68, 0.82) ) path.move(to: point(0.50, 0.61)) path.addCurve( to: point(0.39, 0.49), control1: point(0.45, 0.61), control2: point(0.39, 0.56) ) path.addCurve( to: point(0.50, 0.33), control1: point(0.39, 0.41), control2: point(0.44, 0.33) ) path.addCurve( to: point(0.61, 0.49), control1: point(0.56, 0.33), control2: point(0.61, 0.41) ) path.addCurve( to: point(0.50, 0.61), control1: point(0.61, 0.56), control2: point(0.55, 0.61) ) return path } } // MARK: - Device tilt model /// Publishes a normalized tilt (each axis in -1...1) from Core Motion. /// `isUsingDeviceMotion` stays false until the first real sample arrives, /// which is how the view knows to fall back to drag-preview tilt in /// Simulator. All published changes are hopped back to the main actor. @MainActor private final class DeviceTiltModel: ObservableObject { @Published private(set) var tilt: CGSize = .zero @Published private(set) var isUsingDeviceMotion = false private let motionManager = CMMotionManager() private let motionQueue: OperationQueue = { let queue = OperationQueue() queue.name = "AirbnbIdentityVerificationCardFlipScanSnippet.DeviceMotion" return queue }() /// Begins motion updates if a sensor is present. No-op on Simulator, /// which leaves `isUsingDeviceMotion` false so the drag fallback runs. func start() { guard motionManager.isDeviceMotionAvailable else { return } // Prefer the drift-corrected frame when available for a steadier // resting attitude. let frames = CMMotionManager.availableAttitudeReferenceFrames() let referenceFrame: CMAttitudeReferenceFrame if frames.contains(.xArbitraryCorrectedZVertical) { referenceFrame = .xArbitraryCorrectedZVertical } else { referenceFrame = .xArbitraryZVertical } motionManager.deviceMotionUpdateInterval = 1 / 60 motionManager.startDeviceMotionUpdates(using: referenceFrame, to: motionQueue) { [weak self] motion, _ in guard let self, let motion else { return } // Normalize to -1...1 over a comfortable wrist range: +-45deg of // roll and +-36deg of pitch reach full tilt. let roll = (motion.attitude.roll / (.pi / 4)).clamped(to: -1...1) let pitch = (motion.attitude.pitch / (.pi / 5)).clamped(to: -1...1) Task { @MainActor in self.tilt = CGSize(width: roll, height: pitch) self.isUsingDeviceMotion = true } } } /// Stops updates and resets to a neutral, sensorless state. func stop() { motionManager.stopDeviceMotionUpdates() isUsingDeviceMotion = false tilt = .zero } } // MARK: - Remote image /// A cached remote photo filling its frame, with a style-specific /// placeholder shown while loading or on failure so a tile is never blank. private struct RemoteImage: View { let photoID: String let style: RemoteImageStyle var body: some View { CachedRemotePhoto(photoID: photoID) { image in image .resizable() .scaledToFill() } fallback: { fallbackView } } /// A simple gradient skeleton: a head-and-shoulders silhouette for the /// portrait, a card-like block layout for the listing. private var fallbackView: some View { ZStack { switch style { case .portrait: LinearGradient( colors: [Color(red: 0.83, green: 0.89, blue: 0.97), Color(red: 0.62, green: 0.74, blue: 0.88)], startPoint: .top, endPoint: .bottom ) VStack(spacing: 0) { Circle() .fill(Color.white.opacity(0.70)) .frame(width: 34, height: 34) RoundedRectangle(cornerRadius: 22, style: .continuous) .fill(Color.white.opacity(0.62)) .frame(width: 62, height: 44) .offset(y: -6) } .offset(y: 10) case .listing: LinearGradient( colors: [Color(red: 0.85, green: 0.92, blue: 0.82), Color(red: 0.70, green: 0.80, blue: 0.66)], startPoint: .topLeading, endPoint: .bottomTrailing ) VStack(spacing: 10) { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color.white.opacity(0.78)) .frame(height: 28) .padding(.horizontal, 16) HStack(spacing: 10) { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color.white.opacity(0.74)) RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color.white.opacity(0.54)) } .frame(height: 32) .padding(.horizontal, 16) } } } } } /// Picks which placeholder skeleton `RemoteImage` draws. private enum RemoteImageStyle { case portrait case listing } /// Renders a remote photo once it has loaded, otherwise its fallback. /// Backed by `RemoteImageLoader`, so the image survives view recomposition /// (every tilt frame) without dropping back to a loading state. private struct CachedRemotePhoto<Content: View, Fallback: View>: View { @StateObject private var loader: RemoteImageLoader private let content: (Image) -> Content private let fallback: Fallback init( photoID: String, @ViewBuilder content: @escaping (Image) -> Content, @ViewBuilder fallback: () -> Fallback ) { _loader = StateObject(wrappedValue: RemoteImageLoader(urlString: Self.unsplash(photoID))) self.content = content self.fallback = fallback() } var body: some View { Group { if let image = loader.image { content(Image(uiImage: image)) } else { fallback } } } /// Shared Unsplash URL builder so the cached loader and the rest of /// the file resolve the exact same remote asset. private static func unsplash(_ id: String) -> String { "https://images.unsplash.com/photo-\(id)?w=800&h=800&fit=crop&crop=faces&auto=format&q=80" } } /// Loads one image URL and publishes the result, backed by a process-wide /// `NSCache`. A second view asking for the same URL gets the cached image /// immediately, so flipping and tilting never re-fetch the portrait. @MainActor private final class RemoteImageLoader: ObservableObject { private static let cache = NSCache<NSString, UIImage>() @Published private(set) var image: UIImage? private let urlString: String private var task: Task<Void, Never>? init(urlString: String) { self.urlString = urlString if let cached = Self.cache.object(forKey: urlString as NSString) { image = cached } else { load() } } deinit { task?.cancel() } /// Fetches the image off the main actor and stores it in the shared /// cache before publishing. Skips work if the task was cancelled. private func load() { guard let url = URL(string: urlString) else { return } task = Task { guard let (data, _) = try? await URLSession.shared.data(from: url), !Task.isCancelled, let image = UIImage(data: data) else { return } Self.cache.setObject(image, forKey: urlString as NSString) self.image = image } } } // MARK: - Helpers private extension CGFloat { /// Constrains the value to `range`. Used to keep tilt and gradient /// positions inside their expected bounds. func clamped(to range: ClosedRange<CGFloat>) -> CGFloat { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } #Preview { AirbnbIdentityVerificationCardFlipScanSnippet() }
Working on SwiftUI? Have a snippet?
Submit to us
Shot
Snippet
iOS 17+ • Circular scroll • Cover Flow
Retro Music Player iPod Scroll Interaction
A SwiftUI recreation of an iPod-style music browser, with a rotating click wheel, 3D album cards, reflection styling, and spring snap scrolling.
SwiftUI
import SwiftUI struct RetroMusicPlayerIPodScrollSnippet: View { @State private var selectedIndex: Int = 0 @State private var dragOffset: CGFloat = 0 var body: some View { ZStack { Config.bodyFill .ignoresSafeArea() VStack(spacing: Config.screenToWheelGap) { CoverFlowScreen( albums: Config.albums, selectedIndex: selectedIndex, dragOffset: dragOffset ) ClickWheel( onDelta: handleWheelDelta, onEnd: snapToNearestAlbum ) } } } }
import SwiftUI struct RetroMusicPlayerIPodScrollSnippet: View { @State private var selectedIndex: Int = 0 @State private var dragOffset: CGFloat = 0 var body: some View { ZStack { Config.bodyFill .ignoresSafeArea() VStack(spacing: Config.screenToWheelGap) { CoverFlowScreen( albums: Config.albums, selectedIndex: selectedIndex, dragOffset: dragOffset ) ClickWheel( onDelta: handleWheelDelta, onEnd: snapToNearestAlbum ) } } } }
import SwiftUI struct RetroMusicPlayerIPodScrollSnippet: View { @State private var selectedIndex: Int = 0 @State private var dragOffset: CGFloat = 0 var body: some View { ZStack { Config.bodyFill .ignoresSafeArea() VStack(spacing: Config.screenToWheelGap) { CoverFlowScreen( albums: Config.albums, selectedIndex: selectedIndex, dragOffset: dragOffset ) ClickWheel( onDelta: handleWheelDelta, onEnd: snapToNearestAlbum ) } } } }
import SwiftUI struct RetroMusicPlayerIPodScrollSnippet: View { @State private var selectedIndex: Int = 0 @State private var dragOffset: CGFloat = 0 var body: some View { ZStack { Config.bodyFill .ignoresSafeArea() VStack(spacing: Config.screenToWheelGap) { CoverFlowScreen( albums: Config.albums, selectedIndex: selectedIndex, dragOffset: dragOffset ) ClickWheel( onDelta: handleWheelDelta, onEnd: snapToNearestAlbum ) } } } }
import SwiftUI // RetroMusicPlayerIPodScrollSnippet // // An iPod Classic recreated as a SwiftUI view, with a working Cover Flow album // carousel and a touch-sensitive click wheel. Circle your finger on the outer // ring of the wheel to scroll through albums: clockwise advances, // counter-clockwise goes back. Lift your finger and the nearest album snaps to // center with a spring. // // Cover Flow: the center album faces forward, flanking albums rotate on the Y // axis so they appear to recede in 3D, and each album casts a faded floor // reflection below it. The carousel follows your finger continuously during the // drag and springs to the nearest album on release. // // One file, no external dependencies. Network access is needed to fetch album // art from the Unsplash CDN. Edit Config.albums to swap in your own titles, // artists, and Unsplash photo IDs. // MARK: - Config /// All values a copy-paster might want to tweak. The rest of the file reads /// from Config so no view code needs to be touched. private enum Config { // MARK: Albums /// Placeholder albums. Each entry is a title, artist, Unsplash photo ID /// (verified 200 at time of writing), and a tint color used while the /// image loads and on network failure. static let albums: [AlbumSpec] = [ .init(title: "Golden Hour", artist: "The Midnight", imageURL: unsplash("1493225457124-a3eb161ffa5f"), tint: Color(red: 0.85, green: 0.65, blue: 0.25)), .init(title: "Neon Skyline", artist: "Crystal Coast", imageURL: unsplash("1511379938547-c1f69419868d"), tint: Color(red: 0.20, green: 0.40, blue: 0.80)), .init(title: "Pacific", artist: "Summer Wave", imageURL: unsplash("1459749411175-04bf5292ceea"), tint: Color(red: 0.30, green: 0.60, blue: 0.70)), .init(title: "Indigo Drift", artist: "Blue Era", imageURL: unsplash("1514525253161-7a46d19cd819"), tint: Color(red: 0.25, green: 0.15, blue: 0.55)), .init(title: "Echoes", artist: "Mountain Folk", imageURL: unsplash("1485579149621-3123dd979885"), tint: Color(red: 0.40, green: 0.50, blue: 0.45)), .init(title: "Alive", artist: "Spring Theory", imageURL: unsplash("1471478331149-c72f17e33c73"), tint: Color(red: 0.80, green: 0.30, blue: 0.35)), .init(title: "Velvet", artist: "City Lights", imageURL: unsplash("1514320291840-2e0a9bf2a9ae"), tint: Color(red: 0.60, green: 0.20, blue: 0.40)), .init(title: "Dusk", artist: "Eastern Wind", imageURL: unsplash("1499415479124-43c32433a620"), tint: Color(red: 0.70, green: 0.45, blue: 0.20)), ] /// Builds an Unsplash CDN URL from just the photo ID (the part after /// "photo-" in any unsplash.com URL). Square crop at 300px keeps /// thumbnails small enough to load quickly on a simulator. static func unsplash(_ id: String) -> String { "https://images.unsplash.com/photo-\(id)?w=300&h=300&fit=crop&auto=format&q=75" } // MARK: Theme /// Full-screen background: the body color fills edge-to-edge with no frame. static let bodyFill: Color = Color(red: 0.72, green: 0.73, blue: 0.77) /// Screen background: pure white inside the LCD bezel. static let screenBackground: Color = .white /// Dark bezel border around the LCD, visible against the blue-gray body. static let screenBorder: Color = Color(white: 0.38) /// Thickness of the LCD bezel stroke, in points. static let screenBorderWidth: CGFloat = 3 /// Status bar and metadata text inside the screen. static let statusText: Color = Color(white: 0.20) /// Album title color below the carousel. static let albumTitleColor: Color = .black /// Artist name color below the title. static let artistColor: Color = Color(white: 0.45) /// Click wheel ring labels (MENU, skip icons). Dimmed gray so they read /// as printed hardware labels, not interactive UI elements. static let wheelLabel: Color = Color(white: 0.80) // MARK: Layout (points) /// LCD screen height as a fraction of the safe area height. static let screenFraction: CGFloat = 0.42 /// Horizontal inset between screen edges and the body edge. static let screenInset: CGFloat = 14 /// Click wheel diameter as a fraction of the screen width. static let wheelFraction: CGFloat = 0.84 /// Center button diameter as a fraction of the wheel diameter. static let centerFraction: CGFloat = 0.32 /// Square side length of each album cover in the carousel. static let albumSize: CGFloat = 124 /// Horizontal gap between adjacent album centers. Sized so there is a /// clear gap between the center card and the ±1 cards, while keeping /// the ±2 cards partially visible at the screen edges. static let cardStep: CGFloat = 100 /// Extra cards beyond the immediate neighbor need a tighter stride than /// the center pair, otherwise perspective makes the outer gaps read too wide. static let outerCardStep: CGFloat = 64 /// Y-axis rotation (degrees) per unit of fractional distance from center. /// 65 degrees matches the dramatic foreshortening visible in the reference. static let rotationPerCard: Double = 65 /// Reflection visible height as a fraction of albumSize. static let reflectionFraction: CGFloat = 0.42 /// Opacity at the top of the reflection gradient. Matches the clearly /// visible floor reflection in the reference. static let reflectionOpacity: Double = 0.45 /// Scale reduction per unit of distance from center. Very subtle in the /// reference -- side cards are nearly the same height as the center. static let scalePerCard: CGFloat = 0.05 /// MENU label size relative to the wheel diameter. static let wheelMenuFontFraction: CGFloat = 0.045 /// Skip icon size relative to the wheel diameter. static let wheelSideIconFraction: CGFloat = 0.045 /// Play/pause icon size relative to the wheel diameter. static let wheelBottomIconFraction: CGFloat = 0.042 /// MENU vertical position as a fraction of the wheel radius. static let wheelMenuYOffset: CGFloat = 0.76 /// Side icon horizontal position as a fraction of the wheel radius. static let wheelSideXOffset: CGFloat = 0.76 /// Bottom icon vertical position as a fraction of the wheel radius. static let wheelBottomYOffset: CGFloat = 0.79 // MARK: Motion /// Album steps produced by one full clockwise rotation of the wheel (2 pi). static let scrollSensitivity: CGFloat = 3.0 /// Spring that snaps to the nearest album when the drag ends. static let snapSpring: Animation = .spring(duration: 0.38, bounce: 0.28) } // MARK: - AlbumSpec /// One album in the Cover Flow carousel. struct AlbumSpec: Identifiable, Hashable { let id = UUID() let title: String let artist: String let imageURL: String /// Solid tint for the loading placeholder and offline music-note fallback. let tint: Color } // MARK: - Implementation // MARK: - Root view /// The body color fills the entire screen edge-to-edge. The screen and wheel /// float directly on it, with no framed iPod body or surrounding chrome. struct RetroMusicPlayerIPodScrollSnippet: View { @State private var scrollPosition: CGFloat = 3 var body: some View { GeometryReader { geo in let screenWidth = geo.size.width - Config.screenInset * 2 let screenHeight = geo.size.height * Config.screenFraction let wheelDiameter = geo.size.width * Config.wheelFraction ZStack(alignment: .top) { // Paint the full body color behind the real device cutout so // the top spacer stays the same gray as the rest of the shell. Config.bodyFill.ignoresSafeArea() VStack(spacing: 0) { CoverFlowScreen( width: screenWidth, height: screenHeight, scrollPosition: scrollPosition ) .padding(.horizontal, Config.screenInset) .padding(.top, Config.screenInset) // Equal flex above and below the wheel keeps it centered // in the lower half, like the original snippet layout. Spacer() ClickWheel(diameter: wheelDiameter) { angleDelta in let step = angleDelta / (2 * .pi) * Config.scrollSensitivity scrollPosition = (scrollPosition + step) .clamped(to: 0...(CGFloat(Config.albums.count) - 1)) } onEnd: { withAnimation(Config.snapSpring) { scrollPosition = scrollPosition.rounded() } } .frame(width: wheelDiameter, height: wheelDiameter) Spacer() } } } .preferredColorScheme(.light) .statusBarHidden(true) } } // MARK: - Cover Flow screen /// The iPod's LCD area: status bar, Cover Flow carousel, album title, artist. private struct CoverFlowScreen: View { let width: CGFloat let height: CGFloat let scrollPosition: CGFloat var body: some View { VStack(spacing: 0) { // Status bar mirrors the real iPod layout: title left, icons right. HStack(spacing: 4) { Text("Cover Flow") .font(.system(size: 10.5, weight: .semibold)) .foregroundStyle(Config.statusText) Spacer() Image(systemName: "play.fill") .font(.system(size: 8, weight: .bold)) .foregroundStyle(.green) Image(systemName: "battery.75") .font(.system(size: 10)) .foregroundStyle(Config.statusText) } .padding(.horizontal, 8) .frame(height: 20) .background(Color(white: 0.91)) // Carousel with title grouped inside — both centered together. CoverFlowCarousel(scrollPosition: scrollPosition) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Config.screenBackground) } .frame(width: width, height: height) .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 5, style: .continuous) .strokeBorder(Config.screenBorder, lineWidth: Config.screenBorderWidth) ) } } // MARK: - Cover Flow carousel /// Horizontal 3D album strip with title + artist grouped immediately below. /// The whole block (art + reflection + title) is vertically centered in the /// screen area using equal Spacers above and below. private struct CoverFlowCarousel: View { let scrollPosition: CGFloat /// Total height of one card: art + reflection. private var cardHeight: CGFloat { Config.albumSize * (1 + Config.reflectionFraction) } private var selectedIndex: Int { Int(scrollPosition.rounded()) .clamped(to: 0...(Config.albums.count - 1)) } var body: some View { VStack(spacing: 0) { // Equal flex above -- centers the art+title group vertically. Spacer() // Albums on a shared floor baseline. ZStack(alignment: .bottom) { ForEach(Array(Config.albums.enumerated()), id: \.offset) { i, album in let offset = CGFloat(i) - scrollPosition let absOff = abs(offset) if absOff < 3.5 { // Clamp at ±1.0 so all side cards share the same ~65° // rotation. Negated: left faces right, right faces left. let clampedOff = offset.clamped(to: -1.0...1.0) let rotY = -Double(clampedOff) * Config.rotationPerCard let scale = max(1.0 - absOff * Config.scalePerCard, 0.70) let xShift = horizontalShift(for: offset) CoverFlowCard(album: album) .rotation3DEffect( .degrees(rotY), axis: (x: 0, y: 1, z: 0), perspective: 0.35 ) .scaleEffect(scale) // Nudge scaled cards down so all reflection floors // share the same horizontal baseline. .offset(x: xShift, y: cardHeight * (1 - scale) / 2) .zIndex(-absOff) } } } .frame(maxWidth: .infinity) .frame(height: cardHeight) // Title sits directly below the artwork -- no gap section. VStack(spacing: 3) { Text(Config.albums[selectedIndex].title) .font(.system(size: 13, weight: .bold)) .foregroundStyle(Config.albumTitleColor) .multilineTextAlignment(.center) .lineLimit(2) Text(Config.albums[selectedIndex].artist) .font(.system(size: 11)) .foregroundStyle(Config.artistColor) .lineLimit(1) } .padding(.top, 12) .padding(.horizontal, 12) // Equal flex below -- mirrors the space above. Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } /// The first step away from center stays wide so the selected card has air. /// Additional steps compress so perspective-narrowed outer cards do not leave /// oversized empty columns near the screen edges. private func horizontalShift(for offset: CGFloat) -> CGFloat { let direction: CGFloat = offset >= 0 ? 1 : -1 let distance = abs(offset) guard distance > 1 else { return offset * Config.cardStep } return direction * ( Config.cardStep + (distance - 1) * Config.outerCardStep ) } } // MARK: - Cover Flow card /// A single album card: square cover art on top, faded mirror reflection below. /// The parent carousel applies the Y-axis rotation to the whole card so the /// reflection rotates with its album, matching the source design. private struct CoverFlowCard: View { let album: AlbumSpec private var reflectionHeight: CGFloat { Config.albumSize * Config.reflectionFraction } var body: some View { VStack(spacing: 0) { AlbumArt(album: album) .frame(width: Config.albumSize, height: Config.albumSize) // Glass-floor reflection: flipped, softly blurred, and masked with // an aggressive fade so only the very top is visible. AlbumArt(album: album) .frame(width: Config.albumSize, height: Config.albumSize) .scaleEffect(x: 1, y: -1) // Slight blur gives the soft, out-of-focus look of a floor reflection. .blur(radius: 2) .frame(height: reflectionHeight, alignment: .top) .clipped() // Crop first, then fade, so the tail softens inside the // visible reflection instead of getting chopped off. .mask( LinearGradient( stops: [ .init(color: .black.opacity(Config.reflectionOpacity), location: 0.00), .init(color: .black.opacity(0.20), location: 0.28), .init(color: .black.opacity(0.06), location: 0.58), .init(color: .clear, location: 0.92), .init(color: .clear, location: 1.00), ], startPoint: .top, endPoint: .bottom ) ) } } } // MARK: - Album art /// AsyncImage with a tinted gradient placeholder and a music-note fallback /// for offline or failed loads so no tile ever appears blank. private struct AlbumArt: View { let album: AlbumSpec var body: some View { AsyncImage(url: URL(string: album.imageURL)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() .transition(.opacity.animation(.easeOut(duration: 0.35))) case .failure: Image(systemName: "music.note") .font(.system(size: Config.albumSize * 0.3)) .foregroundStyle(.white.opacity(0.7)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background( LinearGradient( colors: [album.tint, album.tint.opacity(0.6)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) case .empty: album.tint.opacity(0.8) @unknown default: album.tint.opacity(0.8) } } .frame(width: Config.albumSize, height: Config.albumSize) .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) .shadow(color: .black.opacity(0.28), radius: 4, x: 0, y: 3) } } // MARK: - Click wheel /// iPod Classic click wheel. Computes the polar angle of the drag contact /// point on each gesture update and reports the signed delta so the parent /// can scroll proportionally. The center button area is excluded from drag /// tracking so tapping the center does not accidentally scroll. private struct ClickWheel: View { let diameter: CGFloat /// Called with the signed angle change in radians: positive = clockwise. let onDelta: (CGFloat) -> Void let onEnd: () -> Void @State private var lastAngle: CGFloat? = nil private var radius: CGFloat { diameter / 2 } private var centerRadius: CGFloat { diameter * Config.centerFraction / 2 } var body: some View { ZStack { // Pure white ring, flat fill matching the reference exactly. Circle().fill(Color.white) Circle().strokeBorder(Color(white: 0.92), lineWidth: 0.6) // Four printed labels at the cardinal positions of the ring. // Small and light, matching the barely-visible printing on the // real hardware. Text("MENU") .font(.system(size: diameter * Config.wheelMenuFontFraction, weight: .bold)) .tracking(1.2) .foregroundStyle(Config.wheelLabel) .offset(y: -(radius * Config.wheelMenuYOffset)) Image(systemName: "backward.end.fill") .font(.system(size: diameter * Config.wheelSideIconFraction, weight: .regular)) .foregroundStyle(Config.wheelLabel) .offset(x: -(radius * Config.wheelSideXOffset)) Image(systemName: "forward.end.fill") .font(.system(size: diameter * Config.wheelSideIconFraction, weight: .regular)) .foregroundStyle(Config.wheelLabel) .offset(x: radius * Config.wheelSideXOffset) Image(systemName: "playpause.fill") .font(.system(size: diameter * Config.wheelBottomIconFraction, weight: .regular)) .foregroundStyle(Config.wheelLabel) .offset(y: radius * Config.wheelBottomYOffset) // Center button: clearly gray against the white ring so it reads // as a separate physical button, not part of the ring surface. Circle() .fill(Config.bodyFill) .overlay(Circle().strokeBorder(Color(white: 0.70), lineWidth: 0.4)) .frame(width: centerRadius * 2, height: centerRadius * 2) } .frame(width: diameter, height: diameter) .contentShape(Circle()) .gesture( DragGesture(minimumDistance: 2, coordinateSpace: .local) .onChanged { value in let loc = value.location let dx = loc.x - radius let dy = loc.y - radius let dist = hypot(dx, dy) // Dead zone around the center button so taps there do not // accidentally trigger scrolling. +6pt gives a generous buffer. guard dist > centerRadius + 6 else { lastAngle = nil return } let angle = atan2(dy, dx) if let prev = lastAngle { // Unwrap across the +-pi boundary so a drag that crosses // the -x axis does not produce a spurious full-circle jump. var delta = angle - prev if delta > .pi { delta -= 2 * .pi } if delta < -.pi { delta += 2 * .pi } onDelta(delta) } lastAngle = angle } .onEnded { _ in lastAngle = nil onEnd() } ) } } // MARK: - Helpers private extension CGFloat { /// Clamps the value to the given closed range. func clamped(to range: ClosedRange<CGFloat>) -> CGFloat { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } private extension Int { func clamped(to range: ClosedRange<Int>) -> Int { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } #Preview { RetroMusicPlayerIPodScrollSnippet() }
import SwiftUI // RetroMusicPlayerIPodScrollSnippet // // An iPod Classic recreated as a SwiftUI view, with a working Cover Flow album // carousel and a touch-sensitive click wheel. Circle your finger on the outer // ring of the wheel to scroll through albums: clockwise advances, // counter-clockwise goes back. Lift your finger and the nearest album snaps to // center with a spring. // // Cover Flow: the center album faces forward, flanking albums rotate on the Y // axis so they appear to recede in 3D, and each album casts a faded floor // reflection below it. The carousel follows your finger continuously during the // drag and springs to the nearest album on release. // // One file, no external dependencies. Network access is needed to fetch album // art from the Unsplash CDN. Edit Config.albums to swap in your own titles, // artists, and Unsplash photo IDs. // MARK: - Config /// All values a copy-paster might want to tweak. The rest of the file reads /// from Config so no view code needs to be touched. private enum Config { // MARK: Albums /// Placeholder albums. Each entry is a title, artist, Unsplash photo ID /// (verified 200 at time of writing), and a tint color used while the /// image loads and on network failure. static let albums: [AlbumSpec] = [ .init(title: "Golden Hour", artist: "The Midnight", imageURL: unsplash("1493225457124-a3eb161ffa5f"), tint: Color(red: 0.85, green: 0.65, blue: 0.25)), .init(title: "Neon Skyline", artist: "Crystal Coast", imageURL: unsplash("1511379938547-c1f69419868d"), tint: Color(red: 0.20, green: 0.40, blue: 0.80)), .init(title: "Pacific", artist: "Summer Wave", imageURL: unsplash("1459749411175-04bf5292ceea"), tint: Color(red: 0.30, green: 0.60, blue: 0.70)), .init(title: "Indigo Drift", artist: "Blue Era", imageURL: unsplash("1514525253161-7a46d19cd819"), tint: Color(red: 0.25, green: 0.15, blue: 0.55)), .init(title: "Echoes", artist: "Mountain Folk", imageURL: unsplash("1485579149621-3123dd979885"), tint: Color(red: 0.40, green: 0.50, blue: 0.45)), .init(title: "Alive", artist: "Spring Theory", imageURL: unsplash("1471478331149-c72f17e33c73"), tint: Color(red: 0.80, green: 0.30, blue: 0.35)), .init(title: "Velvet", artist: "City Lights", imageURL: unsplash("1514320291840-2e0a9bf2a9ae"), tint: Color(red: 0.60, green: 0.20, blue: 0.40)), .init(title: "Dusk", artist: "Eastern Wind", imageURL: unsplash("1499415479124-43c32433a620"), tint: Color(red: 0.70, green: 0.45, blue: 0.20)), ] /// Builds an Unsplash CDN URL from just the photo ID (the part after /// "photo-" in any unsplash.com URL). Square crop at 300px keeps /// thumbnails small enough to load quickly on a simulator. static func unsplash(_ id: String) -> String { "https://images.unsplash.com/photo-\(id)?w=300&h=300&fit=crop&auto=format&q=75" } // MARK: Theme /// Full-screen background: the body color fills edge-to-edge with no frame. static let bodyFill: Color = Color(red: 0.72, green: 0.73, blue: 0.77) /// Screen background: pure white inside the LCD bezel. static let screenBackground: Color = .white /// Dark bezel border around the LCD, visible against the blue-gray body. static let screenBorder: Color = Color(white: 0.38) /// Thickness of the LCD bezel stroke, in points. static let screenBorderWidth: CGFloat = 3 /// Status bar and metadata text inside the screen. static let statusText: Color = Color(white: 0.20) /// Album title color below the carousel. static let albumTitleColor: Color = .black /// Artist name color below the title. static let artistColor: Color = Color(white: 0.45) /// Click wheel ring labels (MENU, skip icons). Dimmed gray so they read /// as printed hardware labels, not interactive UI elements. static let wheelLabel: Color = Color(white: 0.80) // MARK: Layout (points) /// LCD screen height as a fraction of the safe area height. static let screenFraction: CGFloat = 0.42 /// Horizontal inset between screen edges and the body edge. static let screenInset: CGFloat = 14 /// Click wheel diameter as a fraction of the screen width. static let wheelFraction: CGFloat = 0.84 /// Center button diameter as a fraction of the wheel diameter. static let centerFraction: CGFloat = 0.32 /// Square side length of each album cover in the carousel. static let albumSize: CGFloat = 124 /// Horizontal gap between adjacent album centers. Sized so there is a /// clear gap between the center card and the ±1 cards, while keeping /// the ±2 cards partially visible at the screen edges. static let cardStep: CGFloat = 100 /// Extra cards beyond the immediate neighbor need a tighter stride than /// the center pair, otherwise perspective makes the outer gaps read too wide. static let outerCardStep: CGFloat = 64 /// Y-axis rotation (degrees) per unit of fractional distance from center. /// 65 degrees matches the dramatic foreshortening visible in the reference. static let rotationPerCard: Double = 65 /// Reflection visible height as a fraction of albumSize. static let reflectionFraction: CGFloat = 0.42 /// Opacity at the top of the reflection gradient. Matches the clearly /// visible floor reflection in the reference. static let reflectionOpacity: Double = 0.45 /// Scale reduction per unit of distance from center. Very subtle in the /// reference -- side cards are nearly the same height as the center. static let scalePerCard: CGFloat = 0.05 /// MENU label size relative to the wheel diameter. static let wheelMenuFontFraction: CGFloat = 0.045 /// Skip icon size relative to the wheel diameter. static let wheelSideIconFraction: CGFloat = 0.045 /// Play/pause icon size relative to the wheel diameter. static let wheelBottomIconFraction: CGFloat = 0.042 /// MENU vertical position as a fraction of the wheel radius. static let wheelMenuYOffset: CGFloat = 0.76 /// Side icon horizontal position as a fraction of the wheel radius. static let wheelSideXOffset: CGFloat = 0.76 /// Bottom icon vertical position as a fraction of the wheel radius. static let wheelBottomYOffset: CGFloat = 0.79 // MARK: Motion /// Album steps produced by one full clockwise rotation of the wheel (2 pi). static let scrollSensitivity: CGFloat = 3.0 /// Spring that snaps to the nearest album when the drag ends. static let snapSpring: Animation = .spring(duration: 0.38, bounce: 0.28) } // MARK: - AlbumSpec /// One album in the Cover Flow carousel. struct AlbumSpec: Identifiable, Hashable { let id = UUID() let title: String let artist: String let imageURL: String /// Solid tint for the loading placeholder and offline music-note fallback. let tint: Color } // MARK: - Implementation // MARK: - Root view /// The body color fills the entire screen edge-to-edge. The screen and wheel /// float directly on it, with no framed iPod body or surrounding chrome. struct RetroMusicPlayerIPodScrollSnippet: View { @State private var scrollPosition: CGFloat = 3 var body: some View { GeometryReader { geo in let screenWidth = geo.size.width - Config.screenInset * 2 let screenHeight = geo.size.height * Config.screenFraction let wheelDiameter = geo.size.width * Config.wheelFraction ZStack(alignment: .top) { // Paint the full body color behind the real device cutout so // the top spacer stays the same gray as the rest of the shell. Config.bodyFill.ignoresSafeArea() VStack(spacing: 0) { CoverFlowScreen( width: screenWidth, height: screenHeight, scrollPosition: scrollPosition ) .padding(.horizontal, Config.screenInset) .padding(.top, Config.screenInset) // Equal flex above and below the wheel keeps it centered // in the lower half, like the original snippet layout. Spacer() ClickWheel(diameter: wheelDiameter) { angleDelta in let step = angleDelta / (2 * .pi) * Config.scrollSensitivity scrollPosition = (scrollPosition + step) .clamped(to: 0...(CGFloat(Config.albums.count) - 1)) } onEnd: { withAnimation(Config.snapSpring) { scrollPosition = scrollPosition.rounded() } } .frame(width: wheelDiameter, height: wheelDiameter) Spacer() } } } .preferredColorScheme(.light) .statusBarHidden(true) } } // MARK: - Cover Flow screen /// The iPod's LCD area: status bar, Cover Flow carousel, album title, artist. private struct CoverFlowScreen: View { let width: CGFloat let height: CGFloat let scrollPosition: CGFloat var body: some View { VStack(spacing: 0) { // Status bar mirrors the real iPod layout: title left, icons right. HStack(spacing: 4) { Text("Cover Flow") .font(.system(size: 10.5, weight: .semibold)) .foregroundStyle(Config.statusText) Spacer() Image(systemName: "play.fill") .font(.system(size: 8, weight: .bold)) .foregroundStyle(.green) Image(systemName: "battery.75") .font(.system(size: 10)) .foregroundStyle(Config.statusText) } .padding(.horizontal, 8) .frame(height: 20) .background(Color(white: 0.91)) // Carousel with title grouped inside — both centered together. CoverFlowCarousel(scrollPosition: scrollPosition) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Config.screenBackground) } .frame(width: width, height: height) .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 5, style: .continuous) .strokeBorder(Config.screenBorder, lineWidth: Config.screenBorderWidth) ) } } // MARK: - Cover Flow carousel /// Horizontal 3D album strip with title + artist grouped immediately below. /// The whole block (art + reflection + title) is vertically centered in the /// screen area using equal Spacers above and below. private struct CoverFlowCarousel: View { let scrollPosition: CGFloat /// Total height of one card: art + reflection. private var cardHeight: CGFloat { Config.albumSize * (1 + Config.reflectionFraction) } private var selectedIndex: Int { Int(scrollPosition.rounded()) .clamped(to: 0...(Config.albums.count - 1)) } var body: some View { VStack(spacing: 0) { // Equal flex above -- centers the art+title group vertically. Spacer() // Albums on a shared floor baseline. ZStack(alignment: .bottom) { ForEach(Array(Config.albums.enumerated()), id: \.offset) { i, album in let offset = CGFloat(i) - scrollPosition let absOff = abs(offset) if absOff < 3.5 { // Clamp at ±1.0 so all side cards share the same ~65° // rotation. Negated: left faces right, right faces left. let clampedOff = offset.clamped(to: -1.0...1.0) let rotY = -Double(clampedOff) * Config.rotationPerCard let scale = max(1.0 - absOff * Config.scalePerCard, 0.70) let xShift = horizontalShift(for: offset) CoverFlowCard(album: album) .rotation3DEffect( .degrees(rotY), axis: (x: 0, y: 1, z: 0), perspective: 0.35 ) .scaleEffect(scale) // Nudge scaled cards down so all reflection floors // share the same horizontal baseline. .offset(x: xShift, y: cardHeight * (1 - scale) / 2) .zIndex(-absOff) } } } .frame(maxWidth: .infinity) .frame(height: cardHeight) // Title sits directly below the artwork -- no gap section. VStack(spacing: 3) { Text(Config.albums[selectedIndex].title) .font(.system(size: 13, weight: .bold)) .foregroundStyle(Config.albumTitleColor) .multilineTextAlignment(.center) .lineLimit(2) Text(Config.albums[selectedIndex].artist) .font(.system(size: 11)) .foregroundStyle(Config.artistColor) .lineLimit(1) } .padding(.top, 12) .padding(.horizontal, 12) // Equal flex below -- mirrors the space above. Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } /// The first step away from center stays wide so the selected card has air. /// Additional steps compress so perspective-narrowed outer cards do not leave /// oversized empty columns near the screen edges. private func horizontalShift(for offset: CGFloat) -> CGFloat { let direction: CGFloat = offset >= 0 ? 1 : -1 let distance = abs(offset) guard distance > 1 else { return offset * Config.cardStep } return direction * ( Config.cardStep + (distance - 1) * Config.outerCardStep ) } } // MARK: - Cover Flow card /// A single album card: square cover art on top, faded mirror reflection below. /// The parent carousel applies the Y-axis rotation to the whole card so the /// reflection rotates with its album, matching the source design. private struct CoverFlowCard: View { let album: AlbumSpec private var reflectionHeight: CGFloat { Config.albumSize * Config.reflectionFraction } var body: some View { VStack(spacing: 0) { AlbumArt(album: album) .frame(width: Config.albumSize, height: Config.albumSize) // Glass-floor reflection: flipped, softly blurred, and masked with // an aggressive fade so only the very top is visible. AlbumArt(album: album) .frame(width: Config.albumSize, height: Config.albumSize) .scaleEffect(x: 1, y: -1) // Slight blur gives the soft, out-of-focus look of a floor reflection. .blur(radius: 2) .frame(height: reflectionHeight, alignment: .top) .clipped() // Crop first, then fade, so the tail softens inside the // visible reflection instead of getting chopped off. .mask( LinearGradient( stops: [ .init(color: .black.opacity(Config.reflectionOpacity), location: 0.00), .init(color: .black.opacity(0.20), location: 0.28), .init(color: .black.opacity(0.06), location: 0.58), .init(color: .clear, location: 0.92), .init(color: .clear, location: 1.00), ], startPoint: .top, endPoint: .bottom ) ) } } } // MARK: - Album art /// AsyncImage with a tinted gradient placeholder and a music-note fallback /// for offline or failed loads so no tile ever appears blank. private struct AlbumArt: View { let album: AlbumSpec var body: some View { AsyncImage(url: URL(string: album.imageURL)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() .transition(.opacity.animation(.easeOut(duration: 0.35))) case .failure: Image(systemName: "music.note") .font(.system(size: Config.albumSize * 0.3)) .foregroundStyle(.white.opacity(0.7)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background( LinearGradient( colors: [album.tint, album.tint.opacity(0.6)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) case .empty: album.tint.opacity(0.8) @unknown default: album.tint.opacity(0.8) } } .frame(width: Config.albumSize, height: Config.albumSize) .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) .shadow(color: .black.opacity(0.28), radius: 4, x: 0, y: 3) } } // MARK: - Click wheel /// iPod Classic click wheel. Computes the polar angle of the drag contact /// point on each gesture update and reports the signed delta so the parent /// can scroll proportionally. The center button area is excluded from drag /// tracking so tapping the center does not accidentally scroll. private struct ClickWheel: View { let diameter: CGFloat /// Called with the signed angle change in radians: positive = clockwise. let onDelta: (CGFloat) -> Void let onEnd: () -> Void @State private var lastAngle: CGFloat? = nil private var radius: CGFloat { diameter / 2 } private var centerRadius: CGFloat { diameter * Config.centerFraction / 2 } var body: some View { ZStack { // Pure white ring, flat fill matching the reference exactly. Circle().fill(Color.white) Circle().strokeBorder(Color(white: 0.92), lineWidth: 0.6) // Four printed labels at the cardinal positions of the ring. // Small and light, matching the barely-visible printing on the // real hardware. Text("MENU") .font(.system(size: diameter * Config.wheelMenuFontFraction, weight: .bold)) .tracking(1.2) .foregroundStyle(Config.wheelLabel) .offset(y: -(radius * Config.wheelMenuYOffset)) Image(systemName: "backward.end.fill") .font(.system(size: diameter * Config.wheelSideIconFraction, weight: .regular)) .foregroundStyle(Config.wheelLabel) .offset(x: -(radius * Config.wheelSideXOffset)) Image(systemName: "forward.end.fill") .font(.system(size: diameter * Config.wheelSideIconFraction, weight: .regular)) .foregroundStyle(Config.wheelLabel) .offset(x: radius * Config.wheelSideXOffset) Image(systemName: "playpause.fill") .font(.system(size: diameter * Config.wheelBottomIconFraction, weight: .regular)) .foregroundStyle(Config.wheelLabel) .offset(y: radius * Config.wheelBottomYOffset) // Center button: clearly gray against the white ring so it reads // as a separate physical button, not part of the ring surface. Circle() .fill(Config.bodyFill) .overlay(Circle().strokeBorder(Color(white: 0.70), lineWidth: 0.4)) .frame(width: centerRadius * 2, height: centerRadius * 2) } .frame(width: diameter, height: diameter) .contentShape(Circle()) .gesture( DragGesture(minimumDistance: 2, coordinateSpace: .local) .onChanged { value in let loc = value.location let dx = loc.x - radius let dy = loc.y - radius let dist = hypot(dx, dy) // Dead zone around the center button so taps there do not // accidentally trigger scrolling. +6pt gives a generous buffer. guard dist > centerRadius + 6 else { lastAngle = nil return } let angle = atan2(dy, dx) if let prev = lastAngle { // Unwrap across the +-pi boundary so a drag that crosses // the -x axis does not produce a spurious full-circle jump. var delta = angle - prev if delta > .pi { delta -= 2 * .pi } if delta < -.pi { delta += 2 * .pi } onDelta(delta) } lastAngle = angle } .onEnded { _ in lastAngle = nil onEnd() } ) } } // MARK: - Helpers private extension CGFloat { /// Clamps the value to the given closed range. func clamped(to range: ClosedRange<CGFloat>) -> CGFloat { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } private extension Int { func clamped(to range: ClosedRange<Int>) -> Int { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } #Preview { RetroMusicPlayerIPodScrollSnippet() }
import SwiftUI // RetroMusicPlayerIPodScrollSnippet // // An iPod Classic recreated as a SwiftUI view, with a working Cover Flow album // carousel and a touch-sensitive click wheel. Circle your finger on the outer // ring of the wheel to scroll through albums: clockwise advances, // counter-clockwise goes back. Lift your finger and the nearest album snaps to // center with a spring. // // Cover Flow: the center album faces forward, flanking albums rotate on the Y // axis so they appear to recede in 3D, and each album casts a faded floor // reflection below it. The carousel follows your finger continuously during the // drag and springs to the nearest album on release. // // One file, no external dependencies. Network access is needed to fetch album // art from the Unsplash CDN. Edit Config.albums to swap in your own titles, // artists, and Unsplash photo IDs. // MARK: - Config /// All values a copy-paster might want to tweak. The rest of the file reads /// from Config so no view code needs to be touched. private enum Config { // MARK: Albums /// Placeholder albums. Each entry is a title, artist, Unsplash photo ID /// (verified 200 at time of writing), and a tint color used while the /// image loads and on network failure. static let albums: [AlbumSpec] = [ .init(title: "Golden Hour", artist: "The Midnight", imageURL: unsplash("1493225457124-a3eb161ffa5f"), tint: Color(red: 0.85, green: 0.65, blue: 0.25)), .init(title: "Neon Skyline", artist: "Crystal Coast", imageURL: unsplash("1511379938547-c1f69419868d"), tint: Color(red: 0.20, green: 0.40, blue: 0.80)), .init(title: "Pacific", artist: "Summer Wave", imageURL: unsplash("1459749411175-04bf5292ceea"), tint: Color(red: 0.30, green: 0.60, blue: 0.70)), .init(title: "Indigo Drift", artist: "Blue Era", imageURL: unsplash("1514525253161-7a46d19cd819"), tint: Color(red: 0.25, green: 0.15, blue: 0.55)), .init(title: "Echoes", artist: "Mountain Folk", imageURL: unsplash("1485579149621-3123dd979885"), tint: Color(red: 0.40, green: 0.50, blue: 0.45)), .init(title: "Alive", artist: "Spring Theory", imageURL: unsplash("1471478331149-c72f17e33c73"), tint: Color(red: 0.80, green: 0.30, blue: 0.35)), .init(title: "Velvet", artist: "City Lights", imageURL: unsplash("1514320291840-2e0a9bf2a9ae"), tint: Color(red: 0.60, green: 0.20, blue: 0.40)), .init(title: "Dusk", artist: "Eastern Wind", imageURL: unsplash("1499415479124-43c32433a620"), tint: Color(red: 0.70, green: 0.45, blue: 0.20)), ] /// Builds an Unsplash CDN URL from just the photo ID (the part after /// "photo-" in any unsplash.com URL). Square crop at 300px keeps /// thumbnails small enough to load quickly on a simulator. static func unsplash(_ id: String) -> String { "https://images.unsplash.com/photo-\(id)?w=300&h=300&fit=crop&auto=format&q=75" } // MARK: Theme /// Full-screen background: the body color fills edge-to-edge with no frame. static let bodyFill: Color = Color(red: 0.72, green: 0.73, blue: 0.77) /// Screen background: pure white inside the LCD bezel. static let screenBackground: Color = .white /// Dark bezel border around the LCD, visible against the blue-gray body. static let screenBorder: Color = Color(white: 0.38) /// Thickness of the LCD bezel stroke, in points. static let screenBorderWidth: CGFloat = 3 /// Status bar and metadata text inside the screen. static let statusText: Color = Color(white: 0.20) /// Album title color below the carousel. static let albumTitleColor: Color = .black /// Artist name color below the title. static let artistColor: Color = Color(white: 0.45) /// Click wheel ring labels (MENU, skip icons). Dimmed gray so they read /// as printed hardware labels, not interactive UI elements. static let wheelLabel: Color = Color(white: 0.80) // MARK: Layout (points) /// LCD screen height as a fraction of the safe area height. static let screenFraction: CGFloat = 0.42 /// Horizontal inset between screen edges and the body edge. static let screenInset: CGFloat = 14 /// Click wheel diameter as a fraction of the screen width. static let wheelFraction: CGFloat = 0.84 /// Center button diameter as a fraction of the wheel diameter. static let centerFraction: CGFloat = 0.32 /// Square side length of each album cover in the carousel. static let albumSize: CGFloat = 124 /// Horizontal gap between adjacent album centers. Sized so there is a /// clear gap between the center card and the ±1 cards, while keeping /// the ±2 cards partially visible at the screen edges. static let cardStep: CGFloat = 100 /// Extra cards beyond the immediate neighbor need a tighter stride than /// the center pair, otherwise perspective makes the outer gaps read too wide. static let outerCardStep: CGFloat = 64 /// Y-axis rotation (degrees) per unit of fractional distance from center. /// 65 degrees matches the dramatic foreshortening visible in the reference. static let rotationPerCard: Double = 65 /// Reflection visible height as a fraction of albumSize. static let reflectionFraction: CGFloat = 0.42 /// Opacity at the top of the reflection gradient. Matches the clearly /// visible floor reflection in the reference. static let reflectionOpacity: Double = 0.45 /// Scale reduction per unit of distance from center. Very subtle in the /// reference -- side cards are nearly the same height as the center. static let scalePerCard: CGFloat = 0.05 /// MENU label size relative to the wheel diameter. static let wheelMenuFontFraction: CGFloat = 0.045 /// Skip icon size relative to the wheel diameter. static let wheelSideIconFraction: CGFloat = 0.045 /// Play/pause icon size relative to the wheel diameter. static let wheelBottomIconFraction: CGFloat = 0.042 /// MENU vertical position as a fraction of the wheel radius. static let wheelMenuYOffset: CGFloat = 0.76 /// Side icon horizontal position as a fraction of the wheel radius. static let wheelSideXOffset: CGFloat = 0.76 /// Bottom icon vertical position as a fraction of the wheel radius. static let wheelBottomYOffset: CGFloat = 0.79 // MARK: Motion /// Album steps produced by one full clockwise rotation of the wheel (2 pi). static let scrollSensitivity: CGFloat = 3.0 /// Spring that snaps to the nearest album when the drag ends. static let snapSpring: Animation = .spring(duration: 0.38, bounce: 0.28) } // MARK: - AlbumSpec /// One album in the Cover Flow carousel. struct AlbumSpec: Identifiable, Hashable { let id = UUID() let title: String let artist: String let imageURL: String /// Solid tint for the loading placeholder and offline music-note fallback. let tint: Color } // MARK: - Implementation // MARK: - Root view /// The body color fills the entire screen edge-to-edge. The screen and wheel /// float directly on it, with no framed iPod body or surrounding chrome. struct RetroMusicPlayerIPodScrollSnippet: View { @State private var scrollPosition: CGFloat = 3 var body: some View { GeometryReader { geo in let screenWidth = geo.size.width - Config.screenInset * 2 let screenHeight = geo.size.height * Config.screenFraction let wheelDiameter = geo.size.width * Config.wheelFraction ZStack(alignment: .top) { // Paint the full body color behind the real device cutout so // the top spacer stays the same gray as the rest of the shell. Config.bodyFill.ignoresSafeArea() VStack(spacing: 0) { CoverFlowScreen( width: screenWidth, height: screenHeight, scrollPosition: scrollPosition ) .padding(.horizontal, Config.screenInset) .padding(.top, Config.screenInset) // Equal flex above and below the wheel keeps it centered // in the lower half, like the original snippet layout. Spacer() ClickWheel(diameter: wheelDiameter) { angleDelta in let step = angleDelta / (2 * .pi) * Config.scrollSensitivity scrollPosition = (scrollPosition + step) .clamped(to: 0...(CGFloat(Config.albums.count) - 1)) } onEnd: { withAnimation(Config.snapSpring) { scrollPosition = scrollPosition.rounded() } } .frame(width: wheelDiameter, height: wheelDiameter) Spacer() } } } .preferredColorScheme(.light) .statusBarHidden(true) } } // MARK: - Cover Flow screen /// The iPod's LCD area: status bar, Cover Flow carousel, album title, artist. private struct CoverFlowScreen: View { let width: CGFloat let height: CGFloat let scrollPosition: CGFloat var body: some View { VStack(spacing: 0) { // Status bar mirrors the real iPod layout: title left, icons right. HStack(spacing: 4) { Text("Cover Flow") .font(.system(size: 10.5, weight: .semibold)) .foregroundStyle(Config.statusText) Spacer() Image(systemName: "play.fill") .font(.system(size: 8, weight: .bold)) .foregroundStyle(.green) Image(systemName: "battery.75") .font(.system(size: 10)) .foregroundStyle(Config.statusText) } .padding(.horizontal, 8) .frame(height: 20) .background(Color(white: 0.91)) // Carousel with title grouped inside — both centered together. CoverFlowCarousel(scrollPosition: scrollPosition) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Config.screenBackground) } .frame(width: width, height: height) .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 5, style: .continuous) .strokeBorder(Config.screenBorder, lineWidth: Config.screenBorderWidth) ) } } // MARK: - Cover Flow carousel /// Horizontal 3D album strip with title + artist grouped immediately below. /// The whole block (art + reflection + title) is vertically centered in the /// screen area using equal Spacers above and below. private struct CoverFlowCarousel: View { let scrollPosition: CGFloat /// Total height of one card: art + reflection. private var cardHeight: CGFloat { Config.albumSize * (1 + Config.reflectionFraction) } private var selectedIndex: Int { Int(scrollPosition.rounded()) .clamped(to: 0...(Config.albums.count - 1)) } var body: some View { VStack(spacing: 0) { // Equal flex above -- centers the art+title group vertically. Spacer() // Albums on a shared floor baseline. ZStack(alignment: .bottom) { ForEach(Array(Config.albums.enumerated()), id: \.offset) { i, album in let offset = CGFloat(i) - scrollPosition let absOff = abs(offset) if absOff < 3.5 { // Clamp at ±1.0 so all side cards share the same ~65° // rotation. Negated: left faces right, right faces left. let clampedOff = offset.clamped(to: -1.0...1.0) let rotY = -Double(clampedOff) * Config.rotationPerCard let scale = max(1.0 - absOff * Config.scalePerCard, 0.70) let xShift = horizontalShift(for: offset) CoverFlowCard(album: album) .rotation3DEffect( .degrees(rotY), axis: (x: 0, y: 1, z: 0), perspective: 0.35 ) .scaleEffect(scale) // Nudge scaled cards down so all reflection floors // share the same horizontal baseline. .offset(x: xShift, y: cardHeight * (1 - scale) / 2) .zIndex(-absOff) } } } .frame(maxWidth: .infinity) .frame(height: cardHeight) // Title sits directly below the artwork -- no gap section. VStack(spacing: 3) { Text(Config.albums[selectedIndex].title) .font(.system(size: 13, weight: .bold)) .foregroundStyle(Config.albumTitleColor) .multilineTextAlignment(.center) .lineLimit(2) Text(Config.albums[selectedIndex].artist) .font(.system(size: 11)) .foregroundStyle(Config.artistColor) .lineLimit(1) } .padding(.top, 12) .padding(.horizontal, 12) // Equal flex below -- mirrors the space above. Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } /// The first step away from center stays wide so the selected card has air. /// Additional steps compress so perspective-narrowed outer cards do not leave /// oversized empty columns near the screen edges. private func horizontalShift(for offset: CGFloat) -> CGFloat { let direction: CGFloat = offset >= 0 ? 1 : -1 let distance = abs(offset) guard distance > 1 else { return offset * Config.cardStep } return direction * ( Config.cardStep + (distance - 1) * Config.outerCardStep ) } } // MARK: - Cover Flow card /// A single album card: square cover art on top, faded mirror reflection below. /// The parent carousel applies the Y-axis rotation to the whole card so the /// reflection rotates with its album, matching the source design. private struct CoverFlowCard: View { let album: AlbumSpec private var reflectionHeight: CGFloat { Config.albumSize * Config.reflectionFraction } var body: some View { VStack(spacing: 0) { AlbumArt(album: album) .frame(width: Config.albumSize, height: Config.albumSize) // Glass-floor reflection: flipped, softly blurred, and masked with // an aggressive fade so only the very top is visible. AlbumArt(album: album) .frame(width: Config.albumSize, height: Config.albumSize) .scaleEffect(x: 1, y: -1) // Slight blur gives the soft, out-of-focus look of a floor reflection. .blur(radius: 2) .frame(height: reflectionHeight, alignment: .top) .clipped() // Crop first, then fade, so the tail softens inside the // visible reflection instead of getting chopped off. .mask( LinearGradient( stops: [ .init(color: .black.opacity(Config.reflectionOpacity), location: 0.00), .init(color: .black.opacity(0.20), location: 0.28), .init(color: .black.opacity(0.06), location: 0.58), .init(color: .clear, location: 0.92), .init(color: .clear, location: 1.00), ], startPoint: .top, endPoint: .bottom ) ) } } } // MARK: - Album art /// AsyncImage with a tinted gradient placeholder and a music-note fallback /// for offline or failed loads so no tile ever appears blank. private struct AlbumArt: View { let album: AlbumSpec var body: some View { AsyncImage(url: URL(string: album.imageURL)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() .transition(.opacity.animation(.easeOut(duration: 0.35))) case .failure: Image(systemName: "music.note") .font(.system(size: Config.albumSize * 0.3)) .foregroundStyle(.white.opacity(0.7)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background( LinearGradient( colors: [album.tint, album.tint.opacity(0.6)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) case .empty: album.tint.opacity(0.8) @unknown default: album.tint.opacity(0.8) } } .frame(width: Config.albumSize, height: Config.albumSize) .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) .shadow(color: .black.opacity(0.28), radius: 4, x: 0, y: 3) } } // MARK: - Click wheel /// iPod Classic click wheel. Computes the polar angle of the drag contact /// point on each gesture update and reports the signed delta so the parent /// can scroll proportionally. The center button area is excluded from drag /// tracking so tapping the center does not accidentally scroll. private struct ClickWheel: View { let diameter: CGFloat /// Called with the signed angle change in radians: positive = clockwise. let onDelta: (CGFloat) -> Void let onEnd: () -> Void @State private var lastAngle: CGFloat? = nil private var radius: CGFloat { diameter / 2 } private var centerRadius: CGFloat { diameter * Config.centerFraction / 2 } var body: some View { ZStack { // Pure white ring, flat fill matching the reference exactly. Circle().fill(Color.white) Circle().strokeBorder(Color(white: 0.92), lineWidth: 0.6) // Four printed labels at the cardinal positions of the ring. // Small and light, matching the barely-visible printing on the // real hardware. Text("MENU") .font(.system(size: diameter * Config.wheelMenuFontFraction, weight: .bold)) .tracking(1.2) .foregroundStyle(Config.wheelLabel) .offset(y: -(radius * Config.wheelMenuYOffset)) Image(systemName: "backward.end.fill") .font(.system(size: diameter * Config.wheelSideIconFraction, weight: .regular)) .foregroundStyle(Config.wheelLabel) .offset(x: -(radius * Config.wheelSideXOffset)) Image(systemName: "forward.end.fill") .font(.system(size: diameter * Config.wheelSideIconFraction, weight: .regular)) .foregroundStyle(Config.wheelLabel) .offset(x: radius * Config.wheelSideXOffset) Image(systemName: "playpause.fill") .font(.system(size: diameter * Config.wheelBottomIconFraction, weight: .regular)) .foregroundStyle(Config.wheelLabel) .offset(y: radius * Config.wheelBottomYOffset) // Center button: clearly gray against the white ring so it reads // as a separate physical button, not part of the ring surface. Circle() .fill(Config.bodyFill) .overlay(Circle().strokeBorder(Color(white: 0.70), lineWidth: 0.4)) .frame(width: centerRadius * 2, height: centerRadius * 2) } .frame(width: diameter, height: diameter) .contentShape(Circle()) .gesture( DragGesture(minimumDistance: 2, coordinateSpace: .local) .onChanged { value in let loc = value.location let dx = loc.x - radius let dy = loc.y - radius let dist = hypot(dx, dy) // Dead zone around the center button so taps there do not // accidentally trigger scrolling. +6pt gives a generous buffer. guard dist > centerRadius + 6 else { lastAngle = nil return } let angle = atan2(dy, dx) if let prev = lastAngle { // Unwrap across the +-pi boundary so a drag that crosses // the -x axis does not produce a spurious full-circle jump. var delta = angle - prev if delta > .pi { delta -= 2 * .pi } if delta < -.pi { delta += 2 * .pi } onDelta(delta) } lastAngle = angle } .onEnded { _ in lastAngle = nil onEnd() } ) } } // MARK: - Helpers private extension CGFloat { /// Clamps the value to the given closed range. func clamped(to range: ClosedRange<CGFloat>) -> CGFloat { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } private extension Int { func clamped(to range: ClosedRange<Int>) -> Int { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } #Preview { RetroMusicPlayerIPodScrollSnippet() }
import SwiftUI // RetroMusicPlayerIPodScrollSnippet // // An iPod Classic recreated as a SwiftUI view, with a working Cover Flow album // carousel and a touch-sensitive click wheel. Circle your finger on the outer // ring of the wheel to scroll through albums: clockwise advances, // counter-clockwise goes back. Lift your finger and the nearest album snaps to // center with a spring. // // Cover Flow: the center album faces forward, flanking albums rotate on the Y // axis so they appear to recede in 3D, and each album casts a faded floor // reflection below it. The carousel follows your finger continuously during the // drag and springs to the nearest album on release. // // One file, no external dependencies. Network access is needed to fetch album // art from the Unsplash CDN. Edit Config.albums to swap in your own titles, // artists, and Unsplash photo IDs. // MARK: - Config /// All values a copy-paster might want to tweak. The rest of the file reads /// from Config so no view code needs to be touched. private enum Config { // MARK: Albums /// Placeholder albums. Each entry is a title, artist, Unsplash photo ID /// (verified 200 at time of writing), and a tint color used while the /// image loads and on network failure. static let albums: [AlbumSpec] = [ .init(title: "Golden Hour", artist: "The Midnight", imageURL: unsplash("1493225457124-a3eb161ffa5f"), tint: Color(red: 0.85, green: 0.65, blue: 0.25)), .init(title: "Neon Skyline", artist: "Crystal Coast", imageURL: unsplash("1511379938547-c1f69419868d"), tint: Color(red: 0.20, green: 0.40, blue: 0.80)), .init(title: "Pacific", artist: "Summer Wave", imageURL: unsplash("1459749411175-04bf5292ceea"), tint: Color(red: 0.30, green: 0.60, blue: 0.70)), .init(title: "Indigo Drift", artist: "Blue Era", imageURL: unsplash("1514525253161-7a46d19cd819"), tint: Color(red: 0.25, green: 0.15, blue: 0.55)), .init(title: "Echoes", artist: "Mountain Folk", imageURL: unsplash("1485579149621-3123dd979885"), tint: Color(red: 0.40, green: 0.50, blue: 0.45)), .init(title: "Alive", artist: "Spring Theory", imageURL: unsplash("1471478331149-c72f17e33c73"), tint: Color(red: 0.80, green: 0.30, blue: 0.35)), .init(title: "Velvet", artist: "City Lights", imageURL: unsplash("1514320291840-2e0a9bf2a9ae"), tint: Color(red: 0.60, green: 0.20, blue: 0.40)), .init(title: "Dusk", artist: "Eastern Wind", imageURL: unsplash("1499415479124-43c32433a620"), tint: Color(red: 0.70, green: 0.45, blue: 0.20)), ] /// Builds an Unsplash CDN URL from just the photo ID (the part after /// "photo-" in any unsplash.com URL). Square crop at 300px keeps /// thumbnails small enough to load quickly on a simulator. static func unsplash(_ id: String) -> String { "https://images.unsplash.com/photo-\(id)?w=300&h=300&fit=crop&auto=format&q=75" } // MARK: Theme /// Full-screen background: the body color fills edge-to-edge with no frame. static let bodyFill: Color = Color(red: 0.72, green: 0.73, blue: 0.77) /// Screen background: pure white inside the LCD bezel. static let screenBackground: Color = .white /// Dark bezel border around the LCD, visible against the blue-gray body. static let screenBorder: Color = Color(white: 0.38) /// Thickness of the LCD bezel stroke, in points. static let screenBorderWidth: CGFloat = 3 /// Status bar and metadata text inside the screen. static let statusText: Color = Color(white: 0.20) /// Album title color below the carousel. static let albumTitleColor: Color = .black /// Artist name color below the title. static let artistColor: Color = Color(white: 0.45) /// Click wheel ring labels (MENU, skip icons). Dimmed gray so they read /// as printed hardware labels, not interactive UI elements. static let wheelLabel: Color = Color(white: 0.80) // MARK: Layout (points) /// LCD screen height as a fraction of the safe area height. static let screenFraction: CGFloat = 0.42 /// Horizontal inset between screen edges and the body edge. static let screenInset: CGFloat = 14 /// Click wheel diameter as a fraction of the screen width. static let wheelFraction: CGFloat = 0.84 /// Center button diameter as a fraction of the wheel diameter. static let centerFraction: CGFloat = 0.32 /// Square side length of each album cover in the carousel. static let albumSize: CGFloat = 124 /// Horizontal gap between adjacent album centers. Sized so there is a /// clear gap between the center card and the ±1 cards, while keeping /// the ±2 cards partially visible at the screen edges. static let cardStep: CGFloat = 100 /// Extra cards beyond the immediate neighbor need a tighter stride than /// the center pair, otherwise perspective makes the outer gaps read too wide. static let outerCardStep: CGFloat = 64 /// Y-axis rotation (degrees) per unit of fractional distance from center. /// 65 degrees matches the dramatic foreshortening visible in the reference. static let rotationPerCard: Double = 65 /// Reflection visible height as a fraction of albumSize. static let reflectionFraction: CGFloat = 0.42 /// Opacity at the top of the reflection gradient. Matches the clearly /// visible floor reflection in the reference. static let reflectionOpacity: Double = 0.45 /// Scale reduction per unit of distance from center. Very subtle in the /// reference -- side cards are nearly the same height as the center. static let scalePerCard: CGFloat = 0.05 /// MENU label size relative to the wheel diameter. static let wheelMenuFontFraction: CGFloat = 0.045 /// Skip icon size relative to the wheel diameter. static let wheelSideIconFraction: CGFloat = 0.045 /// Play/pause icon size relative to the wheel diameter. static let wheelBottomIconFraction: CGFloat = 0.042 /// MENU vertical position as a fraction of the wheel radius. static let wheelMenuYOffset: CGFloat = 0.76 /// Side icon horizontal position as a fraction of the wheel radius. static let wheelSideXOffset: CGFloat = 0.76 /// Bottom icon vertical position as a fraction of the wheel radius. static let wheelBottomYOffset: CGFloat = 0.79 // MARK: Motion /// Album steps produced by one full clockwise rotation of the wheel (2 pi). static let scrollSensitivity: CGFloat = 3.0 /// Spring that snaps to the nearest album when the drag ends. static let snapSpring: Animation = .spring(duration: 0.38, bounce: 0.28) } // MARK: - AlbumSpec /// One album in the Cover Flow carousel. struct AlbumSpec: Identifiable, Hashable { let id = UUID() let title: String let artist: String let imageURL: String /// Solid tint for the loading placeholder and offline music-note fallback. let tint: Color } // MARK: - Implementation // MARK: - Root view /// The body color fills the entire screen edge-to-edge. The screen and wheel /// float directly on it, with no framed iPod body or surrounding chrome. struct RetroMusicPlayerIPodScrollSnippet: View { @State private var scrollPosition: CGFloat = 3 var body: some View { GeometryReader { geo in let screenWidth = geo.size.width - Config.screenInset * 2 let screenHeight = geo.size.height * Config.screenFraction let wheelDiameter = geo.size.width * Config.wheelFraction ZStack(alignment: .top) { // Paint the full body color behind the real device cutout so // the top spacer stays the same gray as the rest of the shell. Config.bodyFill.ignoresSafeArea() VStack(spacing: 0) { CoverFlowScreen( width: screenWidth, height: screenHeight, scrollPosition: scrollPosition ) .padding(.horizontal, Config.screenInset) .padding(.top, Config.screenInset) // Equal flex above and below the wheel keeps it centered // in the lower half, like the original snippet layout. Spacer() ClickWheel(diameter: wheelDiameter) { angleDelta in let step = angleDelta / (2 * .pi) * Config.scrollSensitivity scrollPosition = (scrollPosition + step) .clamped(to: 0...(CGFloat(Config.albums.count) - 1)) } onEnd: { withAnimation(Config.snapSpring) { scrollPosition = scrollPosition.rounded() } } .frame(width: wheelDiameter, height: wheelDiameter) Spacer() } } } .preferredColorScheme(.light) .statusBarHidden(true) } } // MARK: - Cover Flow screen /// The iPod's LCD area: status bar, Cover Flow carousel, album title, artist. private struct CoverFlowScreen: View { let width: CGFloat let height: CGFloat let scrollPosition: CGFloat var body: some View { VStack(spacing: 0) { // Status bar mirrors the real iPod layout: title left, icons right. HStack(spacing: 4) { Text("Cover Flow") .font(.system(size: 10.5, weight: .semibold)) .foregroundStyle(Config.statusText) Spacer() Image(systemName: "play.fill") .font(.system(size: 8, weight: .bold)) .foregroundStyle(.green) Image(systemName: "battery.75") .font(.system(size: 10)) .foregroundStyle(Config.statusText) } .padding(.horizontal, 8) .frame(height: 20) .background(Color(white: 0.91)) // Carousel with title grouped inside — both centered together. CoverFlowCarousel(scrollPosition: scrollPosition) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Config.screenBackground) } .frame(width: width, height: height) .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 5, style: .continuous) .strokeBorder(Config.screenBorder, lineWidth: Config.screenBorderWidth) ) } } // MARK: - Cover Flow carousel /// Horizontal 3D album strip with title + artist grouped immediately below. /// The whole block (art + reflection + title) is vertically centered in the /// screen area using equal Spacers above and below. private struct CoverFlowCarousel: View { let scrollPosition: CGFloat /// Total height of one card: art + reflection. private var cardHeight: CGFloat { Config.albumSize * (1 + Config.reflectionFraction) } private var selectedIndex: Int { Int(scrollPosition.rounded()) .clamped(to: 0...(Config.albums.count - 1)) } var body: some View { VStack(spacing: 0) { // Equal flex above -- centers the art+title group vertically. Spacer() // Albums on a shared floor baseline. ZStack(alignment: .bottom) { ForEach(Array(Config.albums.enumerated()), id: \.offset) { i, album in let offset = CGFloat(i) - scrollPosition let absOff = abs(offset) if absOff < 3.5 { // Clamp at ±1.0 so all side cards share the same ~65° // rotation. Negated: left faces right, right faces left. let clampedOff = offset.clamped(to: -1.0...1.0) let rotY = -Double(clampedOff) * Config.rotationPerCard let scale = max(1.0 - absOff * Config.scalePerCard, 0.70) let xShift = horizontalShift(for: offset) CoverFlowCard(album: album) .rotation3DEffect( .degrees(rotY), axis: (x: 0, y: 1, z: 0), perspective: 0.35 ) .scaleEffect(scale) // Nudge scaled cards down so all reflection floors // share the same horizontal baseline. .offset(x: xShift, y: cardHeight * (1 - scale) / 2) .zIndex(-absOff) } } } .frame(maxWidth: .infinity) .frame(height: cardHeight) // Title sits directly below the artwork -- no gap section. VStack(spacing: 3) { Text(Config.albums[selectedIndex].title) .font(.system(size: 13, weight: .bold)) .foregroundStyle(Config.albumTitleColor) .multilineTextAlignment(.center) .lineLimit(2) Text(Config.albums[selectedIndex].artist) .font(.system(size: 11)) .foregroundStyle(Config.artistColor) .lineLimit(1) } .padding(.top, 12) .padding(.horizontal, 12) // Equal flex below -- mirrors the space above. Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } /// The first step away from center stays wide so the selected card has air. /// Additional steps compress so perspective-narrowed outer cards do not leave /// oversized empty columns near the screen edges. private func horizontalShift(for offset: CGFloat) -> CGFloat { let direction: CGFloat = offset >= 0 ? 1 : -1 let distance = abs(offset) guard distance > 1 else { return offset * Config.cardStep } return direction * ( Config.cardStep + (distance - 1) * Config.outerCardStep ) } } // MARK: - Cover Flow card /// A single album card: square cover art on top, faded mirror reflection below. /// The parent carousel applies the Y-axis rotation to the whole card so the /// reflection rotates with its album, matching the source design. private struct CoverFlowCard: View { let album: AlbumSpec private var reflectionHeight: CGFloat { Config.albumSize * Config.reflectionFraction } var body: some View { VStack(spacing: 0) { AlbumArt(album: album) .frame(width: Config.albumSize, height: Config.albumSize) // Glass-floor reflection: flipped, softly blurred, and masked with // an aggressive fade so only the very top is visible. AlbumArt(album: album) .frame(width: Config.albumSize, height: Config.albumSize) .scaleEffect(x: 1, y: -1) // Slight blur gives the soft, out-of-focus look of a floor reflection. .blur(radius: 2) .frame(height: reflectionHeight, alignment: .top) .clipped() // Crop first, then fade, so the tail softens inside the // visible reflection instead of getting chopped off. .mask( LinearGradient( stops: [ .init(color: .black.opacity(Config.reflectionOpacity), location: 0.00), .init(color: .black.opacity(0.20), location: 0.28), .init(color: .black.opacity(0.06), location: 0.58), .init(color: .clear, location: 0.92), .init(color: .clear, location: 1.00), ], startPoint: .top, endPoint: .bottom ) ) } } } // MARK: - Album art /// AsyncImage with a tinted gradient placeholder and a music-note fallback /// for offline or failed loads so no tile ever appears blank. private struct AlbumArt: View { let album: AlbumSpec var body: some View { AsyncImage(url: URL(string: album.imageURL)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() .transition(.opacity.animation(.easeOut(duration: 0.35))) case .failure: Image(systemName: "music.note") .font(.system(size: Config.albumSize * 0.3)) .foregroundStyle(.white.opacity(0.7)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background( LinearGradient( colors: [album.tint, album.tint.opacity(0.6)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) case .empty: album.tint.opacity(0.8) @unknown default: album.tint.opacity(0.8) } } .frame(width: Config.albumSize, height: Config.albumSize) .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) .shadow(color: .black.opacity(0.28), radius: 4, x: 0, y: 3) } } // MARK: - Click wheel /// iPod Classic click wheel. Computes the polar angle of the drag contact /// point on each gesture update and reports the signed delta so the parent /// can scroll proportionally. The center button area is excluded from drag /// tracking so tapping the center does not accidentally scroll. private struct ClickWheel: View { let diameter: CGFloat /// Called with the signed angle change in radians: positive = clockwise. let onDelta: (CGFloat) -> Void let onEnd: () -> Void @State private var lastAngle: CGFloat? = nil private var radius: CGFloat { diameter / 2 } private var centerRadius: CGFloat { diameter * Config.centerFraction / 2 } var body: some View { ZStack { // Pure white ring, flat fill matching the reference exactly. Circle().fill(Color.white) Circle().strokeBorder(Color(white: 0.92), lineWidth: 0.6) // Four printed labels at the cardinal positions of the ring. // Small and light, matching the barely-visible printing on the // real hardware. Text("MENU") .font(.system(size: diameter * Config.wheelMenuFontFraction, weight: .bold)) .tracking(1.2) .foregroundStyle(Config.wheelLabel) .offset(y: -(radius * Config.wheelMenuYOffset)) Image(systemName: "backward.end.fill") .font(.system(size: diameter * Config.wheelSideIconFraction, weight: .regular)) .foregroundStyle(Config.wheelLabel) .offset(x: -(radius * Config.wheelSideXOffset)) Image(systemName: "forward.end.fill") .font(.system(size: diameter * Config.wheelSideIconFraction, weight: .regular)) .foregroundStyle(Config.wheelLabel) .offset(x: radius * Config.wheelSideXOffset) Image(systemName: "playpause.fill") .font(.system(size: diameter * Config.wheelBottomIconFraction, weight: .regular)) .foregroundStyle(Config.wheelLabel) .offset(y: radius * Config.wheelBottomYOffset) // Center button: clearly gray against the white ring so it reads // as a separate physical button, not part of the ring surface. Circle() .fill(Config.bodyFill) .overlay(Circle().strokeBorder(Color(white: 0.70), lineWidth: 0.4)) .frame(width: centerRadius * 2, height: centerRadius * 2) } .frame(width: diameter, height: diameter) .contentShape(Circle()) .gesture( DragGesture(minimumDistance: 2, coordinateSpace: .local) .onChanged { value in let loc = value.location let dx = loc.x - radius let dy = loc.y - radius let dist = hypot(dx, dy) // Dead zone around the center button so taps there do not // accidentally trigger scrolling. +6pt gives a generous buffer. guard dist > centerRadius + 6 else { lastAngle = nil return } let angle = atan2(dy, dx) if let prev = lastAngle { // Unwrap across the +-pi boundary so a drag that crosses // the -x axis does not produce a spurious full-circle jump. var delta = angle - prev if delta > .pi { delta -= 2 * .pi } if delta < -.pi { delta += 2 * .pi } onDelta(delta) } lastAngle = angle } .onEnded { _ in lastAngle = nil onEnd() } ) } } // MARK: - Helpers private extension CGFloat { /// Clamps the value to the given closed range. func clamped(to range: ClosedRange<CGFloat>) -> CGFloat { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } private extension Int { func clamped(to range: ClosedRange<Int>) -> Int { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } #Preview { RetroMusicPlayerIPodScrollSnippet() }
Shot
Snippet
iOS 17+ • Page curl • UIKit bridge
Apple Books Page Flip Interaction
A SwiftUI recreation of Apple Books’ page curl reading interaction, with swipe gestures, edge taps, page tracking, and sepia paper styling.
SwiftUI
import SwiftUI import UIKit struct AppleBooksPageFlipSnippet: View { @State private var pageIndex = 0 var body: some View { PageCurlReader( pageCount: Config.pages.count, currentIndex: $pageIndex ) { index in AnyView( BookPageView( spec: BookPage( header: Config.bookHeader, body: Config.pages[index], pageNumber: Config.firstPageNumber + index ) ) ) } .ignoresSafeArea() } }
import SwiftUI import UIKit struct AppleBooksPageFlipSnippet: View { @State private var pageIndex = 0 var body: some View { PageCurlReader( pageCount: Config.pages.count, currentIndex: $pageIndex ) { index in AnyView( BookPageView( spec: BookPage( header: Config.bookHeader, body: Config.pages[index], pageNumber: Config.firstPageNumber + index ) ) ) } .ignoresSafeArea() } }
import SwiftUI import UIKit struct AppleBooksPageFlipSnippet: View { @State private var pageIndex = 0 var body: some View { PageCurlReader( pageCount: Config.pages.count, currentIndex: $pageIndex ) { index in AnyView( BookPageView( spec: BookPage( header: Config.bookHeader, body: Config.pages[index], pageNumber: Config.firstPageNumber + index ) ) ) } .ignoresSafeArea() } }
import SwiftUI import UIKit struct AppleBooksPageFlipSnippet: View { @State private var pageIndex = 0 var body: some View { PageCurlReader( pageCount: Config.pages.count, currentIndex: $pageIndex ) { index in AnyView( BookPageView( spec: BookPage( header: Config.bookHeader, body: Config.pages[index], pageNumber: Config.firstPageNumber + index ) ) ) } .ignoresSafeArea() } }
import SwiftUI import UIKit // A reading view with the real Apple Books page-curl interaction. The // curl, the finger-tracking paper physics, the soft shadow under the // lifted sheet, and tap-on-the-edge to turn all come from UIKit's // UIPageViewController in its `.pageCurl` transition style: the exact // control iBooks has used since iOS 5. There is no pure-SwiftUI page // curl, so we bridge that controller in with UIViewControllerRepresentable // and host a SwiftUI page (header, body text, page number) on each sheet. // We supply the pages and the sepia theme; the controller does the curl. // // HOW TO CUSTOMIZE: everything tweakable lives in the CONFIG block below. // Swap `Config.pages` for your own text, retitle with `Config.bookHeader`, // recolor the sepia theme, or adjust the type sizes and margins. // MARK: - Config /// Every value a copy-paster might want to change. Grouped into Copy, /// Theme, and Layout. The implementation reads straight from here. private enum Config { // MARK: Copy /// Small muted line centered at the top of every page. Usually the /// author and title of the book being read. static let bookHeader = "Austen, Jane - Pride and Prejudice" /// The page number printed at the bottom of the very first page. /// Each page after it counts up from here. static let firstPageNumber = 1 /// One entry per page, in reading order. These are public-domain /// excerpts (Pride and Prejudice). Replace with your own book text. /// Each string should be roughly one screen of reading; text that /// overflows a page is clipped, so trim to taste after a screenshot. static let pages: [String] = [ """ It is a truth universally acknowledged, that a single man in \ possession of a good fortune, must be in want of a wife. However little known the feelings or views of such a man may be \ on his first entering a neighbourhood, this truth is so well \ fixed in the minds of the surrounding families, that he is \ considered as the rightful property of some one or other of \ their daughters. "My dear Mr. Bennet," said his lady to him one day, "have you \ heard that Netherfield Park is let at last?" Mr. Bennet replied that he had not. "But it is," returned she; "for Mrs. Long has just been here, and \ she told me all about it." Mr. Bennet made no answer. "Do you not want to know who has taken it?" cried his wife \ impatiently. "You want to tell me, and I have no objection to hearing it." This was invitation enough. """, """ "Why, my dear, you must know, Mrs. Long says that Netherfield is \ taken by a young man of large fortune from the north of England; \ that he came down on Monday in a chaise and four to see the \ place, and was so much delighted with it that he agreed with Mr. \ Morris immediately; that he is to take possession before \ Michaelmas, and some of his servants are to be in the house by \ the end of next week." "What is his name?" "Bingley." "Is he married or single?" "Oh! single, my dear, to be sure! A single man of large fortune; \ four or five thousand a year. What a fine thing for our girls!" "How so? how can it affect them?" "My dear Mr. Bennet," replied his wife, "how can you be so \ tiresome! You must know that I am thinking of his marrying one of \ them." """, """ "Is that his design in settling here?" "Design! nonsense, how can you talk so! But it is very likely \ that he may fall in love with one of them, and therefore you must \ visit him as soon as he comes." "I see no occasion for that. You and the girls may go, or you may \ send them by themselves, which perhaps will be still better; for \ as you are as handsome as any of them, Mr. Bingley might like you \ the best of the party." "My dear, you flatter me. I certainly have had my share of beauty, \ but I do not pretend to be anything extraordinary now. When a \ woman has five grown-up daughters, she ought to give over \ thinking of her own beauty." "In such cases, a woman has not often much beauty to think of." """, """ "But, my dear, you must indeed go and see Mr. Bingley when he \ comes into the neighbourhood." "It is more than I engage for, I assure you." "But consider your daughters. Only think what an establishment it \ would be for one of them. Sir William and Lady Lucas are \ determined to go, merely on that account; for in general, you \ know, they visit no newcomers. Indeed you must go, for it will be \ impossible for us to visit him, if you do not." "You are over-scrupulous, surely. I dare say Mr. Bingley will be \ very glad to see you; and I will send a few lines by you to \ assure him of my hearty consent to his marrying whichever he \ chooses of the girls." """, """ "I desire you will do no such thing. Lizzy is not a bit better \ than the others; and I am sure she is not half so handsome as \ Jane, nor half so good-humoured as Lydia. But you are always \ giving her the preference." "They have none of them much to recommend them," replied he; \ "they are all silly and ignorant like other girls; but Lizzy has \ something more of quickness than her sisters." "Mr. Bennet, how can you abuse your own children in such a way? \ You take delight in vexing me. You have no compassion for my poor \ nerves." "You mistake me, my dear. I have a high respect for your nerves. \ They are my old friends." """, ] // MARK: Theme /// The page (paper) color. A warm, dark sepia, matching the Books /// night-ish reading theme. static let paperColor = Color(red: 0.224, green: 0.200, blue: 0.169) /// Body text color: a soft cream that sits gently on the dark paper. static let inkColor = Color(red: 0.839, green: 0.796, blue: 0.725) /// Muted tone for the running header and page number. static let mutedInkColor = Color(red: 0.553, green: 0.514, blue: 0.451) /// How strongly the page's ink shows through to the *back* of the /// sheet while it is curling. Real paper lets a faint, mirrored ghost /// of the text bleed through; without it the lifting sheet reads as a /// flat gray sheen. Keep this low (0.06–0.14). static let backShowThrough: Double = 0.10 // MARK: Layout /// Left/right margin for the text column, in points. static let sidePadding: CGFloat = 26 /// Gap above the running header (below the status bar). static let headerTopPadding: CGFloat = 8 /// Gap between the header and the first line of body text. static let bodyTopPadding: CGFloat = 26 /// Gap below the body before the page number at the bottom. static let pageNumberBottomPadding: CGFloat = 6 /// Body text size in points. Serif, to read like a printed book. static let bodyFontSize: CGFloat = 18 /// Extra space between body lines. Books-style reading wants air. /// Paragraph gaps come from the blank lines in each page string. static let bodyLineSpacing: CGFloat = 6 /// Running-header text size. static let headerFontSize: CGFloat = 12 /// Page-number text size. static let pageNumberFontSize: CGFloat = 12 } // MARK: - Implementation /// A reading view that turns pages with UIKit's native page curl. /// /// All this view owns is the current page index. `PageCurlReader` wraps /// `UIPageViewController(.pageCurl)` and asks back for a SwiftUI page at /// any index, so the curl animation, gesture, and shadow are the system's /// and the content is ours. struct AppleBooksPageFlipSnippet: View { @State private var pageIndex = 0 var body: some View { PageCurlReader(pageCount: Config.pages.count, currentIndex: $pageIndex) { index in BookPageView(spec: spec(at: index)) } // Paper runs edge to edge, behind the status bar and home // indicator; the page content insets itself to the safe area. .background(Config.paperColor.ignoresSafeArea()) .preferredColorScheme(.dark) } private func spec(at index: Int) -> BookPage { BookPage( header: Config.bookHeader, body: Config.pages[index], pageNumber: Config.firstPageNumber + index ) } } // MARK: - Native page curl bridge /// Bridges `UIPageViewController` in its `.pageCurl` transition style into /// SwiftUI. The controller supplies the curl, the pan-to-turn and /// tap-the-edge gestures, and the shadow under the lifting sheet. We feed /// it faces on demand through the data source and report the settled page /// back through `currentIndex`. /// /// Why "faces": with a single-sided curl the back of a lifting sheet draws /// white (the front bleeds through). The fix is a double-sided controller /// where every real page is followed by a back face we control, so the /// reverse of the sheet reads as the same paper with a faint mirrored /// ghost of the ink, not a separate brown wedge. The data source therefore walks a doubled sequence: face 2i is the front (content) /// of page i, and face 2i+1 is its back. Neighbors step by one face; the /// controller pairs a front with its back as one leaf, so a turn still /// advances one page. We stash the face index in `view.tag` for before/after. private struct PageCurlReader: UIViewControllerRepresentable { let pageCount: Int @Binding var currentIndex: Int let content: (Int) -> AnyView init(pageCount: Int, currentIndex: Binding<Int>, @ViewBuilder content: @escaping (Int) -> some View) { self.pageCount = pageCount self._currentIndex = currentIndex self.content = { AnyView(content($0)) } } func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIViewController(context: Context) -> UIPageViewController { let controller = UIPageViewController( transitionStyle: .pageCurl, navigationOrientation: .horizontal ) controller.dataSource = context.coordinator controller.delegate = context.coordinator // Double-sided so we supply the back of each sheet ourselves, // instead of the system drawing it white with the front showing // through. controller.isDoubleSided = true controller.view.backgroundColor = UIColor(Config.paperColor) // At rest the spine is min, which wants a single (front) face. controller.setViewControllers( [context.coordinator.face(at: currentIndex * 2)], direction: .forward, animated: false ) return controller } func updateUIViewController(_ controller: UIPageViewController, context: Context) { // Only act on a programmatic index change, not on the user's own // turns (which the delegate already recorded into currentIndex). guard let shownFace = controller.viewControllers?.first?.view.tag else { return } let shownPage = shownFace / 2 guard shownPage != currentIndex else { return } let direction: UIPageViewController.NavigationDirection = shownPage < currentIndex ? .forward : .reverse // An animated curl turn wants both faces of the landing leaf. controller.setViewControllers( context.coordinator.leaf(forPage: currentIndex), direction: direction, animated: true ) } final class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { private let parent: PageCurlReader init(_ parent: PageCurlReader) { self.parent = parent } private var faceCount: Int { parent.pageCount * 2 } private func pageSpec(at index: Int) -> BookPage { BookPage( header: Config.bookHeader, body: Config.pages[index], pageNumber: Config.firstPageNumber + index ) } /// The front+back pair for a page. A double-sided spine-min curl /// requires both faces when set programmatically (passing one /// raises "doesn't match the number required (2)"). func leaf(forPage page: Int) -> [UIViewController] { [face(at: page * 2), face(at: page * 2 + 1)] } /// The view controller for a face. Even faces host the SwiftUI /// content page. Odd faces are the *back* of that same sheet: /// `paperColor` with a faint mirrored ghost of the ink. The face /// index is recorded on the view so before/after can step it. func face(at faceIndex: Int) -> UIViewController { let pageIndex = faceIndex / 2 let controller: UIViewController if faceIndex.isMultiple(of: 2) { controller = UIHostingController(rootView: parent.content(pageIndex)) } else { let back = ZStack { Config.paperColor BookPageView(spec: pageSpec(at: pageIndex), paintsPaper: false) .opacity(Config.backShowThrough) .scaleEffect(x: -1, y: 1) } controller = UIHostingController(rootView: back) controller.view.tag = faceIndex controller.view.backgroundColor = UIColor(Config.paperColor) return controller } controller.view.tag = faceIndex controller.view.backgroundColor = UIColor(Config.paperColor) return controller } func pageViewController(_ controller: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { let faceIndex = viewController.view.tag return faceIndex > 0 ? face(at: faceIndex - 1) : nil } func pageViewController(_ controller: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { let faceIndex = viewController.view.tag return faceIndex < faceCount - 1 ? face(at: faceIndex + 1) : nil } func pageViewController(_ controller: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { guard completed, let shown = controller.viewControllers?.first else { return } parent.currentIndex = shown.view.tag / 2 } } } // MARK: - Page /// The content of a single page: a centered running header, the body /// text, and a page number. The hosting controller paints the paper /// behind it, so this just lays out the type inside the safe area. private struct BookPage { let header: String let body: String let pageNumber: Int? } private struct BookPageView: View { let spec: BookPage /// Front faces paint the paper; back faces skip it so the curl reads /// as one flat sheet color, not two mismatched browns. var paintsPaper = true var body: some View { VStack(spacing: 0) { Text(spec.header) .font(.system(size: Config.headerFontSize, design: .serif)) .foregroundStyle(Config.mutedInkColor) .padding(.top, Config.headerTopPadding) Text(spec.body) .font(.system(size: Config.bodyFontSize, design: .serif)) .foregroundStyle(Config.inkColor) .lineSpacing(Config.bodyLineSpacing) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.top, Config.bodyTopPadding) if let number = spec.pageNumber { Text("\(number)") .font(.system(size: Config.pageNumberFontSize, design: .serif)) .foregroundStyle(Config.mutedInkColor) .padding(.bottom, Config.pageNumberBottomPadding) } } .padding(.horizontal, Config.sidePadding) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(paintsPaper ? AnyShapeStyle(Config.paperColor) : AnyShapeStyle(Color.clear)) } } #Preview { AppleBooksPageFlipSnippet() }
import SwiftUI import UIKit // A reading view with the real Apple Books page-curl interaction. The // curl, the finger-tracking paper physics, the soft shadow under the // lifted sheet, and tap-on-the-edge to turn all come from UIKit's // UIPageViewController in its `.pageCurl` transition style: the exact // control iBooks has used since iOS 5. There is no pure-SwiftUI page // curl, so we bridge that controller in with UIViewControllerRepresentable // and host a SwiftUI page (header, body text, page number) on each sheet. // We supply the pages and the sepia theme; the controller does the curl. // // HOW TO CUSTOMIZE: everything tweakable lives in the CONFIG block below. // Swap `Config.pages` for your own text, retitle with `Config.bookHeader`, // recolor the sepia theme, or adjust the type sizes and margins. // MARK: - Config /// Every value a copy-paster might want to change. Grouped into Copy, /// Theme, and Layout. The implementation reads straight from here. private enum Config { // MARK: Copy /// Small muted line centered at the top of every page. Usually the /// author and title of the book being read. static let bookHeader = "Austen, Jane - Pride and Prejudice" /// The page number printed at the bottom of the very first page. /// Each page after it counts up from here. static let firstPageNumber = 1 /// One entry per page, in reading order. These are public-domain /// excerpts (Pride and Prejudice). Replace with your own book text. /// Each string should be roughly one screen of reading; text that /// overflows a page is clipped, so trim to taste after a screenshot. static let pages: [String] = [ """ It is a truth universally acknowledged, that a single man in \ possession of a good fortune, must be in want of a wife. However little known the feelings or views of such a man may be \ on his first entering a neighbourhood, this truth is so well \ fixed in the minds of the surrounding families, that he is \ considered as the rightful property of some one or other of \ their daughters. "My dear Mr. Bennet," said his lady to him one day, "have you \ heard that Netherfield Park is let at last?" Mr. Bennet replied that he had not. "But it is," returned she; "for Mrs. Long has just been here, and \ she told me all about it." Mr. Bennet made no answer. "Do you not want to know who has taken it?" cried his wife \ impatiently. "You want to tell me, and I have no objection to hearing it." This was invitation enough. """, """ "Why, my dear, you must know, Mrs. Long says that Netherfield is \ taken by a young man of large fortune from the north of England; \ that he came down on Monday in a chaise and four to see the \ place, and was so much delighted with it that he agreed with Mr. \ Morris immediately; that he is to take possession before \ Michaelmas, and some of his servants are to be in the house by \ the end of next week." "What is his name?" "Bingley." "Is he married or single?" "Oh! single, my dear, to be sure! A single man of large fortune; \ four or five thousand a year. What a fine thing for our girls!" "How so? how can it affect them?" "My dear Mr. Bennet," replied his wife, "how can you be so \ tiresome! You must know that I am thinking of his marrying one of \ them." """, """ "Is that his design in settling here?" "Design! nonsense, how can you talk so! But it is very likely \ that he may fall in love with one of them, and therefore you must \ visit him as soon as he comes." "I see no occasion for that. You and the girls may go, or you may \ send them by themselves, which perhaps will be still better; for \ as you are as handsome as any of them, Mr. Bingley might like you \ the best of the party." "My dear, you flatter me. I certainly have had my share of beauty, \ but I do not pretend to be anything extraordinary now. When a \ woman has five grown-up daughters, she ought to give over \ thinking of her own beauty." "In such cases, a woman has not often much beauty to think of." """, """ "But, my dear, you must indeed go and see Mr. Bingley when he \ comes into the neighbourhood." "It is more than I engage for, I assure you." "But consider your daughters. Only think what an establishment it \ would be for one of them. Sir William and Lady Lucas are \ determined to go, merely on that account; for in general, you \ know, they visit no newcomers. Indeed you must go, for it will be \ impossible for us to visit him, if you do not." "You are over-scrupulous, surely. I dare say Mr. Bingley will be \ very glad to see you; and I will send a few lines by you to \ assure him of my hearty consent to his marrying whichever he \ chooses of the girls." """, """ "I desire you will do no such thing. Lizzy is not a bit better \ than the others; and I am sure she is not half so handsome as \ Jane, nor half so good-humoured as Lydia. But you are always \ giving her the preference." "They have none of them much to recommend them," replied he; \ "they are all silly and ignorant like other girls; but Lizzy has \ something more of quickness than her sisters." "Mr. Bennet, how can you abuse your own children in such a way? \ You take delight in vexing me. You have no compassion for my poor \ nerves." "You mistake me, my dear. I have a high respect for your nerves. \ They are my old friends." """, ] // MARK: Theme /// The page (paper) color. A warm, dark sepia, matching the Books /// night-ish reading theme. static let paperColor = Color(red: 0.224, green: 0.200, blue: 0.169) /// Body text color: a soft cream that sits gently on the dark paper. static let inkColor = Color(red: 0.839, green: 0.796, blue: 0.725) /// Muted tone for the running header and page number. static let mutedInkColor = Color(red: 0.553, green: 0.514, blue: 0.451) /// How strongly the page's ink shows through to the *back* of the /// sheet while it is curling. Real paper lets a faint, mirrored ghost /// of the text bleed through; without it the lifting sheet reads as a /// flat gray sheen. Keep this low (0.06–0.14). static let backShowThrough: Double = 0.10 // MARK: Layout /// Left/right margin for the text column, in points. static let sidePadding: CGFloat = 26 /// Gap above the running header (below the status bar). static let headerTopPadding: CGFloat = 8 /// Gap between the header and the first line of body text. static let bodyTopPadding: CGFloat = 26 /// Gap below the body before the page number at the bottom. static let pageNumberBottomPadding: CGFloat = 6 /// Body text size in points. Serif, to read like a printed book. static let bodyFontSize: CGFloat = 18 /// Extra space between body lines. Books-style reading wants air. /// Paragraph gaps come from the blank lines in each page string. static let bodyLineSpacing: CGFloat = 6 /// Running-header text size. static let headerFontSize: CGFloat = 12 /// Page-number text size. static let pageNumberFontSize: CGFloat = 12 } // MARK: - Implementation /// A reading view that turns pages with UIKit's native page curl. /// /// All this view owns is the current page index. `PageCurlReader` wraps /// `UIPageViewController(.pageCurl)` and asks back for a SwiftUI page at /// any index, so the curl animation, gesture, and shadow are the system's /// and the content is ours. struct AppleBooksPageFlipSnippet: View { @State private var pageIndex = 0 var body: some View { PageCurlReader(pageCount: Config.pages.count, currentIndex: $pageIndex) { index in BookPageView(spec: spec(at: index)) } // Paper runs edge to edge, behind the status bar and home // indicator; the page content insets itself to the safe area. .background(Config.paperColor.ignoresSafeArea()) .preferredColorScheme(.dark) } private func spec(at index: Int) -> BookPage { BookPage( header: Config.bookHeader, body: Config.pages[index], pageNumber: Config.firstPageNumber + index ) } } // MARK: - Native page curl bridge /// Bridges `UIPageViewController` in its `.pageCurl` transition style into /// SwiftUI. The controller supplies the curl, the pan-to-turn and /// tap-the-edge gestures, and the shadow under the lifting sheet. We feed /// it faces on demand through the data source and report the settled page /// back through `currentIndex`. /// /// Why "faces": with a single-sided curl the back of a lifting sheet draws /// white (the front bleeds through). The fix is a double-sided controller /// where every real page is followed by a back face we control, so the /// reverse of the sheet reads as the same paper with a faint mirrored /// ghost of the ink, not a separate brown wedge. The data source therefore walks a doubled sequence: face 2i is the front (content) /// of page i, and face 2i+1 is its back. Neighbors step by one face; the /// controller pairs a front with its back as one leaf, so a turn still /// advances one page. We stash the face index in `view.tag` for before/after. private struct PageCurlReader: UIViewControllerRepresentable { let pageCount: Int @Binding var currentIndex: Int let content: (Int) -> AnyView init(pageCount: Int, currentIndex: Binding<Int>, @ViewBuilder content: @escaping (Int) -> some View) { self.pageCount = pageCount self._currentIndex = currentIndex self.content = { AnyView(content($0)) } } func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIViewController(context: Context) -> UIPageViewController { let controller = UIPageViewController( transitionStyle: .pageCurl, navigationOrientation: .horizontal ) controller.dataSource = context.coordinator controller.delegate = context.coordinator // Double-sided so we supply the back of each sheet ourselves, // instead of the system drawing it white with the front showing // through. controller.isDoubleSided = true controller.view.backgroundColor = UIColor(Config.paperColor) // At rest the spine is min, which wants a single (front) face. controller.setViewControllers( [context.coordinator.face(at: currentIndex * 2)], direction: .forward, animated: false ) return controller } func updateUIViewController(_ controller: UIPageViewController, context: Context) { // Only act on a programmatic index change, not on the user's own // turns (which the delegate already recorded into currentIndex). guard let shownFace = controller.viewControllers?.first?.view.tag else { return } let shownPage = shownFace / 2 guard shownPage != currentIndex else { return } let direction: UIPageViewController.NavigationDirection = shownPage < currentIndex ? .forward : .reverse // An animated curl turn wants both faces of the landing leaf. controller.setViewControllers( context.coordinator.leaf(forPage: currentIndex), direction: direction, animated: true ) } final class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { private let parent: PageCurlReader init(_ parent: PageCurlReader) { self.parent = parent } private var faceCount: Int { parent.pageCount * 2 } private func pageSpec(at index: Int) -> BookPage { BookPage( header: Config.bookHeader, body: Config.pages[index], pageNumber: Config.firstPageNumber + index ) } /// The front+back pair for a page. A double-sided spine-min curl /// requires both faces when set programmatically (passing one /// raises "doesn't match the number required (2)"). func leaf(forPage page: Int) -> [UIViewController] { [face(at: page * 2), face(at: page * 2 + 1)] } /// The view controller for a face. Even faces host the SwiftUI /// content page. Odd faces are the *back* of that same sheet: /// `paperColor` with a faint mirrored ghost of the ink. The face /// index is recorded on the view so before/after can step it. func face(at faceIndex: Int) -> UIViewController { let pageIndex = faceIndex / 2 let controller: UIViewController if faceIndex.isMultiple(of: 2) { controller = UIHostingController(rootView: parent.content(pageIndex)) } else { let back = ZStack { Config.paperColor BookPageView(spec: pageSpec(at: pageIndex), paintsPaper: false) .opacity(Config.backShowThrough) .scaleEffect(x: -1, y: 1) } controller = UIHostingController(rootView: back) controller.view.tag = faceIndex controller.view.backgroundColor = UIColor(Config.paperColor) return controller } controller.view.tag = faceIndex controller.view.backgroundColor = UIColor(Config.paperColor) return controller } func pageViewController(_ controller: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { let faceIndex = viewController.view.tag return faceIndex > 0 ? face(at: faceIndex - 1) : nil } func pageViewController(_ controller: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { let faceIndex = viewController.view.tag return faceIndex < faceCount - 1 ? face(at: faceIndex + 1) : nil } func pageViewController(_ controller: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { guard completed, let shown = controller.viewControllers?.first else { return } parent.currentIndex = shown.view.tag / 2 } } } // MARK: - Page /// The content of a single page: a centered running header, the body /// text, and a page number. The hosting controller paints the paper /// behind it, so this just lays out the type inside the safe area. private struct BookPage { let header: String let body: String let pageNumber: Int? } private struct BookPageView: View { let spec: BookPage /// Front faces paint the paper; back faces skip it so the curl reads /// as one flat sheet color, not two mismatched browns. var paintsPaper = true var body: some View { VStack(spacing: 0) { Text(spec.header) .font(.system(size: Config.headerFontSize, design: .serif)) .foregroundStyle(Config.mutedInkColor) .padding(.top, Config.headerTopPadding) Text(spec.body) .font(.system(size: Config.bodyFontSize, design: .serif)) .foregroundStyle(Config.inkColor) .lineSpacing(Config.bodyLineSpacing) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.top, Config.bodyTopPadding) if let number = spec.pageNumber { Text("\(number)") .font(.system(size: Config.pageNumberFontSize, design: .serif)) .foregroundStyle(Config.mutedInkColor) .padding(.bottom, Config.pageNumberBottomPadding) } } .padding(.horizontal, Config.sidePadding) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(paintsPaper ? AnyShapeStyle(Config.paperColor) : AnyShapeStyle(Color.clear)) } } #Preview { AppleBooksPageFlipSnippet() }
import SwiftUI import UIKit // A reading view with the real Apple Books page-curl interaction. The // curl, the finger-tracking paper physics, the soft shadow under the // lifted sheet, and tap-on-the-edge to turn all come from UIKit's // UIPageViewController in its `.pageCurl` transition style: the exact // control iBooks has used since iOS 5. There is no pure-SwiftUI page // curl, so we bridge that controller in with UIViewControllerRepresentable // and host a SwiftUI page (header, body text, page number) on each sheet. // We supply the pages and the sepia theme; the controller does the curl. // // HOW TO CUSTOMIZE: everything tweakable lives in the CONFIG block below. // Swap `Config.pages` for your own text, retitle with `Config.bookHeader`, // recolor the sepia theme, or adjust the type sizes and margins. // MARK: - Config /// Every value a copy-paster might want to change. Grouped into Copy, /// Theme, and Layout. The implementation reads straight from here. private enum Config { // MARK: Copy /// Small muted line centered at the top of every page. Usually the /// author and title of the book being read. static let bookHeader = "Austen, Jane - Pride and Prejudice" /// The page number printed at the bottom of the very first page. /// Each page after it counts up from here. static let firstPageNumber = 1 /// One entry per page, in reading order. These are public-domain /// excerpts (Pride and Prejudice). Replace with your own book text. /// Each string should be roughly one screen of reading; text that /// overflows a page is clipped, so trim to taste after a screenshot. static let pages: [String] = [ """ It is a truth universally acknowledged, that a single man in \ possession of a good fortune, must be in want of a wife. However little known the feelings or views of such a man may be \ on his first entering a neighbourhood, this truth is so well \ fixed in the minds of the surrounding families, that he is \ considered as the rightful property of some one or other of \ their daughters. "My dear Mr. Bennet," said his lady to him one day, "have you \ heard that Netherfield Park is let at last?" Mr. Bennet replied that he had not. "But it is," returned she; "for Mrs. Long has just been here, and \ she told me all about it." Mr. Bennet made no answer. "Do you not want to know who has taken it?" cried his wife \ impatiently. "You want to tell me, and I have no objection to hearing it." This was invitation enough. """, """ "Why, my dear, you must know, Mrs. Long says that Netherfield is \ taken by a young man of large fortune from the north of England; \ that he came down on Monday in a chaise and four to see the \ place, and was so much delighted with it that he agreed with Mr. \ Morris immediately; that he is to take possession before \ Michaelmas, and some of his servants are to be in the house by \ the end of next week." "What is his name?" "Bingley." "Is he married or single?" "Oh! single, my dear, to be sure! A single man of large fortune; \ four or five thousand a year. What a fine thing for our girls!" "How so? how can it affect them?" "My dear Mr. Bennet," replied his wife, "how can you be so \ tiresome! You must know that I am thinking of his marrying one of \ them." """, """ "Is that his design in settling here?" "Design! nonsense, how can you talk so! But it is very likely \ that he may fall in love with one of them, and therefore you must \ visit him as soon as he comes." "I see no occasion for that. You and the girls may go, or you may \ send them by themselves, which perhaps will be still better; for \ as you are as handsome as any of them, Mr. Bingley might like you \ the best of the party." "My dear, you flatter me. I certainly have had my share of beauty, \ but I do not pretend to be anything extraordinary now. When a \ woman has five grown-up daughters, she ought to give over \ thinking of her own beauty." "In such cases, a woman has not often much beauty to think of." """, """ "But, my dear, you must indeed go and see Mr. Bingley when he \ comes into the neighbourhood." "It is more than I engage for, I assure you." "But consider your daughters. Only think what an establishment it \ would be for one of them. Sir William and Lady Lucas are \ determined to go, merely on that account; for in general, you \ know, they visit no newcomers. Indeed you must go, for it will be \ impossible for us to visit him, if you do not." "You are over-scrupulous, surely. I dare say Mr. Bingley will be \ very glad to see you; and I will send a few lines by you to \ assure him of my hearty consent to his marrying whichever he \ chooses of the girls." """, """ "I desire you will do no such thing. Lizzy is not a bit better \ than the others; and I am sure she is not half so handsome as \ Jane, nor half so good-humoured as Lydia. But you are always \ giving her the preference." "They have none of them much to recommend them," replied he; \ "they are all silly and ignorant like other girls; but Lizzy has \ something more of quickness than her sisters." "Mr. Bennet, how can you abuse your own children in such a way? \ You take delight in vexing me. You have no compassion for my poor \ nerves." "You mistake me, my dear. I have a high respect for your nerves. \ They are my old friends." """, ] // MARK: Theme /// The page (paper) color. A warm, dark sepia, matching the Books /// night-ish reading theme. static let paperColor = Color(red: 0.224, green: 0.200, blue: 0.169) /// Body text color: a soft cream that sits gently on the dark paper. static let inkColor = Color(red: 0.839, green: 0.796, blue: 0.725) /// Muted tone for the running header and page number. static let mutedInkColor = Color(red: 0.553, green: 0.514, blue: 0.451) /// How strongly the page's ink shows through to the *back* of the /// sheet while it is curling. Real paper lets a faint, mirrored ghost /// of the text bleed through; without it the lifting sheet reads as a /// flat gray sheen. Keep this low (0.06–0.14). static let backShowThrough: Double = 0.10 // MARK: Layout /// Left/right margin for the text column, in points. static let sidePadding: CGFloat = 26 /// Gap above the running header (below the status bar). static let headerTopPadding: CGFloat = 8 /// Gap between the header and the first line of body text. static let bodyTopPadding: CGFloat = 26 /// Gap below the body before the page number at the bottom. static let pageNumberBottomPadding: CGFloat = 6 /// Body text size in points. Serif, to read like a printed book. static let bodyFontSize: CGFloat = 18 /// Extra space between body lines. Books-style reading wants air. /// Paragraph gaps come from the blank lines in each page string. static let bodyLineSpacing: CGFloat = 6 /// Running-header text size. static let headerFontSize: CGFloat = 12 /// Page-number text size. static let pageNumberFontSize: CGFloat = 12 } // MARK: - Implementation /// A reading view that turns pages with UIKit's native page curl. /// /// All this view owns is the current page index. `PageCurlReader` wraps /// `UIPageViewController(.pageCurl)` and asks back for a SwiftUI page at /// any index, so the curl animation, gesture, and shadow are the system's /// and the content is ours. struct AppleBooksPageFlipSnippet: View { @State private var pageIndex = 0 var body: some View { PageCurlReader(pageCount: Config.pages.count, currentIndex: $pageIndex) { index in BookPageView(spec: spec(at: index)) } // Paper runs edge to edge, behind the status bar and home // indicator; the page content insets itself to the safe area. .background(Config.paperColor.ignoresSafeArea()) .preferredColorScheme(.dark) } private func spec(at index: Int) -> BookPage { BookPage( header: Config.bookHeader, body: Config.pages[index], pageNumber: Config.firstPageNumber + index ) } } // MARK: - Native page curl bridge /// Bridges `UIPageViewController` in its `.pageCurl` transition style into /// SwiftUI. The controller supplies the curl, the pan-to-turn and /// tap-the-edge gestures, and the shadow under the lifting sheet. We feed /// it faces on demand through the data source and report the settled page /// back through `currentIndex`. /// /// Why "faces": with a single-sided curl the back of a lifting sheet draws /// white (the front bleeds through). The fix is a double-sided controller /// where every real page is followed by a back face we control, so the /// reverse of the sheet reads as the same paper with a faint mirrored /// ghost of the ink, not a separate brown wedge. The data source therefore walks a doubled sequence: face 2i is the front (content) /// of page i, and face 2i+1 is its back. Neighbors step by one face; the /// controller pairs a front with its back as one leaf, so a turn still /// advances one page. We stash the face index in `view.tag` for before/after. private struct PageCurlReader: UIViewControllerRepresentable { let pageCount: Int @Binding var currentIndex: Int let content: (Int) -> AnyView init(pageCount: Int, currentIndex: Binding<Int>, @ViewBuilder content: @escaping (Int) -> some View) { self.pageCount = pageCount self._currentIndex = currentIndex self.content = { AnyView(content($0)) } } func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIViewController(context: Context) -> UIPageViewController { let controller = UIPageViewController( transitionStyle: .pageCurl, navigationOrientation: .horizontal ) controller.dataSource = context.coordinator controller.delegate = context.coordinator // Double-sided so we supply the back of each sheet ourselves, // instead of the system drawing it white with the front showing // through. controller.isDoubleSided = true controller.view.backgroundColor = UIColor(Config.paperColor) // At rest the spine is min, which wants a single (front) face. controller.setViewControllers( [context.coordinator.face(at: currentIndex * 2)], direction: .forward, animated: false ) return controller } func updateUIViewController(_ controller: UIPageViewController, context: Context) { // Only act on a programmatic index change, not on the user's own // turns (which the delegate already recorded into currentIndex). guard let shownFace = controller.viewControllers?.first?.view.tag else { return } let shownPage = shownFace / 2 guard shownPage != currentIndex else { return } let direction: UIPageViewController.NavigationDirection = shownPage < currentIndex ? .forward : .reverse // An animated curl turn wants both faces of the landing leaf. controller.setViewControllers( context.coordinator.leaf(forPage: currentIndex), direction: direction, animated: true ) } final class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { private let parent: PageCurlReader init(_ parent: PageCurlReader) { self.parent = parent } private var faceCount: Int { parent.pageCount * 2 } private func pageSpec(at index: Int) -> BookPage { BookPage( header: Config.bookHeader, body: Config.pages[index], pageNumber: Config.firstPageNumber + index ) } /// The front+back pair for a page. A double-sided spine-min curl /// requires both faces when set programmatically (passing one /// raises "doesn't match the number required (2)"). func leaf(forPage page: Int) -> [UIViewController] { [face(at: page * 2), face(at: page * 2 + 1)] } /// The view controller for a face. Even faces host the SwiftUI /// content page. Odd faces are the *back* of that same sheet: /// `paperColor` with a faint mirrored ghost of the ink. The face /// index is recorded on the view so before/after can step it. func face(at faceIndex: Int) -> UIViewController { let pageIndex = faceIndex / 2 let controller: UIViewController if faceIndex.isMultiple(of: 2) { controller = UIHostingController(rootView: parent.content(pageIndex)) } else { let back = ZStack { Config.paperColor BookPageView(spec: pageSpec(at: pageIndex), paintsPaper: false) .opacity(Config.backShowThrough) .scaleEffect(x: -1, y: 1) } controller = UIHostingController(rootView: back) controller.view.tag = faceIndex controller.view.backgroundColor = UIColor(Config.paperColor) return controller } controller.view.tag = faceIndex controller.view.backgroundColor = UIColor(Config.paperColor) return controller } func pageViewController(_ controller: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { let faceIndex = viewController.view.tag return faceIndex > 0 ? face(at: faceIndex - 1) : nil } func pageViewController(_ controller: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { let faceIndex = viewController.view.tag return faceIndex < faceCount - 1 ? face(at: faceIndex + 1) : nil } func pageViewController(_ controller: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { guard completed, let shown = controller.viewControllers?.first else { return } parent.currentIndex = shown.view.tag / 2 } } } // MARK: - Page /// The content of a single page: a centered running header, the body /// text, and a page number. The hosting controller paints the paper /// behind it, so this just lays out the type inside the safe area. private struct BookPage { let header: String let body: String let pageNumber: Int? } private struct BookPageView: View { let spec: BookPage /// Front faces paint the paper; back faces skip it so the curl reads /// as one flat sheet color, not two mismatched browns. var paintsPaper = true var body: some View { VStack(spacing: 0) { Text(spec.header) .font(.system(size: Config.headerFontSize, design: .serif)) .foregroundStyle(Config.mutedInkColor) .padding(.top, Config.headerTopPadding) Text(spec.body) .font(.system(size: Config.bodyFontSize, design: .serif)) .foregroundStyle(Config.inkColor) .lineSpacing(Config.bodyLineSpacing) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.top, Config.bodyTopPadding) if let number = spec.pageNumber { Text("\(number)") .font(.system(size: Config.pageNumberFontSize, design: .serif)) .foregroundStyle(Config.mutedInkColor) .padding(.bottom, Config.pageNumberBottomPadding) } } .padding(.horizontal, Config.sidePadding) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(paintsPaper ? AnyShapeStyle(Config.paperColor) : AnyShapeStyle(Color.clear)) } } #Preview { AppleBooksPageFlipSnippet() }
import SwiftUI import UIKit // A reading view with the real Apple Books page-curl interaction. The // curl, the finger-tracking paper physics, the soft shadow under the // lifted sheet, and tap-on-the-edge to turn all come from UIKit's // UIPageViewController in its `.pageCurl` transition style: the exact // control iBooks has used since iOS 5. There is no pure-SwiftUI page // curl, so we bridge that controller in with UIViewControllerRepresentable // and host a SwiftUI page (header, body text, page number) on each sheet. // We supply the pages and the sepia theme; the controller does the curl. // // HOW TO CUSTOMIZE: everything tweakable lives in the CONFIG block below. // Swap `Config.pages` for your own text, retitle with `Config.bookHeader`, // recolor the sepia theme, or adjust the type sizes and margins. // MARK: - Config /// Every value a copy-paster might want to change. Grouped into Copy, /// Theme, and Layout. The implementation reads straight from here. private enum Config { // MARK: Copy /// Small muted line centered at the top of every page. Usually the /// author and title of the book being read. static let bookHeader = "Austen, Jane - Pride and Prejudice" /// The page number printed at the bottom of the very first page. /// Each page after it counts up from here. static let firstPageNumber = 1 /// One entry per page, in reading order. These are public-domain /// excerpts (Pride and Prejudice). Replace with your own book text. /// Each string should be roughly one screen of reading; text that /// overflows a page is clipped, so trim to taste after a screenshot. static let pages: [String] = [ """ It is a truth universally acknowledged, that a single man in \ possession of a good fortune, must be in want of a wife. However little known the feelings or views of such a man may be \ on his first entering a neighbourhood, this truth is so well \ fixed in the minds of the surrounding families, that he is \ considered as the rightful property of some one or other of \ their daughters. "My dear Mr. Bennet," said his lady to him one day, "have you \ heard that Netherfield Park is let at last?" Mr. Bennet replied that he had not. "But it is," returned she; "for Mrs. Long has just been here, and \ she told me all about it." Mr. Bennet made no answer. "Do you not want to know who has taken it?" cried his wife \ impatiently. "You want to tell me, and I have no objection to hearing it." This was invitation enough. """, """ "Why, my dear, you must know, Mrs. Long says that Netherfield is \ taken by a young man of large fortune from the north of England; \ that he came down on Monday in a chaise and four to see the \ place, and was so much delighted with it that he agreed with Mr. \ Morris immediately; that he is to take possession before \ Michaelmas, and some of his servants are to be in the house by \ the end of next week." "What is his name?" "Bingley." "Is he married or single?" "Oh! single, my dear, to be sure! A single man of large fortune; \ four or five thousand a year. What a fine thing for our girls!" "How so? how can it affect them?" "My dear Mr. Bennet," replied his wife, "how can you be so \ tiresome! You must know that I am thinking of his marrying one of \ them." """, """ "Is that his design in settling here?" "Design! nonsense, how can you talk so! But it is very likely \ that he may fall in love with one of them, and therefore you must \ visit him as soon as he comes." "I see no occasion for that. You and the girls may go, or you may \ send them by themselves, which perhaps will be still better; for \ as you are as handsome as any of them, Mr. Bingley might like you \ the best of the party." "My dear, you flatter me. I certainly have had my share of beauty, \ but I do not pretend to be anything extraordinary now. When a \ woman has five grown-up daughters, she ought to give over \ thinking of her own beauty." "In such cases, a woman has not often much beauty to think of." """, """ "But, my dear, you must indeed go and see Mr. Bingley when he \ comes into the neighbourhood." "It is more than I engage for, I assure you." "But consider your daughters. Only think what an establishment it \ would be for one of them. Sir William and Lady Lucas are \ determined to go, merely on that account; for in general, you \ know, they visit no newcomers. Indeed you must go, for it will be \ impossible for us to visit him, if you do not." "You are over-scrupulous, surely. I dare say Mr. Bingley will be \ very glad to see you; and I will send a few lines by you to \ assure him of my hearty consent to his marrying whichever he \ chooses of the girls." """, """ "I desire you will do no such thing. Lizzy is not a bit better \ than the others; and I am sure she is not half so handsome as \ Jane, nor half so good-humoured as Lydia. But you are always \ giving her the preference." "They have none of them much to recommend them," replied he; \ "they are all silly and ignorant like other girls; but Lizzy has \ something more of quickness than her sisters." "Mr. Bennet, how can you abuse your own children in such a way? \ You take delight in vexing me. You have no compassion for my poor \ nerves." "You mistake me, my dear. I have a high respect for your nerves. \ They are my old friends." """, ] // MARK: Theme /// The page (paper) color. A warm, dark sepia, matching the Books /// night-ish reading theme. static let paperColor = Color(red: 0.224, green: 0.200, blue: 0.169) /// Body text color: a soft cream that sits gently on the dark paper. static let inkColor = Color(red: 0.839, green: 0.796, blue: 0.725) /// Muted tone for the running header and page number. static let mutedInkColor = Color(red: 0.553, green: 0.514, blue: 0.451) /// How strongly the page's ink shows through to the *back* of the /// sheet while it is curling. Real paper lets a faint, mirrored ghost /// of the text bleed through; without it the lifting sheet reads as a /// flat gray sheen. Keep this low (0.06–0.14). static let backShowThrough: Double = 0.10 // MARK: Layout /// Left/right margin for the text column, in points. static let sidePadding: CGFloat = 26 /// Gap above the running header (below the status bar). static let headerTopPadding: CGFloat = 8 /// Gap between the header and the first line of body text. static let bodyTopPadding: CGFloat = 26 /// Gap below the body before the page number at the bottom. static let pageNumberBottomPadding: CGFloat = 6 /// Body text size in points. Serif, to read like a printed book. static let bodyFontSize: CGFloat = 18 /// Extra space between body lines. Books-style reading wants air. /// Paragraph gaps come from the blank lines in each page string. static let bodyLineSpacing: CGFloat = 6 /// Running-header text size. static let headerFontSize: CGFloat = 12 /// Page-number text size. static let pageNumberFontSize: CGFloat = 12 } // MARK: - Implementation /// A reading view that turns pages with UIKit's native page curl. /// /// All this view owns is the current page index. `PageCurlReader` wraps /// `UIPageViewController(.pageCurl)` and asks back for a SwiftUI page at /// any index, so the curl animation, gesture, and shadow are the system's /// and the content is ours. struct AppleBooksPageFlipSnippet: View { @State private var pageIndex = 0 var body: some View { PageCurlReader(pageCount: Config.pages.count, currentIndex: $pageIndex) { index in BookPageView(spec: spec(at: index)) } // Paper runs edge to edge, behind the status bar and home // indicator; the page content insets itself to the safe area. .background(Config.paperColor.ignoresSafeArea()) .preferredColorScheme(.dark) } private func spec(at index: Int) -> BookPage { BookPage( header: Config.bookHeader, body: Config.pages[index], pageNumber: Config.firstPageNumber + index ) } } // MARK: - Native page curl bridge /// Bridges `UIPageViewController` in its `.pageCurl` transition style into /// SwiftUI. The controller supplies the curl, the pan-to-turn and /// tap-the-edge gestures, and the shadow under the lifting sheet. We feed /// it faces on demand through the data source and report the settled page /// back through `currentIndex`. /// /// Why "faces": with a single-sided curl the back of a lifting sheet draws /// white (the front bleeds through). The fix is a double-sided controller /// where every real page is followed by a back face we control, so the /// reverse of the sheet reads as the same paper with a faint mirrored /// ghost of the ink, not a separate brown wedge. The data source therefore walks a doubled sequence: face 2i is the front (content) /// of page i, and face 2i+1 is its back. Neighbors step by one face; the /// controller pairs a front with its back as one leaf, so a turn still /// advances one page. We stash the face index in `view.tag` for before/after. private struct PageCurlReader: UIViewControllerRepresentable { let pageCount: Int @Binding var currentIndex: Int let content: (Int) -> AnyView init(pageCount: Int, currentIndex: Binding<Int>, @ViewBuilder content: @escaping (Int) -> some View) { self.pageCount = pageCount self._currentIndex = currentIndex self.content = { AnyView(content($0)) } } func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIViewController(context: Context) -> UIPageViewController { let controller = UIPageViewController( transitionStyle: .pageCurl, navigationOrientation: .horizontal ) controller.dataSource = context.coordinator controller.delegate = context.coordinator // Double-sided so we supply the back of each sheet ourselves, // instead of the system drawing it white with the front showing // through. controller.isDoubleSided = true controller.view.backgroundColor = UIColor(Config.paperColor) // At rest the spine is min, which wants a single (front) face. controller.setViewControllers( [context.coordinator.face(at: currentIndex * 2)], direction: .forward, animated: false ) return controller } func updateUIViewController(_ controller: UIPageViewController, context: Context) { // Only act on a programmatic index change, not on the user's own // turns (which the delegate already recorded into currentIndex). guard let shownFace = controller.viewControllers?.first?.view.tag else { return } let shownPage = shownFace / 2 guard shownPage != currentIndex else { return } let direction: UIPageViewController.NavigationDirection = shownPage < currentIndex ? .forward : .reverse // An animated curl turn wants both faces of the landing leaf. controller.setViewControllers( context.coordinator.leaf(forPage: currentIndex), direction: direction, animated: true ) } final class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { private let parent: PageCurlReader init(_ parent: PageCurlReader) { self.parent = parent } private var faceCount: Int { parent.pageCount * 2 } private func pageSpec(at index: Int) -> BookPage { BookPage( header: Config.bookHeader, body: Config.pages[index], pageNumber: Config.firstPageNumber + index ) } /// The front+back pair for a page. A double-sided spine-min curl /// requires both faces when set programmatically (passing one /// raises "doesn't match the number required (2)"). func leaf(forPage page: Int) -> [UIViewController] { [face(at: page * 2), face(at: page * 2 + 1)] } /// The view controller for a face. Even faces host the SwiftUI /// content page. Odd faces are the *back* of that same sheet: /// `paperColor` with a faint mirrored ghost of the ink. The face /// index is recorded on the view so before/after can step it. func face(at faceIndex: Int) -> UIViewController { let pageIndex = faceIndex / 2 let controller: UIViewController if faceIndex.isMultiple(of: 2) { controller = UIHostingController(rootView: parent.content(pageIndex)) } else { let back = ZStack { Config.paperColor BookPageView(spec: pageSpec(at: pageIndex), paintsPaper: false) .opacity(Config.backShowThrough) .scaleEffect(x: -1, y: 1) } controller = UIHostingController(rootView: back) controller.view.tag = faceIndex controller.view.backgroundColor = UIColor(Config.paperColor) return controller } controller.view.tag = faceIndex controller.view.backgroundColor = UIColor(Config.paperColor) return controller } func pageViewController(_ controller: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { let faceIndex = viewController.view.tag return faceIndex > 0 ? face(at: faceIndex - 1) : nil } func pageViewController(_ controller: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { let faceIndex = viewController.view.tag return faceIndex < faceCount - 1 ? face(at: faceIndex + 1) : nil } func pageViewController(_ controller: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { guard completed, let shown = controller.viewControllers?.first else { return } parent.currentIndex = shown.view.tag / 2 } } } // MARK: - Page /// The content of a single page: a centered running header, the body /// text, and a page number. The hosting controller paints the paper /// behind it, so this just lays out the type inside the safe area. private struct BookPage { let header: String let body: String let pageNumber: Int? } private struct BookPageView: View { let spec: BookPage /// Front faces paint the paper; back faces skip it so the curl reads /// as one flat sheet color, not two mismatched browns. var paintsPaper = true var body: some View { VStack(spacing: 0) { Text(spec.header) .font(.system(size: Config.headerFontSize, design: .serif)) .foregroundStyle(Config.mutedInkColor) .padding(.top, Config.headerTopPadding) Text(spec.body) .font(.system(size: Config.bodyFontSize, design: .serif)) .foregroundStyle(Config.inkColor) .lineSpacing(Config.bodyLineSpacing) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.top, Config.bodyTopPadding) if let number = spec.pageNumber { Text("\(number)") .font(.system(size: Config.pageNumberFontSize, design: .serif)) .foregroundStyle(Config.mutedInkColor) .padding(.bottom, Config.pageNumberBottomPadding) } } .padding(.horizontal, Config.sidePadding) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(paintsPaper ? AnyShapeStyle(Config.paperColor) : AnyShapeStyle(Color.clear)) } } #Preview { AppleBooksPageFlipSnippet() }
Shot
Snippet
iOS 17+ • Animated onboarding • Timed sequence
Biscuit Camera Intro Note Interaction
A SwiftUI recreation of a camera app welcome screen, with an animated photo marquee, delayed letter fade-in, and bottom CTA entrance.
SwiftUI
import SwiftUI struct BloomCameraWelcomeSnippet: View { @State private var showGallery = false @State private var startLetter = false @State private var showCTA = false var body: some View { ZStack(alignment: .top) { Config.backgroundColor .ignoresSafeArea() VStack(spacing: 0) { MarqueeGallery() .frame(height: Config.galleryHeight) .offset(y: showGallery ? 0 : Config.galleryEntranceOffset) .opacity(showGallery ? 1 : 0) FadeInLetter(start: startLetter) } } } }
import SwiftUI struct BloomCameraWelcomeSnippet: View { @State private var showGallery = false @State private var startLetter = false @State private var showCTA = false var body: some View { ZStack(alignment: .top) { Config.backgroundColor .ignoresSafeArea() VStack(spacing: 0) { MarqueeGallery() .frame(height: Config.galleryHeight) .offset(y: showGallery ? 0 : Config.galleryEntranceOffset) .opacity(showGallery ? 1 : 0) FadeInLetter(start: startLetter) } } } }
import SwiftUI struct BloomCameraWelcomeSnippet: View { @State private var showGallery = false @State private var startLetter = false @State private var showCTA = false var body: some View { ZStack(alignment: .top) { Config.backgroundColor .ignoresSafeArea() VStack(spacing: 0) { MarqueeGallery() .frame(height: Config.galleryHeight) .offset(y: showGallery ? 0 : Config.galleryEntranceOffset) .opacity(showGallery ? 1 : 0) FadeInLetter(start: startLetter) } } } }
import SwiftUI struct BloomCameraWelcomeSnippet: View { @State private var showGallery = false @State private var startLetter = false @State private var showCTA = false var body: some View { ZStack(alignment: .top) { Config.backgroundColor .ignoresSafeArea() VStack(spacing: 0) { MarqueeGallery() .frame(height: Config.galleryHeight) .offset(y: showGallery ? 0 : Config.galleryEntranceOffset) .opacity(showGallery ? 1 : 0) FadeInLetter(start: startLetter) } } } }
import SwiftUI // BloomCameraWelcomeSnippet // // A self-running onboarding screen with three timed entrances and one // continuous motion layer. // 1. Gallery (top): a row of photo tiles drops in from above, then // drifts right to left forever. Each tile has a fixed viewfinder // shape (squircle, circle, or scalloped star). Shapes do not animate. // 2. Letter (middle): a single block of italic copy fades in once, // after a short beat. The user can drag to scroll if it overflows. // 3. CTA (bottom): a dark rounded button fades and lifts in last. // // The tiles load real photos from Unsplash via AsyncImage, masked through // the viewfinder shape. Edit Config.cards to swap them out. // // One file, no external dependencies. Drop it into any iOS 26+ app or // Swift Playground and it runs. Network access is required for the // gallery and signature avatar to fetch their photos. // MARK: - Config /// Everything a copy-paster might want to tweak. Edit values here. The /// rest of the file reads from Config so views and math do not need touching. private enum Config { // MARK: Copy /// Onboarding letter. Paragraphs are separated by blank lines. Add as /// many as you like; the loop length self-tunes to content height. static let letter = """ Welcome to Bloom Camera! A small experiment in slower photography. Here, the frame comes first. Think about composition, shape, and color before you take the picture, not after. Place your viewfinder inside a circle, a heart, a window, and let the picture come to you. Move freely. Let the world rearrange itself inside the shape until something interesting lands. No filters to chase, no presets to undo. Just you, a frame, and a moment that is already there. No accounts. No tracking. Your photos stay on your device. """ /// Author name shown at the end of the letter. Swap for your own. static let signatureName = "Anonymous" /// Small avatar photo next to the signature name. Real image, masked /// to a circle. Swap the photo ID for your own portrait. static let signatureAvatarURL = unsplash("1535713875002-d1d0cf377fde") /// Text on the bottom button. static let ctaTitle = "Get started" // MARK: Marquee cards /// Photos that drift across the top. Each card carries: /// - `color`: tint of the colored card frame (placeholder + fallback) /// - `imageURL`: Unsplash CDN URL of the photo /// - `shape`: the fixed viewfinder shape this photo sits inside. /// **Does not animate.** Each photo keeps its shape forever, the /// way each photo in the source design was framed with one shape. /// - `rotation`: small per-card rotation so cards look scattered /// like polaroids /// /// To use your own assets, replace `imageURL` with a bundle Image name /// and swap `AsyncImage` for `Image(...)` in Tile. static let cards: [CardSpec] = [ .init( color: Color(red: 0.55, green: 0.74, blue: 0.92), imageURL: unsplash("1486325212027-8081e485255e"), // St Paul's Cathedral shape: .squircle, rotation: -6 ), .init( color: Color(red: 0.78, green: 0.60, blue: 0.25), imageURL: unsplash("1506905925346-21bda4d32df4"), // Moraine Lake, Banff shape: .star, rotation: 4 ), .init( color: Color(red: 0.46, green: 0.49, blue: 0.55), imageURL: unsplash("1502082553048-f009c37129b9"), // warm portrait shape: .circle, rotation: -3 ), .init( color: Color(red: 0.72, green: 0.16, blue: 0.18), imageURL: unsplash("1488646953014-85cb44e25828"), // desert / antelope canyon warmth shape: .squircle, rotation: 7 ), .init( color: Color(red: 0.22, green: 0.45, blue: 0.32), imageURL: unsplash("1485470733090-0aae1788d5af"), // forest / nature shape: .star, rotation: -5 ), ] /// Builds an Unsplash CDN URL. Caller passes only the photo ID /// (the part after `photo-` in any unsplash.com URL). private static func unsplash(_ id: String) -> String { "https://images.unsplash.com/photo-\(id)?w=600&h=600&fit=crop&auto=format&q=70" } // MARK: Theme /// Page background. Pure black matches the source design. static let backgroundColor: Color = .black /// Body letter color. static let bodyTextColor: Color = .white.opacity(0.88) /// Signature row color. static let signatureColor: Color = .white.opacity(0.7) /// CTA fill. Solid dark gray (not a Material), slightly lighter than /// the screen background so the button reads as a distinct surface. /// Color(white: 0.12) lines up with the iOS system grey tones used /// in the source design. static let ctaBackground: Color = Color(white: 0.12) /// CTA hairline border. A barely-there light stroke sits just inside /// the fill in the source design. Reads as a 1pt edge highlight, /// not a colored border. White at low opacity so it shows on top of /// the dark fill without picking up a tint. static let ctaBorderColor: Color = .white.opacity(0.12) /// CTA label color. static let ctaTextColor: Color = .white // MARK: Layout (points) /// Width and height of each square tile, before it gets clipped to a /// shape. ~160pt matches the source design where the cards are large /// and feel chunky / poster-sized rather than thumbnail-sized. static let cardSize: CGFloat = 160 /// Gap between two tiles in the row. Negative on purpose so adjacent /// tiles overlap slightly, matching the source design's tight, /// layered, "stack of polaroids" feel. Z-order is left-to-right (the /// rightmost tile sits on top of its left neighbour). Tune by hand /// if you change `cardSize` — pick a value that keeps roughly the /// same overlap ratio. static let cardSpacing: CGFloat = -30 /// Corner radius of the colored card frame (the squircle that holds /// each photo). The card itself is always a squircle in the source /// design; only the inner viewfinder shape varies per card. static let cardCornerRadius: CGFloat = 32 /// Fraction of the card size the inner photo viewfinder occupies. /// 0.78 leaves a noticeable colored ring of card around the photo /// on every side, matching the source design's generous internal /// padding (photos sit comfortably inside their colored frames, /// not flush to the edge). static let viewfinderScale: CGFloat = 0.78 /// Height of the gallery strip area. A bit taller than `cardSize` so /// the tilted tiles and their shadows are not clipped at top or bottom. static let galleryHeight: CGFloat = 220 /// Breathing room above the gallery, on top of the safe area inset. /// 4pt matches the source design where the cards sit right under the /// dynamic island with minimal gap. static let galleryTopPadding: CGFloat = 4 /// Side padding around the scrolling letter. static let bodyHorizontalPadding: CGFloat = 28 /// Letter body font size, in points. 20pt is noticeably bigger than /// the default `.body` text style (~17pt) and matches the source /// design, which uses an oversized serif italic to give the letter /// the weight of a real handwritten note. Critical on larger devices /// (Pro Max, iPad) where 17pt body text reads as cramped. static let letterFontSize: CGFloat = 20 /// Vertical gap between paragraphs in the letter. Scales with /// `letterFontSize` so the rhythm stays consistent if you bump the /// font: ~1.1x font size gives a generous, letter-like spacing. static let letterParagraphSpacing: CGFloat = 22 /// Extra space between lines *inside* a paragraph, on top of the /// font's natural line height. 5pt at 20pt body keeps long /// paragraphs airy without looking double-spaced. static let letterLineSpacing: CGFloat = 5 /// Signature row font size. Slightly smaller than the body so the /// author credit reads as a postscript, not a continuation of the /// letter. static let signatureFontSize: CGFloat = 17 /// Signature avatar diameter. Tuned to match the signature font size /// so the avatar feels paired with the name rather than dwarfed by /// or dominating it. static let signatureAvatarSize: CGFloat = 30 /// Side padding around the bottom button. Larger value = narrower button. /// The source design has the button take up roughly 70% of the /// screen width, with a chunky ~45pt margin on each side that makes /// the CTA feel anchored rather than spanning edge to edge. static let ctaHorizontalPadding: CGFloat = 45 /// Gap between the button and the bottom safe area. Matches the /// source where the button sits a comfortable distance above the /// home indicator, not glued to the bottom. static let ctaBottomPadding: CGFloat = 18 /// Corner radius of the CTA. ~22pt matches the source design — /// generously rounded but not a pill (a pill would use radius = /// height/2; the source clearly has flat-ish top and bottom edges /// with corner curvature in between). static let ctaCornerRadius: CGFloat = 22 /// CTA internal vertical padding around the label. Source button is /// noticeably chunky — ~18pt vertical padding around 17pt body text /// gives a ~53pt total button height which matches. static let ctaVerticalPadding: CGFloat = 18 /// Width of the hairline border drawn just inside the CTA's rounded /// rect. 1pt is enough to read as a defined edge against the dark /// fill without looking like a real bordered control. static let ctaBorderWidth: CGFloat = 1 // MARK: Motion /// Marquee drift speed, in points per second to the left. static let marqueeSpeed: CGFloat = 38 /// Duration of the letter's single block fade-in. The whole letter /// (heading + paragraphs + signature) appears together with one /// opacity curve, matching the source design where the body copy /// is treated as one piece rather than revealed paragraph by /// paragraph. Slow on purpose — gives the gallery a beat alone /// before the eye moves down to the text. static let letterFadeDuration: Double = 1.2 /// Top breathing room inside the scrolling letter. Keeps the /// heading from sitting flush against the gallery's bottom edge /// when scrolled to the very top. static let letterTopPadding: CGFloat = 36 /// Bottom breathing room inside the scrolling letter. Keeps the /// signature from sitting flush against the CTA when scrolled all /// the way down, and gives the bottom edge fade room to feather /// without dimming the last line of text. static let letterBottomPadding: CGFloat = 48 /// Delay before the letter starts revealing. Lets the gallery enter alone first. static let letterAppearDelay: Duration = .milliseconds(450) /// Delay before the CTA fades and lifts in. static let ctaAppearDelay: Duration = .milliseconds(900) /// Gallery entrance: how far down (negative pulls it up off the top) /// the gallery starts before sliding into place. Matches the video's /// "cards drop in from the top" opening. static let galleryEntranceOffset: CGFloat = -120 } // MARK: - CardSpec /// One tile in the marquee. The UUID lets `ForEach` keep its identity when /// the same card appears multiple times (the marquee tiles the cards list /// three times in a row to fake an infinite strip). struct CardSpec: Identifiable, Hashable { let id = UUID() /// Tint color used as the colored card frame around the viewfinder. let color: Color /// Full image URL. Defaults in Config point at Unsplash CDN. let imageURL: String /// Fixed viewfinder shape for this photo. Does not animate; each photo /// keeps the shape it was framed with, matching the source design. var shape: Archetype = .squircle /// Small per-card rotation in degrees. Gives the strip a scattered /// polaroid feel instead of every tile sitting perfectly upright. var rotation: Double = 0 } // MARK: - Root view /// Bloom Camera welcome screen. Auto-running gallery on top, single-block /// fade-in letter below, delayed CTA pinned to the bottom. struct BloomCameraWelcomeSnippet: View { @State private var showGallery = false @State private var startLetter = false @State private var showCTA = false var body: some View { // Background under everything, extending past the safe areas so the // status bar and home indicator regions are also painted. Content // sits in a ZStack above it and naturally stays inside the safe area. ZStack(alignment: .top) { Config.backgroundColor .ignoresSafeArea() VStack(spacing: 0) { MarqueeGallery() .frame(height: Config.galleryHeight) .padding(.top, Config.galleryTopPadding) .offset(y: showGallery ? 0 : Config.galleryEntranceOffset) .opacity(showGallery ? 1 : 0) .animation(.spring(duration: 0.7, bounce: 0.25), value: showGallery) // `containerRelativeFrame` gives the letter an explicit // width tied to the enclosing container so inner Text wraps. // Vertical breathing room lives inside the FadeInLetter // itself (`Config.letterTopPadding` / `letterBottomPadding`) // so the scrollable content can extend right to the // letter container's edges and the padding scrolls with // it instead of being a hard outer gap. FadeInLetter(start: startLetter) .containerRelativeFrame(.horizontal) { width, _ in width - Config.bodyHorizontalPadding * 2 } .frame(maxHeight: .infinity, alignment: .top) } } // Pin the CTA in a safe-area-aware region so the layout above leaves // room and the button never overlaps the home indicator. .safeAreaInset(edge: .bottom) { CTAButton(title: Config.ctaTitle) { // TODO: wire this up to real navigation } .padding(.horizontal, Config.ctaHorizontalPadding) .padding(.bottom, Config.ctaBottomPadding) .opacity(showCTA ? 1 : 0) .offset(y: showCTA ? 0 : 24) .animation(.easeOut(duration: 0.55), value: showCTA) } .preferredColorScheme(.dark) .task { // Gallery first, then letter starts revealing, then CTA. Matches // the source design where the photo strip lands a beat before // the text begins to appear. withAnimation { showGallery = true } try? await Task.sleep(for: Config.letterAppearDelay) startLetter = true try? await Task.sleep(for: Config.ctaAppearDelay - Config.letterAppearDelay) showCTA = true } } } // MARK: - Marquee gallery /// Row of tiles that drifts left forever. Renders 3 copies of the cards /// list side by side and wraps the HStack's x offset with `truncatingRemainder`, /// so there is always a copy on screen no matter where the offset lands. private struct MarqueeGallery: View { /// Locked at view creation so motion starts at zero on appear, not at /// some random phase of the global clock. @State private var startDate = Date() /// Width of one full copy of the cards plus their spacing. Used as the /// wrap modulus for the scroll offset. private var oneSetWidth: CGFloat { CGFloat(Config.cards.count) * (Config.cardSize + Config.cardSpacing) } var body: some View { TimelineView(.animation) { context in let t = context.date.timeIntervalSince(startDate) let offset = (-CGFloat(t) * Config.marqueeSpeed) .truncatingRemainder(dividingBy: oneSetWidth) HStack(spacing: Config.cardSpacing) { ForEach(0..<3, id: \.self) { _ in ForEach(Config.cards) { card in Tile(card: card) } } } .offset(x: offset) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) } // Fill the full gallery frame (not just the cards' natural 120pt // height) so that rotated card corners and drop shadows have room // and don't get clipped at the top of the row. .frame(maxWidth: .infinity, maxHeight: .infinity) // No horizontal edge fade. The source design lets the leftmost // tile slide off the screen with a hard edge, not a soft fade. // `.clipped()` keeps the offscreen tiles from being visible past // the gallery's measured frame. .clipped() } } // MARK: - Tile /// A single tile. Two layers, both **static**: /// 1. The **card**, a constant rounded square (squircle) filled with the /// card's tint color. /// 2. The **viewfinder**, the photo masked to the card's `Archetype` /// shape. The shape is fixed per card and does not animate; each /// photo keeps the shape it was framed with. /// /// This matches the source design's "place your camera inside a shape" /// concept: the card is the frame, the shape is the viewfinder, and each /// photo lives in exactly one viewfinder shape forever. private struct Tile: View { let card: CardSpec var body: some View { let viewfinderSize = Config.cardSize * Config.viewfinderScale ZStack { // Constant card frame. Solid tint color, always a squircle in // the source design regardless of the inner viewfinder shape. RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill(card.color) // Photo viewfinder. Image loads once and is masked to the // card's fixed shape. No animation. AsyncImage(url: URL(string: card.imageURL)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() .transition(.opacity.animation(.easeOut(duration: 0.4))) case .failure: Image(systemName: "photo") .font(.system(size: viewfinderSize * 0.35)) .foregroundStyle(.white.opacity(0.4)) case .empty: Color.clear @unknown default: Color.clear } } .frame(width: viewfinderSize, height: viewfinderSize) .clipShape(ViewfinderShape(archetype: card.shape)) } .frame(width: Config.cardSize, height: Config.cardSize) // Clip the whole tile to the card squircle so the AsyncImage cannot // bleed past the card's edges during `scaledToFill`. .clipShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) .shadow(color: .black.opacity(0.45), radius: 10, x: 0, y: 6) // Per-card rotation gives the strip a scattered polaroid feel, // matching the source design where no two tiles sit at the same angle. .rotationEffect(.degrees(card.rotation)) } } // MARK: - Viewfinder shape /// A static custom `Shape` for one `Archetype`. Samples `N` points around /// the chosen archetype's perimeter and builds a closed path. Does not /// animate; the shape per card is fixed at view-creation time. struct ViewfinderShape: Shape { let archetype: Archetype /// Points sampled around the shape. 160 reads as a smooth curve at any tile size. private static let sampleCount = 160 func path(in rect: CGRect) -> Path { let center = CGPoint(x: rect.midX, y: rect.midY) let radius = min(rect.width, rect.height) / 2 var path = Path() for i in 0..<Self.sampleCount { let t = Double(i) / Double(Self.sampleCount) let p = archetype.point(at: t) let point = CGPoint(x: center.x + p.x * radius, y: center.y + p.y * radius) i == 0 ? path.move(to: point) : path.addLine(to: point) } path.closeSubpath() return path } } // MARK: - Archetype /// One closed shape recipe. Each case returns points in unit coordinates: /// (0, 0) center, x and y in [-1, +1], y positive going down. `t` in [0, 1) /// traces the perimeter clockwise starting from the top. /// /// Three cases ship: the ones the source design actually uses. To add a /// new shape, add a case and return its points the same way (sampled /// around the unit circle); then reference it from `Config.cards`. enum Archetype { case squircle, circle, star /// Returns the point on this shape at parameter `t` in [0, 1). func point(at t: Double) -> CGPoint { let angle = t * 2 * .pi - .pi / 2 let cosA = cos(angle) let sinA = sin(angle) switch self { case .circle: return CGPoint(x: cosA, y: sinA) case .squircle: // App icon shape: a superellipse with exponent 4 — corners // rounder than a rounded rectangle, edges flatter than a // circle. Sign tricks keep the absolute powers in the right // quadrant so the closed perimeter wraps cleanly. let n = 4.0 let sx = cosA >= 0 ? 1.0 : -1.0 let sy = sinA >= 0 ? 1.0 : -1.0 let x = sx * pow(abs(cosA), 2 / n) let y = sy * pow(abs(sinA), 2 / n) return CGPoint(x: x, y: y) case .star: // Puffy scalloped cloud-flower with 8 small lobes around the // perimeter. In the source design this is what reads as the // "star" — not a sharp geometric star but a soft, almost // doodled shape with many shallow bumps, like a cartoon // cloud or a flower in plan view. // // r(θ) = (1 - depth) + depth * (1 + cos(lobes * (θ - top))) / 2 // // `cos(...)` swings smoothly between -1 (valley) and +1 // (lobe peak), so the radius oscillates between // `1 - depth` and `1`. More lobes means smaller, finer bumps; // 8 reads as a fluffy cloud edge. `depth` controls how // pronounced each bump is — too shallow looks like a circle, // too deep looks like a sharp gear. The `(θ - top)` offset // aligns one lobe with the top of the shape; without it the // lobes land at arbitrary angles and the shape reads as an // irregular blob. let lobes = 8.0 let depth = 0.18 let topAngle = -Double.pi / 2 let r = (1 - depth) + depth * 0.5 * (1 + cos(lobes * (angle - topAngle))) return CGPoint(x: r * cosA, y: r * sinA) } } } // MARK: - Fade-in letter /// Italic letter rendered as one block. The entire letter (all paragraphs /// plus the signature) fades in once when `start` flips to true. After /// that the content is just a normal scrollable column — the user can /// drag to scroll if the letter is longer than the visible area. /// /// Anchored at the top (heading appears right below the gallery), with /// a subtle bottom-only edge fade so any text that runs past the visible /// bottom softens against the CTA area instead of hard-clipping. There /// is no top fade; the heading reads cleanly from the moment it appears. private struct FadeInLetter: View { /// Flips to true from the parent once the gallery has settled. /// Triggers the single block fade-in. let start: Bool /// Letter split on blank lines, one element per paragraph. private var paragraphs: [String] { Config.letter.components(separatedBy: "\n\n") } var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: Config.letterParagraphSpacing) { ForEach(Array(paragraphs.enumerated()), id: \.offset) { _, paragraph in Text(paragraph) .font(.system(size: Config.letterFontSize, design: .serif).italic()) .foregroundStyle(Config.bodyTextColor) .lineSpacing(Config.letterLineSpacing) .frame(maxWidth: .infinity, alignment: .leading) } signature .padding(.top, 8) } // Top + bottom breathing room inside the scrolling content. // Top keeps the heading from sitting flush with the gallery // edge when scrolled to the top; bottom keeps the signature // off the CTA when scrolled all the way down and gives the // bottom edge fade clearance from the last line. .padding(.top, Config.letterTopPadding) .padding(.bottom, Config.letterBottomPadding) } // Subtle bottom-only mask: keeps the text crisp through the // whole window, only softens the bottom ~12% so anything // scrolling toward the CTA area feathers out instead of being // hard-clipped. No top mask — the heading reads cleanly. .mask(bottomFade) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) // The single block fade-in. Triggers once on `start`. .opacity(start ? 1 : 0) .animation(.easeOut(duration: Config.letterFadeDuration), value: start) } /// One-sided gradient: opaque from top down to ~88%, then fades to /// clear at the bottom. Used as a mask so scrolling text feathers /// out cleanly into the CTA area. private var bottomFade: some View { LinearGradient( stops: [ .init(color: .black, location: 0.0), .init(color: .black, location: 0.88), .init(color: .clear, location: 1.0), ], startPoint: .top, endPoint: .bottom ) } /// Small author credit row. Avatar is a real photo from the Unsplash /// CDN, masked to a circle. A tinted circle sits behind it as a /// placeholder so the row never appears empty during the network /// round-trip or on failure. private var signature: some View { HStack(spacing: 10) { ZStack { Circle() .fill(LinearGradient( colors: [.gray.opacity(0.55), .gray.opacity(0.25)], startPoint: .topLeading, endPoint: .bottomTrailing )) AsyncImage(url: URL(string: Config.signatureAvatarURL)) { image in image .resizable() .scaledToFill() } placeholder: { Color.clear } } .frame(width: Config.signatureAvatarSize, height: Config.signatureAvatarSize) .clipShape(Circle()) Text(Config.signatureName) .font(.system(size: Config.signatureFontSize, design: .serif).italic()) .foregroundStyle(Config.signatureColor) } .padding(.top, 4) } } // MARK: - CTA button /// Dark rounded rectangle with centered text and a hairline border just /// inside the fill. Matches the source design's CTA, which sits with /// visible horizontal margins (not a full-width pill) and reads as a /// single defined surface thanks to the subtle 1pt edge highlight. private struct CTAButton: View { let title: String let action: () -> Void var body: some View { Button(action: action) { Text(title) .font(.system(.body, weight: .regular)) .foregroundStyle(Config.ctaTextColor) .frame(maxWidth: .infinity) .padding(.vertical, Config.ctaVerticalPadding) .background( // Dark fill + hairline border, layered as one shape so // they share the same continuous corner. `strokeBorder` // (not `stroke`) draws the line *inside* the shape, so // adding the border does not visually grow the button. RoundedRectangle(cornerRadius: Config.ctaCornerRadius, style: .continuous) .fill(Config.ctaBackground) .overlay { RoundedRectangle(cornerRadius: Config.ctaCornerRadius, style: .continuous) .strokeBorder(Config.ctaBorderColor, lineWidth: Config.ctaBorderWidth) } ) } .buttonStyle(.plain) } } #Preview { BloomCameraWelcomeSnippet() }
import SwiftUI // BloomCameraWelcomeSnippet // // A self-running onboarding screen with three timed entrances and one // continuous motion layer. // 1. Gallery (top): a row of photo tiles drops in from above, then // drifts right to left forever. Each tile has a fixed viewfinder // shape (squircle, circle, or scalloped star). Shapes do not animate. // 2. Letter (middle): a single block of italic copy fades in once, // after a short beat. The user can drag to scroll if it overflows. // 3. CTA (bottom): a dark rounded button fades and lifts in last. // // The tiles load real photos from Unsplash via AsyncImage, masked through // the viewfinder shape. Edit Config.cards to swap them out. // // One file, no external dependencies. Drop it into any iOS 26+ app or // Swift Playground and it runs. Network access is required for the // gallery and signature avatar to fetch their photos. // MARK: - Config /// Everything a copy-paster might want to tweak. Edit values here. The /// rest of the file reads from Config so views and math do not need touching. private enum Config { // MARK: Copy /// Onboarding letter. Paragraphs are separated by blank lines. Add as /// many as you like; the loop length self-tunes to content height. static let letter = """ Welcome to Bloom Camera! A small experiment in slower photography. Here, the frame comes first. Think about composition, shape, and color before you take the picture, not after. Place your viewfinder inside a circle, a heart, a window, and let the picture come to you. Move freely. Let the world rearrange itself inside the shape until something interesting lands. No filters to chase, no presets to undo. Just you, a frame, and a moment that is already there. No accounts. No tracking. Your photos stay on your device. """ /// Author name shown at the end of the letter. Swap for your own. static let signatureName = "Anonymous" /// Small avatar photo next to the signature name. Real image, masked /// to a circle. Swap the photo ID for your own portrait. static let signatureAvatarURL = unsplash("1535713875002-d1d0cf377fde") /// Text on the bottom button. static let ctaTitle = "Get started" // MARK: Marquee cards /// Photos that drift across the top. Each card carries: /// - `color`: tint of the colored card frame (placeholder + fallback) /// - `imageURL`: Unsplash CDN URL of the photo /// - `shape`: the fixed viewfinder shape this photo sits inside. /// **Does not animate.** Each photo keeps its shape forever, the /// way each photo in the source design was framed with one shape. /// - `rotation`: small per-card rotation so cards look scattered /// like polaroids /// /// To use your own assets, replace `imageURL` with a bundle Image name /// and swap `AsyncImage` for `Image(...)` in Tile. static let cards: [CardSpec] = [ .init( color: Color(red: 0.55, green: 0.74, blue: 0.92), imageURL: unsplash("1486325212027-8081e485255e"), // St Paul's Cathedral shape: .squircle, rotation: -6 ), .init( color: Color(red: 0.78, green: 0.60, blue: 0.25), imageURL: unsplash("1506905925346-21bda4d32df4"), // Moraine Lake, Banff shape: .star, rotation: 4 ), .init( color: Color(red: 0.46, green: 0.49, blue: 0.55), imageURL: unsplash("1502082553048-f009c37129b9"), // warm portrait shape: .circle, rotation: -3 ), .init( color: Color(red: 0.72, green: 0.16, blue: 0.18), imageURL: unsplash("1488646953014-85cb44e25828"), // desert / antelope canyon warmth shape: .squircle, rotation: 7 ), .init( color: Color(red: 0.22, green: 0.45, blue: 0.32), imageURL: unsplash("1485470733090-0aae1788d5af"), // forest / nature shape: .star, rotation: -5 ), ] /// Builds an Unsplash CDN URL. Caller passes only the photo ID /// (the part after `photo-` in any unsplash.com URL). private static func unsplash(_ id: String) -> String { "https://images.unsplash.com/photo-\(id)?w=600&h=600&fit=crop&auto=format&q=70" } // MARK: Theme /// Page background. Pure black matches the source design. static let backgroundColor: Color = .black /// Body letter color. static let bodyTextColor: Color = .white.opacity(0.88) /// Signature row color. static let signatureColor: Color = .white.opacity(0.7) /// CTA fill. Solid dark gray (not a Material), slightly lighter than /// the screen background so the button reads as a distinct surface. /// Color(white: 0.12) lines up with the iOS system grey tones used /// in the source design. static let ctaBackground: Color = Color(white: 0.12) /// CTA hairline border. A barely-there light stroke sits just inside /// the fill in the source design. Reads as a 1pt edge highlight, /// not a colored border. White at low opacity so it shows on top of /// the dark fill without picking up a tint. static let ctaBorderColor: Color = .white.opacity(0.12) /// CTA label color. static let ctaTextColor: Color = .white // MARK: Layout (points) /// Width and height of each square tile, before it gets clipped to a /// shape. ~160pt matches the source design where the cards are large /// and feel chunky / poster-sized rather than thumbnail-sized. static let cardSize: CGFloat = 160 /// Gap between two tiles in the row. Negative on purpose so adjacent /// tiles overlap slightly, matching the source design's tight, /// layered, "stack of polaroids" feel. Z-order is left-to-right (the /// rightmost tile sits on top of its left neighbour). Tune by hand /// if you change `cardSize` — pick a value that keeps roughly the /// same overlap ratio. static let cardSpacing: CGFloat = -30 /// Corner radius of the colored card frame (the squircle that holds /// each photo). The card itself is always a squircle in the source /// design; only the inner viewfinder shape varies per card. static let cardCornerRadius: CGFloat = 32 /// Fraction of the card size the inner photo viewfinder occupies. /// 0.78 leaves a noticeable colored ring of card around the photo /// on every side, matching the source design's generous internal /// padding (photos sit comfortably inside their colored frames, /// not flush to the edge). static let viewfinderScale: CGFloat = 0.78 /// Height of the gallery strip area. A bit taller than `cardSize` so /// the tilted tiles and their shadows are not clipped at top or bottom. static let galleryHeight: CGFloat = 220 /// Breathing room above the gallery, on top of the safe area inset. /// 4pt matches the source design where the cards sit right under the /// dynamic island with minimal gap. static let galleryTopPadding: CGFloat = 4 /// Side padding around the scrolling letter. static let bodyHorizontalPadding: CGFloat = 28 /// Letter body font size, in points. 20pt is noticeably bigger than /// the default `.body` text style (~17pt) and matches the source /// design, which uses an oversized serif italic to give the letter /// the weight of a real handwritten note. Critical on larger devices /// (Pro Max, iPad) where 17pt body text reads as cramped. static let letterFontSize: CGFloat = 20 /// Vertical gap between paragraphs in the letter. Scales with /// `letterFontSize` so the rhythm stays consistent if you bump the /// font: ~1.1x font size gives a generous, letter-like spacing. static let letterParagraphSpacing: CGFloat = 22 /// Extra space between lines *inside* a paragraph, on top of the /// font's natural line height. 5pt at 20pt body keeps long /// paragraphs airy without looking double-spaced. static let letterLineSpacing: CGFloat = 5 /// Signature row font size. Slightly smaller than the body so the /// author credit reads as a postscript, not a continuation of the /// letter. static let signatureFontSize: CGFloat = 17 /// Signature avatar diameter. Tuned to match the signature font size /// so the avatar feels paired with the name rather than dwarfed by /// or dominating it. static let signatureAvatarSize: CGFloat = 30 /// Side padding around the bottom button. Larger value = narrower button. /// The source design has the button take up roughly 70% of the /// screen width, with a chunky ~45pt margin on each side that makes /// the CTA feel anchored rather than spanning edge to edge. static let ctaHorizontalPadding: CGFloat = 45 /// Gap between the button and the bottom safe area. Matches the /// source where the button sits a comfortable distance above the /// home indicator, not glued to the bottom. static let ctaBottomPadding: CGFloat = 18 /// Corner radius of the CTA. ~22pt matches the source design — /// generously rounded but not a pill (a pill would use radius = /// height/2; the source clearly has flat-ish top and bottom edges /// with corner curvature in between). static let ctaCornerRadius: CGFloat = 22 /// CTA internal vertical padding around the label. Source button is /// noticeably chunky — ~18pt vertical padding around 17pt body text /// gives a ~53pt total button height which matches. static let ctaVerticalPadding: CGFloat = 18 /// Width of the hairline border drawn just inside the CTA's rounded /// rect. 1pt is enough to read as a defined edge against the dark /// fill without looking like a real bordered control. static let ctaBorderWidth: CGFloat = 1 // MARK: Motion /// Marquee drift speed, in points per second to the left. static let marqueeSpeed: CGFloat = 38 /// Duration of the letter's single block fade-in. The whole letter /// (heading + paragraphs + signature) appears together with one /// opacity curve, matching the source design where the body copy /// is treated as one piece rather than revealed paragraph by /// paragraph. Slow on purpose — gives the gallery a beat alone /// before the eye moves down to the text. static let letterFadeDuration: Double = 1.2 /// Top breathing room inside the scrolling letter. Keeps the /// heading from sitting flush against the gallery's bottom edge /// when scrolled to the very top. static let letterTopPadding: CGFloat = 36 /// Bottom breathing room inside the scrolling letter. Keeps the /// signature from sitting flush against the CTA when scrolled all /// the way down, and gives the bottom edge fade room to feather /// without dimming the last line of text. static let letterBottomPadding: CGFloat = 48 /// Delay before the letter starts revealing. Lets the gallery enter alone first. static let letterAppearDelay: Duration = .milliseconds(450) /// Delay before the CTA fades and lifts in. static let ctaAppearDelay: Duration = .milliseconds(900) /// Gallery entrance: how far down (negative pulls it up off the top) /// the gallery starts before sliding into place. Matches the video's /// "cards drop in from the top" opening. static let galleryEntranceOffset: CGFloat = -120 } // MARK: - CardSpec /// One tile in the marquee. The UUID lets `ForEach` keep its identity when /// the same card appears multiple times (the marquee tiles the cards list /// three times in a row to fake an infinite strip). struct CardSpec: Identifiable, Hashable { let id = UUID() /// Tint color used as the colored card frame around the viewfinder. let color: Color /// Full image URL. Defaults in Config point at Unsplash CDN. let imageURL: String /// Fixed viewfinder shape for this photo. Does not animate; each photo /// keeps the shape it was framed with, matching the source design. var shape: Archetype = .squircle /// Small per-card rotation in degrees. Gives the strip a scattered /// polaroid feel instead of every tile sitting perfectly upright. var rotation: Double = 0 } // MARK: - Root view /// Bloom Camera welcome screen. Auto-running gallery on top, single-block /// fade-in letter below, delayed CTA pinned to the bottom. struct BloomCameraWelcomeSnippet: View { @State private var showGallery = false @State private var startLetter = false @State private var showCTA = false var body: some View { // Background under everything, extending past the safe areas so the // status bar and home indicator regions are also painted. Content // sits in a ZStack above it and naturally stays inside the safe area. ZStack(alignment: .top) { Config.backgroundColor .ignoresSafeArea() VStack(spacing: 0) { MarqueeGallery() .frame(height: Config.galleryHeight) .padding(.top, Config.galleryTopPadding) .offset(y: showGallery ? 0 : Config.galleryEntranceOffset) .opacity(showGallery ? 1 : 0) .animation(.spring(duration: 0.7, bounce: 0.25), value: showGallery) // `containerRelativeFrame` gives the letter an explicit // width tied to the enclosing container so inner Text wraps. // Vertical breathing room lives inside the FadeInLetter // itself (`Config.letterTopPadding` / `letterBottomPadding`) // so the scrollable content can extend right to the // letter container's edges and the padding scrolls with // it instead of being a hard outer gap. FadeInLetter(start: startLetter) .containerRelativeFrame(.horizontal) { width, _ in width - Config.bodyHorizontalPadding * 2 } .frame(maxHeight: .infinity, alignment: .top) } } // Pin the CTA in a safe-area-aware region so the layout above leaves // room and the button never overlaps the home indicator. .safeAreaInset(edge: .bottom) { CTAButton(title: Config.ctaTitle) { // TODO: wire this up to real navigation } .padding(.horizontal, Config.ctaHorizontalPadding) .padding(.bottom, Config.ctaBottomPadding) .opacity(showCTA ? 1 : 0) .offset(y: showCTA ? 0 : 24) .animation(.easeOut(duration: 0.55), value: showCTA) } .preferredColorScheme(.dark) .task { // Gallery first, then letter starts revealing, then CTA. Matches // the source design where the photo strip lands a beat before // the text begins to appear. withAnimation { showGallery = true } try? await Task.sleep(for: Config.letterAppearDelay) startLetter = true try? await Task.sleep(for: Config.ctaAppearDelay - Config.letterAppearDelay) showCTA = true } } } // MARK: - Marquee gallery /// Row of tiles that drifts left forever. Renders 3 copies of the cards /// list side by side and wraps the HStack's x offset with `truncatingRemainder`, /// so there is always a copy on screen no matter where the offset lands. private struct MarqueeGallery: View { /// Locked at view creation so motion starts at zero on appear, not at /// some random phase of the global clock. @State private var startDate = Date() /// Width of one full copy of the cards plus their spacing. Used as the /// wrap modulus for the scroll offset. private var oneSetWidth: CGFloat { CGFloat(Config.cards.count) * (Config.cardSize + Config.cardSpacing) } var body: some View { TimelineView(.animation) { context in let t = context.date.timeIntervalSince(startDate) let offset = (-CGFloat(t) * Config.marqueeSpeed) .truncatingRemainder(dividingBy: oneSetWidth) HStack(spacing: Config.cardSpacing) { ForEach(0..<3, id: \.self) { _ in ForEach(Config.cards) { card in Tile(card: card) } } } .offset(x: offset) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) } // Fill the full gallery frame (not just the cards' natural 120pt // height) so that rotated card corners and drop shadows have room // and don't get clipped at the top of the row. .frame(maxWidth: .infinity, maxHeight: .infinity) // No horizontal edge fade. The source design lets the leftmost // tile slide off the screen with a hard edge, not a soft fade. // `.clipped()` keeps the offscreen tiles from being visible past // the gallery's measured frame. .clipped() } } // MARK: - Tile /// A single tile. Two layers, both **static**: /// 1. The **card**, a constant rounded square (squircle) filled with the /// card's tint color. /// 2. The **viewfinder**, the photo masked to the card's `Archetype` /// shape. The shape is fixed per card and does not animate; each /// photo keeps the shape it was framed with. /// /// This matches the source design's "place your camera inside a shape" /// concept: the card is the frame, the shape is the viewfinder, and each /// photo lives in exactly one viewfinder shape forever. private struct Tile: View { let card: CardSpec var body: some View { let viewfinderSize = Config.cardSize * Config.viewfinderScale ZStack { // Constant card frame. Solid tint color, always a squircle in // the source design regardless of the inner viewfinder shape. RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill(card.color) // Photo viewfinder. Image loads once and is masked to the // card's fixed shape. No animation. AsyncImage(url: URL(string: card.imageURL)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() .transition(.opacity.animation(.easeOut(duration: 0.4))) case .failure: Image(systemName: "photo") .font(.system(size: viewfinderSize * 0.35)) .foregroundStyle(.white.opacity(0.4)) case .empty: Color.clear @unknown default: Color.clear } } .frame(width: viewfinderSize, height: viewfinderSize) .clipShape(ViewfinderShape(archetype: card.shape)) } .frame(width: Config.cardSize, height: Config.cardSize) // Clip the whole tile to the card squircle so the AsyncImage cannot // bleed past the card's edges during `scaledToFill`. .clipShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) .shadow(color: .black.opacity(0.45), radius: 10, x: 0, y: 6) // Per-card rotation gives the strip a scattered polaroid feel, // matching the source design where no two tiles sit at the same angle. .rotationEffect(.degrees(card.rotation)) } } // MARK: - Viewfinder shape /// A static custom `Shape` for one `Archetype`. Samples `N` points around /// the chosen archetype's perimeter and builds a closed path. Does not /// animate; the shape per card is fixed at view-creation time. struct ViewfinderShape: Shape { let archetype: Archetype /// Points sampled around the shape. 160 reads as a smooth curve at any tile size. private static let sampleCount = 160 func path(in rect: CGRect) -> Path { let center = CGPoint(x: rect.midX, y: rect.midY) let radius = min(rect.width, rect.height) / 2 var path = Path() for i in 0..<Self.sampleCount { let t = Double(i) / Double(Self.sampleCount) let p = archetype.point(at: t) let point = CGPoint(x: center.x + p.x * radius, y: center.y + p.y * radius) i == 0 ? path.move(to: point) : path.addLine(to: point) } path.closeSubpath() return path } } // MARK: - Archetype /// One closed shape recipe. Each case returns points in unit coordinates: /// (0, 0) center, x and y in [-1, +1], y positive going down. `t` in [0, 1) /// traces the perimeter clockwise starting from the top. /// /// Three cases ship: the ones the source design actually uses. To add a /// new shape, add a case and return its points the same way (sampled /// around the unit circle); then reference it from `Config.cards`. enum Archetype { case squircle, circle, star /// Returns the point on this shape at parameter `t` in [0, 1). func point(at t: Double) -> CGPoint { let angle = t * 2 * .pi - .pi / 2 let cosA = cos(angle) let sinA = sin(angle) switch self { case .circle: return CGPoint(x: cosA, y: sinA) case .squircle: // App icon shape: a superellipse with exponent 4 — corners // rounder than a rounded rectangle, edges flatter than a // circle. Sign tricks keep the absolute powers in the right // quadrant so the closed perimeter wraps cleanly. let n = 4.0 let sx = cosA >= 0 ? 1.0 : -1.0 let sy = sinA >= 0 ? 1.0 : -1.0 let x = sx * pow(abs(cosA), 2 / n) let y = sy * pow(abs(sinA), 2 / n) return CGPoint(x: x, y: y) case .star: // Puffy scalloped cloud-flower with 8 small lobes around the // perimeter. In the source design this is what reads as the // "star" — not a sharp geometric star but a soft, almost // doodled shape with many shallow bumps, like a cartoon // cloud or a flower in plan view. // // r(θ) = (1 - depth) + depth * (1 + cos(lobes * (θ - top))) / 2 // // `cos(...)` swings smoothly between -1 (valley) and +1 // (lobe peak), so the radius oscillates between // `1 - depth` and `1`. More lobes means smaller, finer bumps; // 8 reads as a fluffy cloud edge. `depth` controls how // pronounced each bump is — too shallow looks like a circle, // too deep looks like a sharp gear. The `(θ - top)` offset // aligns one lobe with the top of the shape; without it the // lobes land at arbitrary angles and the shape reads as an // irregular blob. let lobes = 8.0 let depth = 0.18 let topAngle = -Double.pi / 2 let r = (1 - depth) + depth * 0.5 * (1 + cos(lobes * (angle - topAngle))) return CGPoint(x: r * cosA, y: r * sinA) } } } // MARK: - Fade-in letter /// Italic letter rendered as one block. The entire letter (all paragraphs /// plus the signature) fades in once when `start` flips to true. After /// that the content is just a normal scrollable column — the user can /// drag to scroll if the letter is longer than the visible area. /// /// Anchored at the top (heading appears right below the gallery), with /// a subtle bottom-only edge fade so any text that runs past the visible /// bottom softens against the CTA area instead of hard-clipping. There /// is no top fade; the heading reads cleanly from the moment it appears. private struct FadeInLetter: View { /// Flips to true from the parent once the gallery has settled. /// Triggers the single block fade-in. let start: Bool /// Letter split on blank lines, one element per paragraph. private var paragraphs: [String] { Config.letter.components(separatedBy: "\n\n") } var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: Config.letterParagraphSpacing) { ForEach(Array(paragraphs.enumerated()), id: \.offset) { _, paragraph in Text(paragraph) .font(.system(size: Config.letterFontSize, design: .serif).italic()) .foregroundStyle(Config.bodyTextColor) .lineSpacing(Config.letterLineSpacing) .frame(maxWidth: .infinity, alignment: .leading) } signature .padding(.top, 8) } // Top + bottom breathing room inside the scrolling content. // Top keeps the heading from sitting flush with the gallery // edge when scrolled to the top; bottom keeps the signature // off the CTA when scrolled all the way down and gives the // bottom edge fade clearance from the last line. .padding(.top, Config.letterTopPadding) .padding(.bottom, Config.letterBottomPadding) } // Subtle bottom-only mask: keeps the text crisp through the // whole window, only softens the bottom ~12% so anything // scrolling toward the CTA area feathers out instead of being // hard-clipped. No top mask — the heading reads cleanly. .mask(bottomFade) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) // The single block fade-in. Triggers once on `start`. .opacity(start ? 1 : 0) .animation(.easeOut(duration: Config.letterFadeDuration), value: start) } /// One-sided gradient: opaque from top down to ~88%, then fades to /// clear at the bottom. Used as a mask so scrolling text feathers /// out cleanly into the CTA area. private var bottomFade: some View { LinearGradient( stops: [ .init(color: .black, location: 0.0), .init(color: .black, location: 0.88), .init(color: .clear, location: 1.0), ], startPoint: .top, endPoint: .bottom ) } /// Small author credit row. Avatar is a real photo from the Unsplash /// CDN, masked to a circle. A tinted circle sits behind it as a /// placeholder so the row never appears empty during the network /// round-trip or on failure. private var signature: some View { HStack(spacing: 10) { ZStack { Circle() .fill(LinearGradient( colors: [.gray.opacity(0.55), .gray.opacity(0.25)], startPoint: .topLeading, endPoint: .bottomTrailing )) AsyncImage(url: URL(string: Config.signatureAvatarURL)) { image in image .resizable() .scaledToFill() } placeholder: { Color.clear } } .frame(width: Config.signatureAvatarSize, height: Config.signatureAvatarSize) .clipShape(Circle()) Text(Config.signatureName) .font(.system(size: Config.signatureFontSize, design: .serif).italic()) .foregroundStyle(Config.signatureColor) } .padding(.top, 4) } } // MARK: - CTA button /// Dark rounded rectangle with centered text and a hairline border just /// inside the fill. Matches the source design's CTA, which sits with /// visible horizontal margins (not a full-width pill) and reads as a /// single defined surface thanks to the subtle 1pt edge highlight. private struct CTAButton: View { let title: String let action: () -> Void var body: some View { Button(action: action) { Text(title) .font(.system(.body, weight: .regular)) .foregroundStyle(Config.ctaTextColor) .frame(maxWidth: .infinity) .padding(.vertical, Config.ctaVerticalPadding) .background( // Dark fill + hairline border, layered as one shape so // they share the same continuous corner. `strokeBorder` // (not `stroke`) draws the line *inside* the shape, so // adding the border does not visually grow the button. RoundedRectangle(cornerRadius: Config.ctaCornerRadius, style: .continuous) .fill(Config.ctaBackground) .overlay { RoundedRectangle(cornerRadius: Config.ctaCornerRadius, style: .continuous) .strokeBorder(Config.ctaBorderColor, lineWidth: Config.ctaBorderWidth) } ) } .buttonStyle(.plain) } } #Preview { BloomCameraWelcomeSnippet() }
import SwiftUI // BloomCameraWelcomeSnippet // // A self-running onboarding screen with three timed entrances and one // continuous motion layer. // 1. Gallery (top): a row of photo tiles drops in from above, then // drifts right to left forever. Each tile has a fixed viewfinder // shape (squircle, circle, or scalloped star). Shapes do not animate. // 2. Letter (middle): a single block of italic copy fades in once, // after a short beat. The user can drag to scroll if it overflows. // 3. CTA (bottom): a dark rounded button fades and lifts in last. // // The tiles load real photos from Unsplash via AsyncImage, masked through // the viewfinder shape. Edit Config.cards to swap them out. // // One file, no external dependencies. Drop it into any iOS 26+ app or // Swift Playground and it runs. Network access is required for the // gallery and signature avatar to fetch their photos. // MARK: - Config /// Everything a copy-paster might want to tweak. Edit values here. The /// rest of the file reads from Config so views and math do not need touching. private enum Config { // MARK: Copy /// Onboarding letter. Paragraphs are separated by blank lines. Add as /// many as you like; the loop length self-tunes to content height. static let letter = """ Welcome to Bloom Camera! A small experiment in slower photography. Here, the frame comes first. Think about composition, shape, and color before you take the picture, not after. Place your viewfinder inside a circle, a heart, a window, and let the picture come to you. Move freely. Let the world rearrange itself inside the shape until something interesting lands. No filters to chase, no presets to undo. Just you, a frame, and a moment that is already there. No accounts. No tracking. Your photos stay on your device. """ /// Author name shown at the end of the letter. Swap for your own. static let signatureName = "Anonymous" /// Small avatar photo next to the signature name. Real image, masked /// to a circle. Swap the photo ID for your own portrait. static let signatureAvatarURL = unsplash("1535713875002-d1d0cf377fde") /// Text on the bottom button. static let ctaTitle = "Get started" // MARK: Marquee cards /// Photos that drift across the top. Each card carries: /// - `color`: tint of the colored card frame (placeholder + fallback) /// - `imageURL`: Unsplash CDN URL of the photo /// - `shape`: the fixed viewfinder shape this photo sits inside. /// **Does not animate.** Each photo keeps its shape forever, the /// way each photo in the source design was framed with one shape. /// - `rotation`: small per-card rotation so cards look scattered /// like polaroids /// /// To use your own assets, replace `imageURL` with a bundle Image name /// and swap `AsyncImage` for `Image(...)` in Tile. static let cards: [CardSpec] = [ .init( color: Color(red: 0.55, green: 0.74, blue: 0.92), imageURL: unsplash("1486325212027-8081e485255e"), // St Paul's Cathedral shape: .squircle, rotation: -6 ), .init( color: Color(red: 0.78, green: 0.60, blue: 0.25), imageURL: unsplash("1506905925346-21bda4d32df4"), // Moraine Lake, Banff shape: .star, rotation: 4 ), .init( color: Color(red: 0.46, green: 0.49, blue: 0.55), imageURL: unsplash("1502082553048-f009c37129b9"), // warm portrait shape: .circle, rotation: -3 ), .init( color: Color(red: 0.72, green: 0.16, blue: 0.18), imageURL: unsplash("1488646953014-85cb44e25828"), // desert / antelope canyon warmth shape: .squircle, rotation: 7 ), .init( color: Color(red: 0.22, green: 0.45, blue: 0.32), imageURL: unsplash("1485470733090-0aae1788d5af"), // forest / nature shape: .star, rotation: -5 ), ] /// Builds an Unsplash CDN URL. Caller passes only the photo ID /// (the part after `photo-` in any unsplash.com URL). private static func unsplash(_ id: String) -> String { "https://images.unsplash.com/photo-\(id)?w=600&h=600&fit=crop&auto=format&q=70" } // MARK: Theme /// Page background. Pure black matches the source design. static let backgroundColor: Color = .black /// Body letter color. static let bodyTextColor: Color = .white.opacity(0.88) /// Signature row color. static let signatureColor: Color = .white.opacity(0.7) /// CTA fill. Solid dark gray (not a Material), slightly lighter than /// the screen background so the button reads as a distinct surface. /// Color(white: 0.12) lines up with the iOS system grey tones used /// in the source design. static let ctaBackground: Color = Color(white: 0.12) /// CTA hairline border. A barely-there light stroke sits just inside /// the fill in the source design. Reads as a 1pt edge highlight, /// not a colored border. White at low opacity so it shows on top of /// the dark fill without picking up a tint. static let ctaBorderColor: Color = .white.opacity(0.12) /// CTA label color. static let ctaTextColor: Color = .white // MARK: Layout (points) /// Width and height of each square tile, before it gets clipped to a /// shape. ~160pt matches the source design where the cards are large /// and feel chunky / poster-sized rather than thumbnail-sized. static let cardSize: CGFloat = 160 /// Gap between two tiles in the row. Negative on purpose so adjacent /// tiles overlap slightly, matching the source design's tight, /// layered, "stack of polaroids" feel. Z-order is left-to-right (the /// rightmost tile sits on top of its left neighbour). Tune by hand /// if you change `cardSize` — pick a value that keeps roughly the /// same overlap ratio. static let cardSpacing: CGFloat = -30 /// Corner radius of the colored card frame (the squircle that holds /// each photo). The card itself is always a squircle in the source /// design; only the inner viewfinder shape varies per card. static let cardCornerRadius: CGFloat = 32 /// Fraction of the card size the inner photo viewfinder occupies. /// 0.78 leaves a noticeable colored ring of card around the photo /// on every side, matching the source design's generous internal /// padding (photos sit comfortably inside their colored frames, /// not flush to the edge). static let viewfinderScale: CGFloat = 0.78 /// Height of the gallery strip area. A bit taller than `cardSize` so /// the tilted tiles and their shadows are not clipped at top or bottom. static let galleryHeight: CGFloat = 220 /// Breathing room above the gallery, on top of the safe area inset. /// 4pt matches the source design where the cards sit right under the /// dynamic island with minimal gap. static let galleryTopPadding: CGFloat = 4 /// Side padding around the scrolling letter. static let bodyHorizontalPadding: CGFloat = 28 /// Letter body font size, in points. 20pt is noticeably bigger than /// the default `.body` text style (~17pt) and matches the source /// design, which uses an oversized serif italic to give the letter /// the weight of a real handwritten note. Critical on larger devices /// (Pro Max, iPad) where 17pt body text reads as cramped. static let letterFontSize: CGFloat = 20 /// Vertical gap between paragraphs in the letter. Scales with /// `letterFontSize` so the rhythm stays consistent if you bump the /// font: ~1.1x font size gives a generous, letter-like spacing. static let letterParagraphSpacing: CGFloat = 22 /// Extra space between lines *inside* a paragraph, on top of the /// font's natural line height. 5pt at 20pt body keeps long /// paragraphs airy without looking double-spaced. static let letterLineSpacing: CGFloat = 5 /// Signature row font size. Slightly smaller than the body so the /// author credit reads as a postscript, not a continuation of the /// letter. static let signatureFontSize: CGFloat = 17 /// Signature avatar diameter. Tuned to match the signature font size /// so the avatar feels paired with the name rather than dwarfed by /// or dominating it. static let signatureAvatarSize: CGFloat = 30 /// Side padding around the bottom button. Larger value = narrower button. /// The source design has the button take up roughly 70% of the /// screen width, with a chunky ~45pt margin on each side that makes /// the CTA feel anchored rather than spanning edge to edge. static let ctaHorizontalPadding: CGFloat = 45 /// Gap between the button and the bottom safe area. Matches the /// source where the button sits a comfortable distance above the /// home indicator, not glued to the bottom. static let ctaBottomPadding: CGFloat = 18 /// Corner radius of the CTA. ~22pt matches the source design — /// generously rounded but not a pill (a pill would use radius = /// height/2; the source clearly has flat-ish top and bottom edges /// with corner curvature in between). static let ctaCornerRadius: CGFloat = 22 /// CTA internal vertical padding around the label. Source button is /// noticeably chunky — ~18pt vertical padding around 17pt body text /// gives a ~53pt total button height which matches. static let ctaVerticalPadding: CGFloat = 18 /// Width of the hairline border drawn just inside the CTA's rounded /// rect. 1pt is enough to read as a defined edge against the dark /// fill without looking like a real bordered control. static let ctaBorderWidth: CGFloat = 1 // MARK: Motion /// Marquee drift speed, in points per second to the left. static let marqueeSpeed: CGFloat = 38 /// Duration of the letter's single block fade-in. The whole letter /// (heading + paragraphs + signature) appears together with one /// opacity curve, matching the source design where the body copy /// is treated as one piece rather than revealed paragraph by /// paragraph. Slow on purpose — gives the gallery a beat alone /// before the eye moves down to the text. static let letterFadeDuration: Double = 1.2 /// Top breathing room inside the scrolling letter. Keeps the /// heading from sitting flush against the gallery's bottom edge /// when scrolled to the very top. static let letterTopPadding: CGFloat = 36 /// Bottom breathing room inside the scrolling letter. Keeps the /// signature from sitting flush against the CTA when scrolled all /// the way down, and gives the bottom edge fade room to feather /// without dimming the last line of text. static let letterBottomPadding: CGFloat = 48 /// Delay before the letter starts revealing. Lets the gallery enter alone first. static let letterAppearDelay: Duration = .milliseconds(450) /// Delay before the CTA fades and lifts in. static let ctaAppearDelay: Duration = .milliseconds(900) /// Gallery entrance: how far down (negative pulls it up off the top) /// the gallery starts before sliding into place. Matches the video's /// "cards drop in from the top" opening. static let galleryEntranceOffset: CGFloat = -120 } // MARK: - CardSpec /// One tile in the marquee. The UUID lets `ForEach` keep its identity when /// the same card appears multiple times (the marquee tiles the cards list /// three times in a row to fake an infinite strip). struct CardSpec: Identifiable, Hashable { let id = UUID() /// Tint color used as the colored card frame around the viewfinder. let color: Color /// Full image URL. Defaults in Config point at Unsplash CDN. let imageURL: String /// Fixed viewfinder shape for this photo. Does not animate; each photo /// keeps the shape it was framed with, matching the source design. var shape: Archetype = .squircle /// Small per-card rotation in degrees. Gives the strip a scattered /// polaroid feel instead of every tile sitting perfectly upright. var rotation: Double = 0 } // MARK: - Root view /// Bloom Camera welcome screen. Auto-running gallery on top, single-block /// fade-in letter below, delayed CTA pinned to the bottom. struct BloomCameraWelcomeSnippet: View { @State private var showGallery = false @State private var startLetter = false @State private var showCTA = false var body: some View { // Background under everything, extending past the safe areas so the // status bar and home indicator regions are also painted. Content // sits in a ZStack above it and naturally stays inside the safe area. ZStack(alignment: .top) { Config.backgroundColor .ignoresSafeArea() VStack(spacing: 0) { MarqueeGallery() .frame(height: Config.galleryHeight) .padding(.top, Config.galleryTopPadding) .offset(y: showGallery ? 0 : Config.galleryEntranceOffset) .opacity(showGallery ? 1 : 0) .animation(.spring(duration: 0.7, bounce: 0.25), value: showGallery) // `containerRelativeFrame` gives the letter an explicit // width tied to the enclosing container so inner Text wraps. // Vertical breathing room lives inside the FadeInLetter // itself (`Config.letterTopPadding` / `letterBottomPadding`) // so the scrollable content can extend right to the // letter container's edges and the padding scrolls with // it instead of being a hard outer gap. FadeInLetter(start: startLetter) .containerRelativeFrame(.horizontal) { width, _ in width - Config.bodyHorizontalPadding * 2 } .frame(maxHeight: .infinity, alignment: .top) } } // Pin the CTA in a safe-area-aware region so the layout above leaves // room and the button never overlaps the home indicator. .safeAreaInset(edge: .bottom) { CTAButton(title: Config.ctaTitle) { // TODO: wire this up to real navigation } .padding(.horizontal, Config.ctaHorizontalPadding) .padding(.bottom, Config.ctaBottomPadding) .opacity(showCTA ? 1 : 0) .offset(y: showCTA ? 0 : 24) .animation(.easeOut(duration: 0.55), value: showCTA) } .preferredColorScheme(.dark) .task { // Gallery first, then letter starts revealing, then CTA. Matches // the source design where the photo strip lands a beat before // the text begins to appear. withAnimation { showGallery = true } try? await Task.sleep(for: Config.letterAppearDelay) startLetter = true try? await Task.sleep(for: Config.ctaAppearDelay - Config.letterAppearDelay) showCTA = true } } } // MARK: - Marquee gallery /// Row of tiles that drifts left forever. Renders 3 copies of the cards /// list side by side and wraps the HStack's x offset with `truncatingRemainder`, /// so there is always a copy on screen no matter where the offset lands. private struct MarqueeGallery: View { /// Locked at view creation so motion starts at zero on appear, not at /// some random phase of the global clock. @State private var startDate = Date() /// Width of one full copy of the cards plus their spacing. Used as the /// wrap modulus for the scroll offset. private var oneSetWidth: CGFloat { CGFloat(Config.cards.count) * (Config.cardSize + Config.cardSpacing) } var body: some View { TimelineView(.animation) { context in let t = context.date.timeIntervalSince(startDate) let offset = (-CGFloat(t) * Config.marqueeSpeed) .truncatingRemainder(dividingBy: oneSetWidth) HStack(spacing: Config.cardSpacing) { ForEach(0..<3, id: \.self) { _ in ForEach(Config.cards) { card in Tile(card: card) } } } .offset(x: offset) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) } // Fill the full gallery frame (not just the cards' natural 120pt // height) so that rotated card corners and drop shadows have room // and don't get clipped at the top of the row. .frame(maxWidth: .infinity, maxHeight: .infinity) // No horizontal edge fade. The source design lets the leftmost // tile slide off the screen with a hard edge, not a soft fade. // `.clipped()` keeps the offscreen tiles from being visible past // the gallery's measured frame. .clipped() } } // MARK: - Tile /// A single tile. Two layers, both **static**: /// 1. The **card**, a constant rounded square (squircle) filled with the /// card's tint color. /// 2. The **viewfinder**, the photo masked to the card's `Archetype` /// shape. The shape is fixed per card and does not animate; each /// photo keeps the shape it was framed with. /// /// This matches the source design's "place your camera inside a shape" /// concept: the card is the frame, the shape is the viewfinder, and each /// photo lives in exactly one viewfinder shape forever. private struct Tile: View { let card: CardSpec var body: some View { let viewfinderSize = Config.cardSize * Config.viewfinderScale ZStack { // Constant card frame. Solid tint color, always a squircle in // the source design regardless of the inner viewfinder shape. RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill(card.color) // Photo viewfinder. Image loads once and is masked to the // card's fixed shape. No animation. AsyncImage(url: URL(string: card.imageURL)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() .transition(.opacity.animation(.easeOut(duration: 0.4))) case .failure: Image(systemName: "photo") .font(.system(size: viewfinderSize * 0.35)) .foregroundStyle(.white.opacity(0.4)) case .empty: Color.clear @unknown default: Color.clear } } .frame(width: viewfinderSize, height: viewfinderSize) .clipShape(ViewfinderShape(archetype: card.shape)) } .frame(width: Config.cardSize, height: Config.cardSize) // Clip the whole tile to the card squircle so the AsyncImage cannot // bleed past the card's edges during `scaledToFill`. .clipShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) .shadow(color: .black.opacity(0.45), radius: 10, x: 0, y: 6) // Per-card rotation gives the strip a scattered polaroid feel, // matching the source design where no two tiles sit at the same angle. .rotationEffect(.degrees(card.rotation)) } } // MARK: - Viewfinder shape /// A static custom `Shape` for one `Archetype`. Samples `N` points around /// the chosen archetype's perimeter and builds a closed path. Does not /// animate; the shape per card is fixed at view-creation time. struct ViewfinderShape: Shape { let archetype: Archetype /// Points sampled around the shape. 160 reads as a smooth curve at any tile size. private static let sampleCount = 160 func path(in rect: CGRect) -> Path { let center = CGPoint(x: rect.midX, y: rect.midY) let radius = min(rect.width, rect.height) / 2 var path = Path() for i in 0..<Self.sampleCount { let t = Double(i) / Double(Self.sampleCount) let p = archetype.point(at: t) let point = CGPoint(x: center.x + p.x * radius, y: center.y + p.y * radius) i == 0 ? path.move(to: point) : path.addLine(to: point) } path.closeSubpath() return path } } // MARK: - Archetype /// One closed shape recipe. Each case returns points in unit coordinates: /// (0, 0) center, x and y in [-1, +1], y positive going down. `t` in [0, 1) /// traces the perimeter clockwise starting from the top. /// /// Three cases ship: the ones the source design actually uses. To add a /// new shape, add a case and return its points the same way (sampled /// around the unit circle); then reference it from `Config.cards`. enum Archetype { case squircle, circle, star /// Returns the point on this shape at parameter `t` in [0, 1). func point(at t: Double) -> CGPoint { let angle = t * 2 * .pi - .pi / 2 let cosA = cos(angle) let sinA = sin(angle) switch self { case .circle: return CGPoint(x: cosA, y: sinA) case .squircle: // App icon shape: a superellipse with exponent 4 — corners // rounder than a rounded rectangle, edges flatter than a // circle. Sign tricks keep the absolute powers in the right // quadrant so the closed perimeter wraps cleanly. let n = 4.0 let sx = cosA >= 0 ? 1.0 : -1.0 let sy = sinA >= 0 ? 1.0 : -1.0 let x = sx * pow(abs(cosA), 2 / n) let y = sy * pow(abs(sinA), 2 / n) return CGPoint(x: x, y: y) case .star: // Puffy scalloped cloud-flower with 8 small lobes around the // perimeter. In the source design this is what reads as the // "star" — not a sharp geometric star but a soft, almost // doodled shape with many shallow bumps, like a cartoon // cloud or a flower in plan view. // // r(θ) = (1 - depth) + depth * (1 + cos(lobes * (θ - top))) / 2 // // `cos(...)` swings smoothly between -1 (valley) and +1 // (lobe peak), so the radius oscillates between // `1 - depth` and `1`. More lobes means smaller, finer bumps; // 8 reads as a fluffy cloud edge. `depth` controls how // pronounced each bump is — too shallow looks like a circle, // too deep looks like a sharp gear. The `(θ - top)` offset // aligns one lobe with the top of the shape; without it the // lobes land at arbitrary angles and the shape reads as an // irregular blob. let lobes = 8.0 let depth = 0.18 let topAngle = -Double.pi / 2 let r = (1 - depth) + depth * 0.5 * (1 + cos(lobes * (angle - topAngle))) return CGPoint(x: r * cosA, y: r * sinA) } } } // MARK: - Fade-in letter /// Italic letter rendered as one block. The entire letter (all paragraphs /// plus the signature) fades in once when `start` flips to true. After /// that the content is just a normal scrollable column — the user can /// drag to scroll if the letter is longer than the visible area. /// /// Anchored at the top (heading appears right below the gallery), with /// a subtle bottom-only edge fade so any text that runs past the visible /// bottom softens against the CTA area instead of hard-clipping. There /// is no top fade; the heading reads cleanly from the moment it appears. private struct FadeInLetter: View { /// Flips to true from the parent once the gallery has settled. /// Triggers the single block fade-in. let start: Bool /// Letter split on blank lines, one element per paragraph. private var paragraphs: [String] { Config.letter.components(separatedBy: "\n\n") } var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: Config.letterParagraphSpacing) { ForEach(Array(paragraphs.enumerated()), id: \.offset) { _, paragraph in Text(paragraph) .font(.system(size: Config.letterFontSize, design: .serif).italic()) .foregroundStyle(Config.bodyTextColor) .lineSpacing(Config.letterLineSpacing) .frame(maxWidth: .infinity, alignment: .leading) } signature .padding(.top, 8) } // Top + bottom breathing room inside the scrolling content. // Top keeps the heading from sitting flush with the gallery // edge when scrolled to the top; bottom keeps the signature // off the CTA when scrolled all the way down and gives the // bottom edge fade clearance from the last line. .padding(.top, Config.letterTopPadding) .padding(.bottom, Config.letterBottomPadding) } // Subtle bottom-only mask: keeps the text crisp through the // whole window, only softens the bottom ~12% so anything // scrolling toward the CTA area feathers out instead of being // hard-clipped. No top mask — the heading reads cleanly. .mask(bottomFade) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) // The single block fade-in. Triggers once on `start`. .opacity(start ? 1 : 0) .animation(.easeOut(duration: Config.letterFadeDuration), value: start) } /// One-sided gradient: opaque from top down to ~88%, then fades to /// clear at the bottom. Used as a mask so scrolling text feathers /// out cleanly into the CTA area. private var bottomFade: some View { LinearGradient( stops: [ .init(color: .black, location: 0.0), .init(color: .black, location: 0.88), .init(color: .clear, location: 1.0), ], startPoint: .top, endPoint: .bottom ) } /// Small author credit row. Avatar is a real photo from the Unsplash /// CDN, masked to a circle. A tinted circle sits behind it as a /// placeholder so the row never appears empty during the network /// round-trip or on failure. private var signature: some View { HStack(spacing: 10) { ZStack { Circle() .fill(LinearGradient( colors: [.gray.opacity(0.55), .gray.opacity(0.25)], startPoint: .topLeading, endPoint: .bottomTrailing )) AsyncImage(url: URL(string: Config.signatureAvatarURL)) { image in image .resizable() .scaledToFill() } placeholder: { Color.clear } } .frame(width: Config.signatureAvatarSize, height: Config.signatureAvatarSize) .clipShape(Circle()) Text(Config.signatureName) .font(.system(size: Config.signatureFontSize, design: .serif).italic()) .foregroundStyle(Config.signatureColor) } .padding(.top, 4) } } // MARK: - CTA button /// Dark rounded rectangle with centered text and a hairline border just /// inside the fill. Matches the source design's CTA, which sits with /// visible horizontal margins (not a full-width pill) and reads as a /// single defined surface thanks to the subtle 1pt edge highlight. private struct CTAButton: View { let title: String let action: () -> Void var body: some View { Button(action: action) { Text(title) .font(.system(.body, weight: .regular)) .foregroundStyle(Config.ctaTextColor) .frame(maxWidth: .infinity) .padding(.vertical, Config.ctaVerticalPadding) .background( // Dark fill + hairline border, layered as one shape so // they share the same continuous corner. `strokeBorder` // (not `stroke`) draws the line *inside* the shape, so // adding the border does not visually grow the button. RoundedRectangle(cornerRadius: Config.ctaCornerRadius, style: .continuous) .fill(Config.ctaBackground) .overlay { RoundedRectangle(cornerRadius: Config.ctaCornerRadius, style: .continuous) .strokeBorder(Config.ctaBorderColor, lineWidth: Config.ctaBorderWidth) } ) } .buttonStyle(.plain) } } #Preview { BloomCameraWelcomeSnippet() }
import SwiftUI // BloomCameraWelcomeSnippet // // A self-running onboarding screen with three timed entrances and one // continuous motion layer. // 1. Gallery (top): a row of photo tiles drops in from above, then // drifts right to left forever. Each tile has a fixed viewfinder // shape (squircle, circle, or scalloped star). Shapes do not animate. // 2. Letter (middle): a single block of italic copy fades in once, // after a short beat. The user can drag to scroll if it overflows. // 3. CTA (bottom): a dark rounded button fades and lifts in last. // // The tiles load real photos from Unsplash via AsyncImage, masked through // the viewfinder shape. Edit Config.cards to swap them out. // // One file, no external dependencies. Drop it into any iOS 26+ app or // Swift Playground and it runs. Network access is required for the // gallery and signature avatar to fetch their photos. // MARK: - Config /// Everything a copy-paster might want to tweak. Edit values here. The /// rest of the file reads from Config so views and math do not need touching. private enum Config { // MARK: Copy /// Onboarding letter. Paragraphs are separated by blank lines. Add as /// many as you like; the loop length self-tunes to content height. static let letter = """ Welcome to Bloom Camera! A small experiment in slower photography. Here, the frame comes first. Think about composition, shape, and color before you take the picture, not after. Place your viewfinder inside a circle, a heart, a window, and let the picture come to you. Move freely. Let the world rearrange itself inside the shape until something interesting lands. No filters to chase, no presets to undo. Just you, a frame, and a moment that is already there. No accounts. No tracking. Your photos stay on your device. """ /// Author name shown at the end of the letter. Swap for your own. static let signatureName = "Anonymous" /// Small avatar photo next to the signature name. Real image, masked /// to a circle. Swap the photo ID for your own portrait. static let signatureAvatarURL = unsplash("1535713875002-d1d0cf377fde") /// Text on the bottom button. static let ctaTitle = "Get started" // MARK: Marquee cards /// Photos that drift across the top. Each card carries: /// - `color`: tint of the colored card frame (placeholder + fallback) /// - `imageURL`: Unsplash CDN URL of the photo /// - `shape`: the fixed viewfinder shape this photo sits inside. /// **Does not animate.** Each photo keeps its shape forever, the /// way each photo in the source design was framed with one shape. /// - `rotation`: small per-card rotation so cards look scattered /// like polaroids /// /// To use your own assets, replace `imageURL` with a bundle Image name /// and swap `AsyncImage` for `Image(...)` in Tile. static let cards: [CardSpec] = [ .init( color: Color(red: 0.55, green: 0.74, blue: 0.92), imageURL: unsplash("1486325212027-8081e485255e"), // St Paul's Cathedral shape: .squircle, rotation: -6 ), .init( color: Color(red: 0.78, green: 0.60, blue: 0.25), imageURL: unsplash("1506905925346-21bda4d32df4"), // Moraine Lake, Banff shape: .star, rotation: 4 ), .init( color: Color(red: 0.46, green: 0.49, blue: 0.55), imageURL: unsplash("1502082553048-f009c37129b9"), // warm portrait shape: .circle, rotation: -3 ), .init( color: Color(red: 0.72, green: 0.16, blue: 0.18), imageURL: unsplash("1488646953014-85cb44e25828"), // desert / antelope canyon warmth shape: .squircle, rotation: 7 ), .init( color: Color(red: 0.22, green: 0.45, blue: 0.32), imageURL: unsplash("1485470733090-0aae1788d5af"), // forest / nature shape: .star, rotation: -5 ), ] /// Builds an Unsplash CDN URL. Caller passes only the photo ID /// (the part after `photo-` in any unsplash.com URL). private static func unsplash(_ id: String) -> String { "https://images.unsplash.com/photo-\(id)?w=600&h=600&fit=crop&auto=format&q=70" } // MARK: Theme /// Page background. Pure black matches the source design. static let backgroundColor: Color = .black /// Body letter color. static let bodyTextColor: Color = .white.opacity(0.88) /// Signature row color. static let signatureColor: Color = .white.opacity(0.7) /// CTA fill. Solid dark gray (not a Material), slightly lighter than /// the screen background so the button reads as a distinct surface. /// Color(white: 0.12) lines up with the iOS system grey tones used /// in the source design. static let ctaBackground: Color = Color(white: 0.12) /// CTA hairline border. A barely-there light stroke sits just inside /// the fill in the source design. Reads as a 1pt edge highlight, /// not a colored border. White at low opacity so it shows on top of /// the dark fill without picking up a tint. static let ctaBorderColor: Color = .white.opacity(0.12) /// CTA label color. static let ctaTextColor: Color = .white // MARK: Layout (points) /// Width and height of each square tile, before it gets clipped to a /// shape. ~160pt matches the source design where the cards are large /// and feel chunky / poster-sized rather than thumbnail-sized. static let cardSize: CGFloat = 160 /// Gap between two tiles in the row. Negative on purpose so adjacent /// tiles overlap slightly, matching the source design's tight, /// layered, "stack of polaroids" feel. Z-order is left-to-right (the /// rightmost tile sits on top of its left neighbour). Tune by hand /// if you change `cardSize` — pick a value that keeps roughly the /// same overlap ratio. static let cardSpacing: CGFloat = -30 /// Corner radius of the colored card frame (the squircle that holds /// each photo). The card itself is always a squircle in the source /// design; only the inner viewfinder shape varies per card. static let cardCornerRadius: CGFloat = 32 /// Fraction of the card size the inner photo viewfinder occupies. /// 0.78 leaves a noticeable colored ring of card around the photo /// on every side, matching the source design's generous internal /// padding (photos sit comfortably inside their colored frames, /// not flush to the edge). static let viewfinderScale: CGFloat = 0.78 /// Height of the gallery strip area. A bit taller than `cardSize` so /// the tilted tiles and their shadows are not clipped at top or bottom. static let galleryHeight: CGFloat = 220 /// Breathing room above the gallery, on top of the safe area inset. /// 4pt matches the source design where the cards sit right under the /// dynamic island with minimal gap. static let galleryTopPadding: CGFloat = 4 /// Side padding around the scrolling letter. static let bodyHorizontalPadding: CGFloat = 28 /// Letter body font size, in points. 20pt is noticeably bigger than /// the default `.body` text style (~17pt) and matches the source /// design, which uses an oversized serif italic to give the letter /// the weight of a real handwritten note. Critical on larger devices /// (Pro Max, iPad) where 17pt body text reads as cramped. static let letterFontSize: CGFloat = 20 /// Vertical gap between paragraphs in the letter. Scales with /// `letterFontSize` so the rhythm stays consistent if you bump the /// font: ~1.1x font size gives a generous, letter-like spacing. static let letterParagraphSpacing: CGFloat = 22 /// Extra space between lines *inside* a paragraph, on top of the /// font's natural line height. 5pt at 20pt body keeps long /// paragraphs airy without looking double-spaced. static let letterLineSpacing: CGFloat = 5 /// Signature row font size. Slightly smaller than the body so the /// author credit reads as a postscript, not a continuation of the /// letter. static let signatureFontSize: CGFloat = 17 /// Signature avatar diameter. Tuned to match the signature font size /// so the avatar feels paired with the name rather than dwarfed by /// or dominating it. static let signatureAvatarSize: CGFloat = 30 /// Side padding around the bottom button. Larger value = narrower button. /// The source design has the button take up roughly 70% of the /// screen width, with a chunky ~45pt margin on each side that makes /// the CTA feel anchored rather than spanning edge to edge. static let ctaHorizontalPadding: CGFloat = 45 /// Gap between the button and the bottom safe area. Matches the /// source where the button sits a comfortable distance above the /// home indicator, not glued to the bottom. static let ctaBottomPadding: CGFloat = 18 /// Corner radius of the CTA. ~22pt matches the source design — /// generously rounded but not a pill (a pill would use radius = /// height/2; the source clearly has flat-ish top and bottom edges /// with corner curvature in between). static let ctaCornerRadius: CGFloat = 22 /// CTA internal vertical padding around the label. Source button is /// noticeably chunky — ~18pt vertical padding around 17pt body text /// gives a ~53pt total button height which matches. static let ctaVerticalPadding: CGFloat = 18 /// Width of the hairline border drawn just inside the CTA's rounded /// rect. 1pt is enough to read as a defined edge against the dark /// fill without looking like a real bordered control. static let ctaBorderWidth: CGFloat = 1 // MARK: Motion /// Marquee drift speed, in points per second to the left. static let marqueeSpeed: CGFloat = 38 /// Duration of the letter's single block fade-in. The whole letter /// (heading + paragraphs + signature) appears together with one /// opacity curve, matching the source design where the body copy /// is treated as one piece rather than revealed paragraph by /// paragraph. Slow on purpose — gives the gallery a beat alone /// before the eye moves down to the text. static let letterFadeDuration: Double = 1.2 /// Top breathing room inside the scrolling letter. Keeps the /// heading from sitting flush against the gallery's bottom edge /// when scrolled to the very top. static let letterTopPadding: CGFloat = 36 /// Bottom breathing room inside the scrolling letter. Keeps the /// signature from sitting flush against the CTA when scrolled all /// the way down, and gives the bottom edge fade room to feather /// without dimming the last line of text. static let letterBottomPadding: CGFloat = 48 /// Delay before the letter starts revealing. Lets the gallery enter alone first. static let letterAppearDelay: Duration = .milliseconds(450) /// Delay before the CTA fades and lifts in. static let ctaAppearDelay: Duration = .milliseconds(900) /// Gallery entrance: how far down (negative pulls it up off the top) /// the gallery starts before sliding into place. Matches the video's /// "cards drop in from the top" opening. static let galleryEntranceOffset: CGFloat = -120 } // MARK: - CardSpec /// One tile in the marquee. The UUID lets `ForEach` keep its identity when /// the same card appears multiple times (the marquee tiles the cards list /// three times in a row to fake an infinite strip). struct CardSpec: Identifiable, Hashable { let id = UUID() /// Tint color used as the colored card frame around the viewfinder. let color: Color /// Full image URL. Defaults in Config point at Unsplash CDN. let imageURL: String /// Fixed viewfinder shape for this photo. Does not animate; each photo /// keeps the shape it was framed with, matching the source design. var shape: Archetype = .squircle /// Small per-card rotation in degrees. Gives the strip a scattered /// polaroid feel instead of every tile sitting perfectly upright. var rotation: Double = 0 } // MARK: - Root view /// Bloom Camera welcome screen. Auto-running gallery on top, single-block /// fade-in letter below, delayed CTA pinned to the bottom. struct BloomCameraWelcomeSnippet: View { @State private var showGallery = false @State private var startLetter = false @State private var showCTA = false var body: some View { // Background under everything, extending past the safe areas so the // status bar and home indicator regions are also painted. Content // sits in a ZStack above it and naturally stays inside the safe area. ZStack(alignment: .top) { Config.backgroundColor .ignoresSafeArea() VStack(spacing: 0) { MarqueeGallery() .frame(height: Config.galleryHeight) .padding(.top, Config.galleryTopPadding) .offset(y: showGallery ? 0 : Config.galleryEntranceOffset) .opacity(showGallery ? 1 : 0) .animation(.spring(duration: 0.7, bounce: 0.25), value: showGallery) // `containerRelativeFrame` gives the letter an explicit // width tied to the enclosing container so inner Text wraps. // Vertical breathing room lives inside the FadeInLetter // itself (`Config.letterTopPadding` / `letterBottomPadding`) // so the scrollable content can extend right to the // letter container's edges and the padding scrolls with // it instead of being a hard outer gap. FadeInLetter(start: startLetter) .containerRelativeFrame(.horizontal) { width, _ in width - Config.bodyHorizontalPadding * 2 } .frame(maxHeight: .infinity, alignment: .top) } } // Pin the CTA in a safe-area-aware region so the layout above leaves // room and the button never overlaps the home indicator. .safeAreaInset(edge: .bottom) { CTAButton(title: Config.ctaTitle) { // TODO: wire this up to real navigation } .padding(.horizontal, Config.ctaHorizontalPadding) .padding(.bottom, Config.ctaBottomPadding) .opacity(showCTA ? 1 : 0) .offset(y: showCTA ? 0 : 24) .animation(.easeOut(duration: 0.55), value: showCTA) } .preferredColorScheme(.dark) .task { // Gallery first, then letter starts revealing, then CTA. Matches // the source design where the photo strip lands a beat before // the text begins to appear. withAnimation { showGallery = true } try? await Task.sleep(for: Config.letterAppearDelay) startLetter = true try? await Task.sleep(for: Config.ctaAppearDelay - Config.letterAppearDelay) showCTA = true } } } // MARK: - Marquee gallery /// Row of tiles that drifts left forever. Renders 3 copies of the cards /// list side by side and wraps the HStack's x offset with `truncatingRemainder`, /// so there is always a copy on screen no matter where the offset lands. private struct MarqueeGallery: View { /// Locked at view creation so motion starts at zero on appear, not at /// some random phase of the global clock. @State private var startDate = Date() /// Width of one full copy of the cards plus their spacing. Used as the /// wrap modulus for the scroll offset. private var oneSetWidth: CGFloat { CGFloat(Config.cards.count) * (Config.cardSize + Config.cardSpacing) } var body: some View { TimelineView(.animation) { context in let t = context.date.timeIntervalSince(startDate) let offset = (-CGFloat(t) * Config.marqueeSpeed) .truncatingRemainder(dividingBy: oneSetWidth) HStack(spacing: Config.cardSpacing) { ForEach(0..<3, id: \.self) { _ in ForEach(Config.cards) { card in Tile(card: card) } } } .offset(x: offset) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) } // Fill the full gallery frame (not just the cards' natural 120pt // height) so that rotated card corners and drop shadows have room // and don't get clipped at the top of the row. .frame(maxWidth: .infinity, maxHeight: .infinity) // No horizontal edge fade. The source design lets the leftmost // tile slide off the screen with a hard edge, not a soft fade. // `.clipped()` keeps the offscreen tiles from being visible past // the gallery's measured frame. .clipped() } } // MARK: - Tile /// A single tile. Two layers, both **static**: /// 1. The **card**, a constant rounded square (squircle) filled with the /// card's tint color. /// 2. The **viewfinder**, the photo masked to the card's `Archetype` /// shape. The shape is fixed per card and does not animate; each /// photo keeps the shape it was framed with. /// /// This matches the source design's "place your camera inside a shape" /// concept: the card is the frame, the shape is the viewfinder, and each /// photo lives in exactly one viewfinder shape forever. private struct Tile: View { let card: CardSpec var body: some View { let viewfinderSize = Config.cardSize * Config.viewfinderScale ZStack { // Constant card frame. Solid tint color, always a squircle in // the source design regardless of the inner viewfinder shape. RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous) .fill(card.color) // Photo viewfinder. Image loads once and is masked to the // card's fixed shape. No animation. AsyncImage(url: URL(string: card.imageURL)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() .transition(.opacity.animation(.easeOut(duration: 0.4))) case .failure: Image(systemName: "photo") .font(.system(size: viewfinderSize * 0.35)) .foregroundStyle(.white.opacity(0.4)) case .empty: Color.clear @unknown default: Color.clear } } .frame(width: viewfinderSize, height: viewfinderSize) .clipShape(ViewfinderShape(archetype: card.shape)) } .frame(width: Config.cardSize, height: Config.cardSize) // Clip the whole tile to the card squircle so the AsyncImage cannot // bleed past the card's edges during `scaledToFill`. .clipShape(RoundedRectangle(cornerRadius: Config.cardCornerRadius, style: .continuous)) .shadow(color: .black.opacity(0.45), radius: 10, x: 0, y: 6) // Per-card rotation gives the strip a scattered polaroid feel, // matching the source design where no two tiles sit at the same angle. .rotationEffect(.degrees(card.rotation)) } } // MARK: - Viewfinder shape /// A static custom `Shape` for one `Archetype`. Samples `N` points around /// the chosen archetype's perimeter and builds a closed path. Does not /// animate; the shape per card is fixed at view-creation time. struct ViewfinderShape: Shape { let archetype: Archetype /// Points sampled around the shape. 160 reads as a smooth curve at any tile size. private static let sampleCount = 160 func path(in rect: CGRect) -> Path { let center = CGPoint(x: rect.midX, y: rect.midY) let radius = min(rect.width, rect.height) / 2 var path = Path() for i in 0..<Self.sampleCount { let t = Double(i) / Double(Self.sampleCount) let p = archetype.point(at: t) let point = CGPoint(x: center.x + p.x * radius, y: center.y + p.y * radius) i == 0 ? path.move(to: point) : path.addLine(to: point) } path.closeSubpath() return path } } // MARK: - Archetype /// One closed shape recipe. Each case returns points in unit coordinates: /// (0, 0) center, x and y in [-1, +1], y positive going down. `t` in [0, 1) /// traces the perimeter clockwise starting from the top. /// /// Three cases ship: the ones the source design actually uses. To add a /// new shape, add a case and return its points the same way (sampled /// around the unit circle); then reference it from `Config.cards`. enum Archetype { case squircle, circle, star /// Returns the point on this shape at parameter `t` in [0, 1). func point(at t: Double) -> CGPoint { let angle = t * 2 * .pi - .pi / 2 let cosA = cos(angle) let sinA = sin(angle) switch self { case .circle: return CGPoint(x: cosA, y: sinA) case .squircle: // App icon shape: a superellipse with exponent 4 — corners // rounder than a rounded rectangle, edges flatter than a // circle. Sign tricks keep the absolute powers in the right // quadrant so the closed perimeter wraps cleanly. let n = 4.0 let sx = cosA >= 0 ? 1.0 : -1.0 let sy = sinA >= 0 ? 1.0 : -1.0 let x = sx * pow(abs(cosA), 2 / n) let y = sy * pow(abs(sinA), 2 / n) return CGPoint(x: x, y: y) case .star: // Puffy scalloped cloud-flower with 8 small lobes around the // perimeter. In the source design this is what reads as the // "star" — not a sharp geometric star but a soft, almost // doodled shape with many shallow bumps, like a cartoon // cloud or a flower in plan view. // // r(θ) = (1 - depth) + depth * (1 + cos(lobes * (θ - top))) / 2 // // `cos(...)` swings smoothly between -1 (valley) and +1 // (lobe peak), so the radius oscillates between // `1 - depth` and `1`. More lobes means smaller, finer bumps; // 8 reads as a fluffy cloud edge. `depth` controls how // pronounced each bump is — too shallow looks like a circle, // too deep looks like a sharp gear. The `(θ - top)` offset // aligns one lobe with the top of the shape; without it the // lobes land at arbitrary angles and the shape reads as an // irregular blob. let lobes = 8.0 let depth = 0.18 let topAngle = -Double.pi / 2 let r = (1 - depth) + depth * 0.5 * (1 + cos(lobes * (angle - topAngle))) return CGPoint(x: r * cosA, y: r * sinA) } } } // MARK: - Fade-in letter /// Italic letter rendered as one block. The entire letter (all paragraphs /// plus the signature) fades in once when `start` flips to true. After /// that the content is just a normal scrollable column — the user can /// drag to scroll if the letter is longer than the visible area. /// /// Anchored at the top (heading appears right below the gallery), with /// a subtle bottom-only edge fade so any text that runs past the visible /// bottom softens against the CTA area instead of hard-clipping. There /// is no top fade; the heading reads cleanly from the moment it appears. private struct FadeInLetter: View { /// Flips to true from the parent once the gallery has settled. /// Triggers the single block fade-in. let start: Bool /// Letter split on blank lines, one element per paragraph. private var paragraphs: [String] { Config.letter.components(separatedBy: "\n\n") } var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: Config.letterParagraphSpacing) { ForEach(Array(paragraphs.enumerated()), id: \.offset) { _, paragraph in Text(paragraph) .font(.system(size: Config.letterFontSize, design: .serif).italic()) .foregroundStyle(Config.bodyTextColor) .lineSpacing(Config.letterLineSpacing) .frame(maxWidth: .infinity, alignment: .leading) } signature .padding(.top, 8) } // Top + bottom breathing room inside the scrolling content. // Top keeps the heading from sitting flush with the gallery // edge when scrolled to the top; bottom keeps the signature // off the CTA when scrolled all the way down and gives the // bottom edge fade clearance from the last line. .padding(.top, Config.letterTopPadding) .padding(.bottom, Config.letterBottomPadding) } // Subtle bottom-only mask: keeps the text crisp through the // whole window, only softens the bottom ~12% so anything // scrolling toward the CTA area feathers out instead of being // hard-clipped. No top mask — the heading reads cleanly. .mask(bottomFade) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) // The single block fade-in. Triggers once on `start`. .opacity(start ? 1 : 0) .animation(.easeOut(duration: Config.letterFadeDuration), value: start) } /// One-sided gradient: opaque from top down to ~88%, then fades to /// clear at the bottom. Used as a mask so scrolling text feathers /// out cleanly into the CTA area. private var bottomFade: some View { LinearGradient( stops: [ .init(color: .black, location: 0.0), .init(color: .black, location: 0.88), .init(color: .clear, location: 1.0), ], startPoint: .top, endPoint: .bottom ) } /// Small author credit row. Avatar is a real photo from the Unsplash /// CDN, masked to a circle. A tinted circle sits behind it as a /// placeholder so the row never appears empty during the network /// round-trip or on failure. private var signature: some View { HStack(spacing: 10) { ZStack { Circle() .fill(LinearGradient( colors: [.gray.opacity(0.55), .gray.opacity(0.25)], startPoint: .topLeading, endPoint: .bottomTrailing )) AsyncImage(url: URL(string: Config.signatureAvatarURL)) { image in image .resizable() .scaledToFill() } placeholder: { Color.clear } } .frame(width: Config.signatureAvatarSize, height: Config.signatureAvatarSize) .clipShape(Circle()) Text(Config.signatureName) .font(.system(size: Config.signatureFontSize, design: .serif).italic()) .foregroundStyle(Config.signatureColor) } .padding(.top, 4) } } // MARK: - CTA button /// Dark rounded rectangle with centered text and a hairline border just /// inside the fill. Matches the source design's CTA, which sits with /// visible horizontal margins (not a full-width pill) and reads as a /// single defined surface thanks to the subtle 1pt edge highlight. private struct CTAButton: View { let title: String let action: () -> Void var body: some View { Button(action: action) { Text(title) .font(.system(.body, weight: .regular)) .foregroundStyle(Config.ctaTextColor) .frame(maxWidth: .infinity) .padding(.vertical, Config.ctaVerticalPadding) .background( // Dark fill + hairline border, layered as one shape so // they share the same continuous corner. `strokeBorder` // (not `stroke`) draws the line *inside* the shape, so // adding the border does not visually grow the button. RoundedRectangle(cornerRadius: Config.ctaCornerRadius, style: .continuous) .fill(Config.ctaBackground) .overlay { RoundedRectangle(cornerRadius: Config.ctaCornerRadius, style: .continuous) .strokeBorder(Config.ctaBorderColor, lineWidth: Config.ctaBorderWidth) } ) } .buttonStyle(.plain) } } #Preview { BloomCameraWelcomeSnippet() }
Shot
Snippet
iOS 17+ • Synced tabs • Swipe paging
Zara Menu Swipe Interaction
A SwiftUI recreation of Zara’s editorial category menu, with swipeable pages, synced top tabs, an active dot, and spring motion.
SwiftUI
import SwiftUI struct ZaraEditorialMenuSwipeSnippet: View { @State private var currentIndex: Int = 0 var body: some View { ZStack(alignment: .top) { Config.backgroundColor .ignoresSafeArea() VStack(spacing: 0) { TabHeaderStrip( tabs: Config.tabs, currentIndex: $currentIndex ) TabView(selection: $currentIndex) { ForEach(Array(Config.tabs.enumerated()), id: \.offset) { index, tab in PageBody(spec: tab) .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never)) } } } }
import SwiftUI struct ZaraEditorialMenuSwipeSnippet: View { @State private var currentIndex: Int = 0 var body: some View { ZStack(alignment: .top) { Config.backgroundColor .ignoresSafeArea() VStack(spacing: 0) { TabHeaderStrip( tabs: Config.tabs, currentIndex: $currentIndex ) TabView(selection: $currentIndex) { ForEach(Array(Config.tabs.enumerated()), id: \.offset) { index, tab in PageBody(spec: tab) .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never)) } } } }
import SwiftUI struct ZaraEditorialMenuSwipeSnippet: View { @State private var currentIndex: Int = 0 var body: some View { ZStack(alignment: .top) { Config.backgroundColor .ignoresSafeArea() VStack(spacing: 0) { TabHeaderStrip( tabs: Config.tabs, currentIndex: $currentIndex ) TabView(selection: $currentIndex) { ForEach(Array(Config.tabs.enumerated()), id: \.offset) { index, tab in PageBody(spec: tab) .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never)) } } } }
import SwiftUI struct ZaraEditorialMenuSwipeSnippet: View { @State private var currentIndex: Int = 0 var body: some View { ZStack(alignment: .top) { Config.backgroundColor .ignoresSafeArea() VStack(spacing: 0) { TabHeaderStrip( tabs: Config.tabs, currentIndex: $currentIndex ) TabView(selection: $currentIndex) { ForEach(Array(Config.tabs.enumerated()), id: \.offset) { index, tab in PageBody(spec: tab) .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never)) } } } }
import SwiftUI // ZaraEditorialMenuSwipeSnippet // // A horizontal paginated menu in an editorial / high-end-fashion style. // Each page is its own category (WOMAN, MAN, KIDS, PERFUMES, TRAVEL MODE). // // 1. Tab strip (top): a row of large serif tab labels. Each title // takes its intrinsic text width with a uniform gap between // neighbours — short tabs like "MAN" stay narrow, long ones // like "PERFUMES" take more room. The active tab slides to a // fixed left inset with a small dot under it; the strip clamps // at its ends like a scroll view, so the last tabs stop at the // right edge instead of leaving blank space. The slide uses the // same spring as the page swipe so the two motions read locked. // 2. Body (below): a SwiftUI `TabView` with `.page` style. One // page per tab. Each page has its own layout: a numbered menu, // sometimes a hero image strip, sometimes a wide hero card, // sometimes nothing but a couple of links. The user drives // navigation by swiping horizontally; the header strip follows // via the shared selection binding. // // All photos load from Unsplash via AsyncImage. Edit `Config.tabs` to // swap them, or rewrite the page content blocks below to fit your // own categories. // // One file, no external dependencies. Drop into any iOS 26+ app or // Swift Playground. Network is required for the hero photos. // MARK: - Config /// All values a copy-paster might want to tweak. The implementation /// below reads everything from here, so renaming a tab, swapping a /// photo, or retuning the swipe feel never needs touching the views. private enum Config { // MARK: Copy /// Tab strip across the top, left to right. Each tab is a separate /// page in the horizontal pager. The user swipes the body or drags /// the strip / taps a tab to move between them. static let tabs: [PageSpec] = [ .init( title: "WOMAN", kind: .numberedWithHeroCard( sections: [ .init(number: "|05|", label: "PERFUMES", items: ["PERFUMES"]), .init(number: "|06|", label: "SPECIAL EDITION", items: []), .init(number: "|07|", label: "", items: ["SALE", "VIEW ALL"]), ], hero: .init( imageURL: unsplash("1483729558449-99ef09a8c325"), caption: "Brazil", body: "Some places ask to be planned. Rio asks to be lived." ), footer: ["TRAVEL MODE", "GIFT CARD", "STORES", "JOIN LIFE", "CAREERS"] ) ), .init( title: "MAN", kind: .stripWithNumbered( strip: [ .init(imageURL: unsplash("1488161628813-04466f872be2"), caption: "SPECIAL OCCASION"), .init(imageURL: unsplash("1542327897-d73f4005b533"), caption: "THE NEW"), .init(imageURL: unsplash("1517649763962-0c623066013b"), caption: "ATHLETICZ"), .init(imageURL: unsplash("1499529112087-3cb3b73cec95"), caption: "LINEN"), ], sections: [ .init(number: "|01|", label: "NEW IN", items: ["THE NEW", "SPRING EDIT^HOLI", "WAYS TO WEAR", "SPECIAL OCCASION^NEW"]), .init(number: "|02|", label: "COLLECTION", items: ["VIEW ALL", "BEST SELLERS", "JACKETS | GILETS", "SHIRTS", "LINEN", "T-SHIRTS"]), ] ) ), .init( // The category landings (GIRL / BOY + age range) match the // source design verbatim. The imagery uses editorial, // model-released children's-fashion photos from Unsplash as // stand-ins for the brand's own catalogue shots. title: "KIDS", kind: .verticalCards(cards: [ .init(imageURL: unsplash("1476234251651-f353703a034d"), caption: "GIRL", subtitle: "6 - 14 YEARS"), .init(imageURL: unsplash("1503919545889-aef636e10ad4"), caption: "BOY", subtitle: "6 - 14 YEARS"), .init(imageURL: unsplash("1518831959646-742c3a14ebf7"), caption: "GIRL", subtitle: "1½ - 6 YEARS"), .init(imageURL: unsplash("1471286174890-9c112ffca5b4"), caption: "BOY", subtitle: "1½ - 6 YEARS"), ]) ), .init( title: "PERFUMES", kind: .wideHeroWithNumbered( wideImageURL: unsplash("1485968579580-b6d095142e6e"), sections: [ .init(number: "|01|", label: "WOMAN", items: ["PERFUMES", "SALE"]), .init(number: "|02|", label: "MAN", items: ["PERFUMES"]), .init(number: "|03|", label: "KIDS", items: ["PERFUMES"]), ], hero: .init( imageURL: unsplash("1483729558449-99ef09a8c325"), caption: "Brazil", body: "Some places ask to be planned. Rio asks to be lived." ), footer: ["TRAVEL MODE"] ) ), .init( // The tab reads "TRAVEL MODE"; its page is a terse pair of // links (ABOUT / THE GUIDES), matching the source design. title: "TRAVEL MODE", kind: .plainList(items: ["ABOUT", "THE GUIDES"]) ), ] // MARK: Theme /// Page background. The source design is on pure-white paper. static let backgroundColor: Color = Color(white: 0.99) /// Primary ink color (tab text, menu numbers, body labels). static let inkColor: Color = .black /// Muted body text (sub-labels under menu numbers, footer links). static let mutedInkColor: Color = Color(white: 0.20) /// Accent used for the "highlighted" sub-items (the source shows /// occasional purple and yellow labels). Two-color accent kept here /// so a copy-paster can rebrand without hunting through the views. static let accentHighlight: Color = Color(red: 0.55, green: 0.2, blue: 0.85) static let accentBadge: Color = Color(red: 0.85, green: 0.65, blue: 0.10) /// Background of the small text card sitting next to a hero photo /// (the "Brazil / Coast" panel). Cream tone, not pure white, so it /// reads as a distinct surface against the page. static let heroCardBackground: Color = Color(red: 0.93, green: 0.92, blue: 0.88) // MARK: Layout (points) /// Side padding on every page's content column. static let pageHorizontalPadding: CGFloat = 32 /// Vertical gap between the tab strip and the dot indicator. static let tabToDotSpacing: CGFloat = 4 /// Tab strip font size. Big serif caps so the strip reads as the /// main page title, not as a small tab control. static let tabFontSize: CGFloat = 30 /// Horizontal gap between two adjacent tab titles. The titles /// themselves take their intrinsic text width, so a short tab /// like "MAN" occupies less room than "PERFUMES". This matches /// the source design where the strip reads as type set /// editorially, not as a tab control with uniform slot widths. static let tabSpacing: CGFloat = 32 /// Leading/trailing inset on the scrollable tab strip. The active /// tab scrolls to sit this far from the screen's leading edge, /// and the first/last tab keep this margin from the edges. /// Mirrors `pageHorizontalPadding` so the active tab's left edge /// stacks vertically with the page body's left margin below. static let tabLeadingInset: CGFloat = 32 /// Breathing room above the tab strip. The reference design has /// noticeable air between the status bar and the first tab /// title; 36pt feels editorial without pushing content too far /// down the page. static let tabStripTopPadding: CGFloat = 36 /// Breathing room below the dot indicator (between the dot row /// and the page body). The source design leaves a clear gap /// here, so the body never crowds the tab strip. static let tabStripBottomPadding: CGFloat = 30 /// Size of the active-tab indicator dot. static let activeDotSize: CGFloat = 4 /// Vertical spacing between numbered menu rows on a page. static let menuRowSpacing: CGFloat = 14 /// Vertical spacing between two numbered sections (|01|, |02|, ...). static let menuSectionSpacing: CGFloat = 18 /// Width allotted to the |0N| LABEL column. Keeps the sub-items /// aligned in a consistent vertical line down the page even when /// the section label is long. static let menuLabelColumnWidth: CGFloat = 170 /// Body font size for the numbered menu labels and items. static let menuFontSize: CGFloat = 14 /// Height of the horizontal photo strip on MAN-style pages. static let photoStripHeight: CGFloat = 180 /// Per-photo caption font size in the MAN strip and KIDS list. static let captionFontSize: CGFloat = 12 /// Height of the wide hero photo on the PERFUMES-style page. static let wideHeroHeight: CGFloat = 220 /// Hero card (image + text panel) overall height. static let heroCardHeight: CGFloat = 200 /// Width of the text panel on the right side of the hero card, /// as a fraction of the card's total width. ~30% gives the panel /// enough room for a short paragraph without dwarfing the photo. static let heroCardTextFraction: CGFloat = 0.32 /// Vertical thumbnail size in the KIDS-style vertical list. static let verticalCardImageSize: CGFloat = 130 /// Footer link row spacing on WOMAN-style pages. static let footerRowSpacing: CGFloat = 12 // MARK: Motion /// Spring used by the tab strip when it slides to follow the /// active page. Tuned to feel like the same gesture as the /// TabView's own page swipe, so the header reads as locked to /// the body even though SwiftUI animates them independently. static let pageSpring: Animation = .spring(duration: 0.7, bounce: 0.15) // MARK: Helpers /// Builds an Unsplash CDN URL. Caller passes only the photo ID /// (the part after `photo-` in any unsplash.com URL). `crop` /// optionally biases the auto-crop toward a side (`top`, `bottom`, /// `entropy`, etc.) when the image is being squeezed into a /// shorter frame than its native aspect. fileprivate static func unsplash(_ id: String, crop: String = "entropy") -> String { "https://images.unsplash.com/photo-\(id)?w=900&fit=crop&crop=\(crop)&auto=format&q=70" } } // MARK: - Page spec types /// One tab / page in the pager. `title` is what shows in the tab strip; /// `kind` defines what layout the body uses. struct PageSpec: Identifiable, Hashable { let id = UUID() let title: String let kind: PageKind } /// Each page in the source design follows one of a handful of layout /// recipes. Encoding them as enum cases (rather than per-tab View /// types) keeps the page-driving code small and lets the snippet /// declare its tabs as pure data inside `Config`. enum PageKind: Hashable { /// Numbered menu + a side-by-side hero card + a footer link list. /// Used by the WOMAN-style landing tab. case numberedWithHeroCard( sections: [MenuSection], hero: HeroCard, footer: [String] ) /// A horizontal photo strip at the top, then numbered menu sections. /// Used by the MAN-style tabs that lead with imagery. case stripWithNumbered( strip: [StripPhoto], sections: [MenuSection] ) /// A vertical list of cards, each with a thumbnail + caption + subtitle. /// Used by the KIDS-style tab where the page is a list of category /// landings (GIRL / BOY plus an age range). case verticalCards(cards: [VerticalCard]) /// One wide hero photo, then numbered menu sections, then a small /// hero card and a footer line. Used by the PERFUMES tab. case wideHeroWithNumbered( wideImageURL: String, sections: [MenuSection], hero: HeroCard, footer: [String] ) /// Bare list of label links, no numbers or imagery. Used by the /// terse ABOUT tab. case plainList(items: [String]) } /// One numbered section on a menu page (`|01| LABEL` plus sub-items). struct MenuSection: Hashable { /// The bracketed number like "|01|". Stored as a string so the /// brackets are part of the data, not assembled at render time. let number: String /// Section label shown to the right of the number, e.g. "NEW IN". let label: String /// Sub-items rendered as a vertical list inside the section. let items: [String] } /// One photo in a horizontal strip with a label underneath. struct StripPhoto: Hashable { let imageURL: String let caption: String } /// One card in the KIDS-style vertical list: thumbnail on the left, /// caption + subtitle stacked on the right. struct VerticalCard: Hashable { let imageURL: String let caption: String let subtitle: String } /// Wide photo + small cream text panel on its right, with a short /// poetic line of body copy. Appears on the WOMAN and PERFUMES tabs /// in the source design. struct HeroCard: Hashable { let imageURL: String let caption: String let body: String } // MARK: - Root view /// Editorial menu pager. Horizontally paginated tabs with a synced /// header strip on top. The user navigates three ways: swipe the body, /// drag the scrollable header strip, or tap a tab. All three share the /// same `currentIndex`, so the strip and the visible page stay in sync. struct ZaraEditorialMenuSwipeSnippet: View { /// The current page's index, shared by the `TabView` and the /// header strip so a swipe, a strip tap, or a programmatic scroll /// all read and write the same source of truth. @State private var currentIndex: Int = 0 var body: some View { // White paper background under everything. Sits inside a ZStack // so safe-area-extending bg stays separate from content layout. ZStack(alignment: .top) { Config.backgroundColor.ignoresSafeArea() VStack(spacing: 0) { TabHeaderStrip( tabs: Config.tabs, currentIndex: $currentIndex ) .padding(.top, Config.tabStripTopPadding) .padding(.bottom, Config.tabStripBottomPadding) // TabView in `.page` style gives us swipeable horizontal // paging for free, with proper paging gestures and // animated programmatic page changes via the binding. // Custom ScrollView + scrollPosition setups end up // fighting layout (LazyHStack widths, alignment in // nested ScrollViews); TabView avoids all of that. TabView(selection: $currentIndex) { ForEach(Array(Config.tabs.enumerated()), id: \.offset) { index, tab in PageBody(spec: tab) .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never)) } } .preferredColorScheme(.light) } } // MARK: - Tab header strip /// The big serif tab titles across the top, plus a small dot under /// the active one. The whole strip is a real horizontal `ScrollView`, /// so the user can drag it on its own to peek at other tabs. It also /// stays in sync with the pager three ways: /// - Swiping the body changes `currentIndex`; an `onChange` scrolls /// the active tab to the leading inset. /// - Tapping a tab sets `currentIndex`, which pages the body and /// scrolls the strip. /// - The dot lives inside the scroll content under each tab and is /// only shown for the active one, so it tracks position for free /// whether the strip is driven or hand-dragged. /// `contentMargins` gives the row a leading/trailing inset, and the /// ScrollView clamps at its ends, so the last tabs stop at the right /// edge instead of dragging blank space onto the screen. private struct TabHeaderStrip: View { let tabs: [PageSpec] @Binding var currentIndex: Int var body: some View { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: Config.tabSpacing) { ForEach(Array(tabs.enumerated()), id: \.offset) { index, tab in tabLabel(index: index, title: tab.title) .id(index) } } } // Symmetric inset so the first/last tab keep a margin from // the screen edges, and the active tab lands at this inset // when scrolled into view. .contentMargins(.horizontal, Config.tabLeadingInset, for: .scrollContent) .onChange(of: currentIndex) { _, newValue in withAnimation(Config.pageSpring) { proxy.scrollTo(newValue, anchor: .leading) } } .onAppear { proxy.scrollTo(currentIndex, anchor: .leading) } } // Fixed height (title + gap + dot) so the strip sizes tightly // inside the root VStack instead of stretching. .frame(height: Config.tabFontSize * 1.2 + Config.tabToDotSpacing + Config.activeDotSize) } /// One tab: the serif title with the active dot directly beneath /// it. Tapping anywhere on the column selects that page. private func tabLabel(index: Int, title: String) -> some View { VStack(spacing: Config.tabToDotSpacing) { Text(title) .font(.system(size: Config.tabFontSize, weight: .regular, design: .serif)) .foregroundStyle(Config.inkColor) .lineLimit(1) .fixedSize() Circle() .fill(Config.inkColor) .frame(width: Config.activeDotSize, height: Config.activeDotSize) .opacity(index == currentIndex ? 1 : 0) } .contentShape(Rectangle()) .onTapGesture { withAnimation(Config.pageSpring) { currentIndex = index } } } } // MARK: - Page body /// Dispatches a `PageSpec` to the right layout based on its `kind`. /// Each layout is its own private view below; keeping this dispatcher /// thin makes adding a new `PageKind` case a one-place change. private struct PageBody: View { let spec: PageSpec var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { switch spec.kind { case let .numberedWithHeroCard(sections, hero, footer): NumberedSectionsView(sections: sections) Spacer().frame(height: 28) HeroCardView(card: hero) Spacer().frame(height: 18) FooterLinksView(items: footer) case let .stripWithNumbered(strip, sections): Spacer().frame(height: 6) PhotoStripView(photos: strip) Spacer().frame(height: 22) NumberedSectionsView(sections: sections) case let .verticalCards(cards): Spacer().frame(height: 6) VerticalCardsView(cards: cards) case let .wideHeroWithNumbered(wide, sections, hero, footer): Spacer().frame(height: 6) WideHeroView(imageURL: wide) Spacer().frame(height: 24) NumberedSectionsView(sections: sections) Spacer().frame(height: 28) HeroCardView(card: hero) Spacer().frame(height: 18) FooterLinksView(items: footer) case let .plainList(items): PlainListView(items: items) } } // Claim the full page width so .leading alignment inside // the VStack anchors content to the left of the page, // not the center. Without this, the VStack hugs the // longest row's natural width and the vertical ScrollView // centers the (narrow) column in its viewport. .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 24) } // Side padding lives *outside* the ScrollView so it constrains // the ScrollView's width directly. Putting padding inside the // ScrollView's VStack lets the ScrollView take full width and // the padding only applies to the inner content, which makes // photo strips and other `maxWidth: .infinity` rows bleed // past the intended page margin. TabView page-style children // also strip padding applied to them directly (the bridged // UIPageViewController sizes its child to the full page). .padding(.horizontal, Config.pageHorizontalPadding) } } // MARK: - Page layout: numbered sections /// Renders a vertical list of `|0N| LABEL` rows, each with optional /// sub-items right below the label. The number sits in a fixed-width /// column on the left so sub-items down the page all line up. private struct NumberedSectionsView: View { let sections: [MenuSection] var body: some View { VStack(alignment: .leading, spacing: Config.menuSectionSpacing) { ForEach(Array(sections.enumerated()), id: \.offset) { _, section in HStack(alignment: .top, spacing: 0) { // |0N| LABEL column HStack(spacing: 12) { Text(section.number) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) Text(section.label) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) } .frame(width: Config.menuLabelColumnWidth, alignment: .leading) // Sub-items column VStack(alignment: .leading, spacing: Config.menuRowSpacing) { ForEach(Array(section.items.enumerated()), id: \.offset) { _, item in menuItem(item) } } } } } .padding(.top, 4) } /// One sub-item line. Two source-design touches are encoded in the /// flat item string so the data stays plain text: /// - A caret splits off a small raised badge, e.g. /// "SPRING EDIT^HOLI" renders "SPRING EDIT" with a superscript /// "HOLI", matching the little editorial tags in the video. /// - Some labels are tinted (purple for editorial pushes, gold /// for "NEW" badges), detected by keyword in `tint(for:)`. @ViewBuilder private func menuItem(_ raw: String) -> some View { let parts = raw.split(separator: "^", maxSplits: 1).map(String.init) let label = parts.first ?? raw let badge = parts.count > 1 ? parts[1] : nil let tinted = tint(for: label) HStack(alignment: .top, spacing: 3) { Text(label) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(tinted ?? Config.inkColor) .tracking(0.5) if let badge { Text(badge) .font(.system(size: Config.menuFontSize * 0.6, weight: .regular, design: .default)) .foregroundStyle(tinted ?? Config.mutedInkColor) .baselineOffset(Config.menuFontSize * 0.45) } } } /// Per-keyword accent picker. Returns nil for the common case so /// the caller can fall back to the default ink color. private func tint(for label: String) -> Color? { if label.contains("SPRING EDIT") { return Config.accentHighlight } if label.contains("SPECIAL OCCASION") { return Config.accentBadge } return nil } } // MARK: - Page layout: hero card /// Wide photo on the left, cream text panel on the right with a short /// caption + body line. The photo and the panel share a single /// horizontal frame so they always line up edge to edge. private struct HeroCardView: View { let card: HeroCard var body: some View { // Measure the row's own width so the cream text panel can // claim a precise fraction of it (~32%). Using // `containerRelativeFrame` here would size against the whole // TabView, not this row, and the panel would end up wrong. GeometryReader { proxy in let panelWidth = proxy.size.width * Config.heroCardTextFraction HStack(spacing: 0) { AsyncImage(url: URL(string: card.imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .font(.system(size: 32)) .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(width: proxy.size.width - panelWidth, height: Config.heroCardHeight) .clipped() VStack(alignment: .leading, spacing: 8) { Text(card.caption) .font(.system(size: 16, weight: .bold, design: .default)) .foregroundStyle(Config.inkColor) Text(card.body) .font(.system(size: 11, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) .lineSpacing(2) Spacer(minLength: 0) } .padding(12) .frame(width: panelWidth, height: Config.heroCardHeight, alignment: .topLeading) .background(Config.heroCardBackground) } } .frame(height: Config.heroCardHeight) } } // MARK: - Page layout: footer links /// Vertical list of small uppercase labels with generous row spacing. /// Used as the WOMAN tab's tail (TRAVEL MODE / GIFT CARD / etc.) and /// shortened for the PERFUMES tab. private struct FooterLinksView: View { let items: [String] var body: some View { VStack(alignment: .leading, spacing: Config.footerRowSpacing) { ForEach(Array(items.enumerated()), id: \.offset) { _, item in Text(item) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) .tracking(0.5) } } .padding(.top, 18) } } // MARK: - Page layout: horizontal photo strip /// Row of equal-width photos with small captions underneath. Spans /// the page horizontally with a tight inter-photo gap. private struct PhotoStripView: View { let photos: [StripPhoto] /// Gap between two photos in the strip. private let photoSpacing: CGFloat = 8 var body: some View { // Measure the row's own width and compute a fixed per-photo // width from it. With `.frame(maxWidth: .infinity)` on every // photo, the HStack ended up wider than the page (TabView's // bridged page view doesn't propagate finite width proposals // through `.frame(maxWidth: .infinity)` children the way a // normal layout does), and the strip bled off both screen // edges. Reading our own width and computing exact pixel // widths is the dependable fix. GeometryReader { proxy in let totalSpacing = photoSpacing * CGFloat(photos.count - 1) let perPhotoWidth = max( (proxy.size.width - totalSpacing) / CGFloat(photos.count), 10 ) HStack(spacing: photoSpacing) { ForEach(Array(photos.enumerated()), id: \.offset) { _, photo in VStack(alignment: .leading, spacing: 8) { AsyncImage(url: URL(string: photo.imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(width: perPhotoWidth, height: Config.photoStripHeight) .clipped() Text(photo.caption) .font(.system(size: Config.captionFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) .frame(width: perPhotoWidth, alignment: .leading) } } } } .frame(height: Config.photoStripHeight + 24) } } // MARK: - Page layout: vertical cards /// Stack of rows, each row a square-ish thumbnail on the left with /// caption + subtitle on the right. The KIDS tab uses this for its /// GIRL / BOY age-bracket landings. private struct VerticalCardsView: View { let cards: [VerticalCard] var body: some View { VStack(spacing: 18) { ForEach(Array(cards.enumerated()), id: \.offset) { _, card in HStack(spacing: 18) { AsyncImage(url: URL(string: card.imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(width: Config.verticalCardImageSize, height: Config.verticalCardImageSize) .clipped() VStack(alignment: .leading, spacing: 6) { Text(card.caption) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) Text(card.subtitle) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) .tracking(0.5) } Spacer(minLength: 0) } } } } } // MARK: - Page layout: wide hero /// Single wide photo filling the page width. The PERFUMES tab opens /// with one of these as a category hero. private struct WideHeroView: View { let imageURL: String var body: some View { AsyncImage(url: URL(string: imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(maxWidth: .infinity) .frame(height: Config.wideHeroHeight) .clipped() } } // MARK: - Page layout: plain list /// Two tiny links and nothing else. The ABOUT tab is just this. private struct PlainListView: View { let items: [String] var body: some View { VStack(alignment: .leading, spacing: Config.menuRowSpacing) { ForEach(Array(items.enumerated()), id: \.offset) { _, item in Text(item) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) } } .padding(.top, 4) } } #Preview { ZaraEditorialMenuSwipeSnippet() }
import SwiftUI // ZaraEditorialMenuSwipeSnippet // // A horizontal paginated menu in an editorial / high-end-fashion style. // Each page is its own category (WOMAN, MAN, KIDS, PERFUMES, TRAVEL MODE). // // 1. Tab strip (top): a row of large serif tab labels. Each title // takes its intrinsic text width with a uniform gap between // neighbours — short tabs like "MAN" stay narrow, long ones // like "PERFUMES" take more room. The active tab slides to a // fixed left inset with a small dot under it; the strip clamps // at its ends like a scroll view, so the last tabs stop at the // right edge instead of leaving blank space. The slide uses the // same spring as the page swipe so the two motions read locked. // 2. Body (below): a SwiftUI `TabView` with `.page` style. One // page per tab. Each page has its own layout: a numbered menu, // sometimes a hero image strip, sometimes a wide hero card, // sometimes nothing but a couple of links. The user drives // navigation by swiping horizontally; the header strip follows // via the shared selection binding. // // All photos load from Unsplash via AsyncImage. Edit `Config.tabs` to // swap them, or rewrite the page content blocks below to fit your // own categories. // // One file, no external dependencies. Drop into any iOS 26+ app or // Swift Playground. Network is required for the hero photos. // MARK: - Config /// All values a copy-paster might want to tweak. The implementation /// below reads everything from here, so renaming a tab, swapping a /// photo, or retuning the swipe feel never needs touching the views. private enum Config { // MARK: Copy /// Tab strip across the top, left to right. Each tab is a separate /// page in the horizontal pager. The user swipes the body or drags /// the strip / taps a tab to move between them. static let tabs: [PageSpec] = [ .init( title: "WOMAN", kind: .numberedWithHeroCard( sections: [ .init(number: "|05|", label: "PERFUMES", items: ["PERFUMES"]), .init(number: "|06|", label: "SPECIAL EDITION", items: []), .init(number: "|07|", label: "", items: ["SALE", "VIEW ALL"]), ], hero: .init( imageURL: unsplash("1483729558449-99ef09a8c325"), caption: "Brazil", body: "Some places ask to be planned. Rio asks to be lived." ), footer: ["TRAVEL MODE", "GIFT CARD", "STORES", "JOIN LIFE", "CAREERS"] ) ), .init( title: "MAN", kind: .stripWithNumbered( strip: [ .init(imageURL: unsplash("1488161628813-04466f872be2"), caption: "SPECIAL OCCASION"), .init(imageURL: unsplash("1542327897-d73f4005b533"), caption: "THE NEW"), .init(imageURL: unsplash("1517649763962-0c623066013b"), caption: "ATHLETICZ"), .init(imageURL: unsplash("1499529112087-3cb3b73cec95"), caption: "LINEN"), ], sections: [ .init(number: "|01|", label: "NEW IN", items: ["THE NEW", "SPRING EDIT^HOLI", "WAYS TO WEAR", "SPECIAL OCCASION^NEW"]), .init(number: "|02|", label: "COLLECTION", items: ["VIEW ALL", "BEST SELLERS", "JACKETS | GILETS", "SHIRTS", "LINEN", "T-SHIRTS"]), ] ) ), .init( // The category landings (GIRL / BOY + age range) match the // source design verbatim. The imagery uses editorial, // model-released children's-fashion photos from Unsplash as // stand-ins for the brand's own catalogue shots. title: "KIDS", kind: .verticalCards(cards: [ .init(imageURL: unsplash("1476234251651-f353703a034d"), caption: "GIRL", subtitle: "6 - 14 YEARS"), .init(imageURL: unsplash("1503919545889-aef636e10ad4"), caption: "BOY", subtitle: "6 - 14 YEARS"), .init(imageURL: unsplash("1518831959646-742c3a14ebf7"), caption: "GIRL", subtitle: "1½ - 6 YEARS"), .init(imageURL: unsplash("1471286174890-9c112ffca5b4"), caption: "BOY", subtitle: "1½ - 6 YEARS"), ]) ), .init( title: "PERFUMES", kind: .wideHeroWithNumbered( wideImageURL: unsplash("1485968579580-b6d095142e6e"), sections: [ .init(number: "|01|", label: "WOMAN", items: ["PERFUMES", "SALE"]), .init(number: "|02|", label: "MAN", items: ["PERFUMES"]), .init(number: "|03|", label: "KIDS", items: ["PERFUMES"]), ], hero: .init( imageURL: unsplash("1483729558449-99ef09a8c325"), caption: "Brazil", body: "Some places ask to be planned. Rio asks to be lived." ), footer: ["TRAVEL MODE"] ) ), .init( // The tab reads "TRAVEL MODE"; its page is a terse pair of // links (ABOUT / THE GUIDES), matching the source design. title: "TRAVEL MODE", kind: .plainList(items: ["ABOUT", "THE GUIDES"]) ), ] // MARK: Theme /// Page background. The source design is on pure-white paper. static let backgroundColor: Color = Color(white: 0.99) /// Primary ink color (tab text, menu numbers, body labels). static let inkColor: Color = .black /// Muted body text (sub-labels under menu numbers, footer links). static let mutedInkColor: Color = Color(white: 0.20) /// Accent used for the "highlighted" sub-items (the source shows /// occasional purple and yellow labels). Two-color accent kept here /// so a copy-paster can rebrand without hunting through the views. static let accentHighlight: Color = Color(red: 0.55, green: 0.2, blue: 0.85) static let accentBadge: Color = Color(red: 0.85, green: 0.65, blue: 0.10) /// Background of the small text card sitting next to a hero photo /// (the "Brazil / Coast" panel). Cream tone, not pure white, so it /// reads as a distinct surface against the page. static let heroCardBackground: Color = Color(red: 0.93, green: 0.92, blue: 0.88) // MARK: Layout (points) /// Side padding on every page's content column. static let pageHorizontalPadding: CGFloat = 32 /// Vertical gap between the tab strip and the dot indicator. static let tabToDotSpacing: CGFloat = 4 /// Tab strip font size. Big serif caps so the strip reads as the /// main page title, not as a small tab control. static let tabFontSize: CGFloat = 30 /// Horizontal gap between two adjacent tab titles. The titles /// themselves take their intrinsic text width, so a short tab /// like "MAN" occupies less room than "PERFUMES". This matches /// the source design where the strip reads as type set /// editorially, not as a tab control with uniform slot widths. static let tabSpacing: CGFloat = 32 /// Leading/trailing inset on the scrollable tab strip. The active /// tab scrolls to sit this far from the screen's leading edge, /// and the first/last tab keep this margin from the edges. /// Mirrors `pageHorizontalPadding` so the active tab's left edge /// stacks vertically with the page body's left margin below. static let tabLeadingInset: CGFloat = 32 /// Breathing room above the tab strip. The reference design has /// noticeable air between the status bar and the first tab /// title; 36pt feels editorial without pushing content too far /// down the page. static let tabStripTopPadding: CGFloat = 36 /// Breathing room below the dot indicator (between the dot row /// and the page body). The source design leaves a clear gap /// here, so the body never crowds the tab strip. static let tabStripBottomPadding: CGFloat = 30 /// Size of the active-tab indicator dot. static let activeDotSize: CGFloat = 4 /// Vertical spacing between numbered menu rows on a page. static let menuRowSpacing: CGFloat = 14 /// Vertical spacing between two numbered sections (|01|, |02|, ...). static let menuSectionSpacing: CGFloat = 18 /// Width allotted to the |0N| LABEL column. Keeps the sub-items /// aligned in a consistent vertical line down the page even when /// the section label is long. static let menuLabelColumnWidth: CGFloat = 170 /// Body font size for the numbered menu labels and items. static let menuFontSize: CGFloat = 14 /// Height of the horizontal photo strip on MAN-style pages. static let photoStripHeight: CGFloat = 180 /// Per-photo caption font size in the MAN strip and KIDS list. static let captionFontSize: CGFloat = 12 /// Height of the wide hero photo on the PERFUMES-style page. static let wideHeroHeight: CGFloat = 220 /// Hero card (image + text panel) overall height. static let heroCardHeight: CGFloat = 200 /// Width of the text panel on the right side of the hero card, /// as a fraction of the card's total width. ~30% gives the panel /// enough room for a short paragraph without dwarfing the photo. static let heroCardTextFraction: CGFloat = 0.32 /// Vertical thumbnail size in the KIDS-style vertical list. static let verticalCardImageSize: CGFloat = 130 /// Footer link row spacing on WOMAN-style pages. static let footerRowSpacing: CGFloat = 12 // MARK: Motion /// Spring used by the tab strip when it slides to follow the /// active page. Tuned to feel like the same gesture as the /// TabView's own page swipe, so the header reads as locked to /// the body even though SwiftUI animates them independently. static let pageSpring: Animation = .spring(duration: 0.7, bounce: 0.15) // MARK: Helpers /// Builds an Unsplash CDN URL. Caller passes only the photo ID /// (the part after `photo-` in any unsplash.com URL). `crop` /// optionally biases the auto-crop toward a side (`top`, `bottom`, /// `entropy`, etc.) when the image is being squeezed into a /// shorter frame than its native aspect. fileprivate static func unsplash(_ id: String, crop: String = "entropy") -> String { "https://images.unsplash.com/photo-\(id)?w=900&fit=crop&crop=\(crop)&auto=format&q=70" } } // MARK: - Page spec types /// One tab / page in the pager. `title` is what shows in the tab strip; /// `kind` defines what layout the body uses. struct PageSpec: Identifiable, Hashable { let id = UUID() let title: String let kind: PageKind } /// Each page in the source design follows one of a handful of layout /// recipes. Encoding them as enum cases (rather than per-tab View /// types) keeps the page-driving code small and lets the snippet /// declare its tabs as pure data inside `Config`. enum PageKind: Hashable { /// Numbered menu + a side-by-side hero card + a footer link list. /// Used by the WOMAN-style landing tab. case numberedWithHeroCard( sections: [MenuSection], hero: HeroCard, footer: [String] ) /// A horizontal photo strip at the top, then numbered menu sections. /// Used by the MAN-style tabs that lead with imagery. case stripWithNumbered( strip: [StripPhoto], sections: [MenuSection] ) /// A vertical list of cards, each with a thumbnail + caption + subtitle. /// Used by the KIDS-style tab where the page is a list of category /// landings (GIRL / BOY plus an age range). case verticalCards(cards: [VerticalCard]) /// One wide hero photo, then numbered menu sections, then a small /// hero card and a footer line. Used by the PERFUMES tab. case wideHeroWithNumbered( wideImageURL: String, sections: [MenuSection], hero: HeroCard, footer: [String] ) /// Bare list of label links, no numbers or imagery. Used by the /// terse ABOUT tab. case plainList(items: [String]) } /// One numbered section on a menu page (`|01| LABEL` plus sub-items). struct MenuSection: Hashable { /// The bracketed number like "|01|". Stored as a string so the /// brackets are part of the data, not assembled at render time. let number: String /// Section label shown to the right of the number, e.g. "NEW IN". let label: String /// Sub-items rendered as a vertical list inside the section. let items: [String] } /// One photo in a horizontal strip with a label underneath. struct StripPhoto: Hashable { let imageURL: String let caption: String } /// One card in the KIDS-style vertical list: thumbnail on the left, /// caption + subtitle stacked on the right. struct VerticalCard: Hashable { let imageURL: String let caption: String let subtitle: String } /// Wide photo + small cream text panel on its right, with a short /// poetic line of body copy. Appears on the WOMAN and PERFUMES tabs /// in the source design. struct HeroCard: Hashable { let imageURL: String let caption: String let body: String } // MARK: - Root view /// Editorial menu pager. Horizontally paginated tabs with a synced /// header strip on top. The user navigates three ways: swipe the body, /// drag the scrollable header strip, or tap a tab. All three share the /// same `currentIndex`, so the strip and the visible page stay in sync. struct ZaraEditorialMenuSwipeSnippet: View { /// The current page's index, shared by the `TabView` and the /// header strip so a swipe, a strip tap, or a programmatic scroll /// all read and write the same source of truth. @State private var currentIndex: Int = 0 var body: some View { // White paper background under everything. Sits inside a ZStack // so safe-area-extending bg stays separate from content layout. ZStack(alignment: .top) { Config.backgroundColor.ignoresSafeArea() VStack(spacing: 0) { TabHeaderStrip( tabs: Config.tabs, currentIndex: $currentIndex ) .padding(.top, Config.tabStripTopPadding) .padding(.bottom, Config.tabStripBottomPadding) // TabView in `.page` style gives us swipeable horizontal // paging for free, with proper paging gestures and // animated programmatic page changes via the binding. // Custom ScrollView + scrollPosition setups end up // fighting layout (LazyHStack widths, alignment in // nested ScrollViews); TabView avoids all of that. TabView(selection: $currentIndex) { ForEach(Array(Config.tabs.enumerated()), id: \.offset) { index, tab in PageBody(spec: tab) .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never)) } } .preferredColorScheme(.light) } } // MARK: - Tab header strip /// The big serif tab titles across the top, plus a small dot under /// the active one. The whole strip is a real horizontal `ScrollView`, /// so the user can drag it on its own to peek at other tabs. It also /// stays in sync with the pager three ways: /// - Swiping the body changes `currentIndex`; an `onChange` scrolls /// the active tab to the leading inset. /// - Tapping a tab sets `currentIndex`, which pages the body and /// scrolls the strip. /// - The dot lives inside the scroll content under each tab and is /// only shown for the active one, so it tracks position for free /// whether the strip is driven or hand-dragged. /// `contentMargins` gives the row a leading/trailing inset, and the /// ScrollView clamps at its ends, so the last tabs stop at the right /// edge instead of dragging blank space onto the screen. private struct TabHeaderStrip: View { let tabs: [PageSpec] @Binding var currentIndex: Int var body: some View { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: Config.tabSpacing) { ForEach(Array(tabs.enumerated()), id: \.offset) { index, tab in tabLabel(index: index, title: tab.title) .id(index) } } } // Symmetric inset so the first/last tab keep a margin from // the screen edges, and the active tab lands at this inset // when scrolled into view. .contentMargins(.horizontal, Config.tabLeadingInset, for: .scrollContent) .onChange(of: currentIndex) { _, newValue in withAnimation(Config.pageSpring) { proxy.scrollTo(newValue, anchor: .leading) } } .onAppear { proxy.scrollTo(currentIndex, anchor: .leading) } } // Fixed height (title + gap + dot) so the strip sizes tightly // inside the root VStack instead of stretching. .frame(height: Config.tabFontSize * 1.2 + Config.tabToDotSpacing + Config.activeDotSize) } /// One tab: the serif title with the active dot directly beneath /// it. Tapping anywhere on the column selects that page. private func tabLabel(index: Int, title: String) -> some View { VStack(spacing: Config.tabToDotSpacing) { Text(title) .font(.system(size: Config.tabFontSize, weight: .regular, design: .serif)) .foregroundStyle(Config.inkColor) .lineLimit(1) .fixedSize() Circle() .fill(Config.inkColor) .frame(width: Config.activeDotSize, height: Config.activeDotSize) .opacity(index == currentIndex ? 1 : 0) } .contentShape(Rectangle()) .onTapGesture { withAnimation(Config.pageSpring) { currentIndex = index } } } } // MARK: - Page body /// Dispatches a `PageSpec` to the right layout based on its `kind`. /// Each layout is its own private view below; keeping this dispatcher /// thin makes adding a new `PageKind` case a one-place change. private struct PageBody: View { let spec: PageSpec var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { switch spec.kind { case let .numberedWithHeroCard(sections, hero, footer): NumberedSectionsView(sections: sections) Spacer().frame(height: 28) HeroCardView(card: hero) Spacer().frame(height: 18) FooterLinksView(items: footer) case let .stripWithNumbered(strip, sections): Spacer().frame(height: 6) PhotoStripView(photos: strip) Spacer().frame(height: 22) NumberedSectionsView(sections: sections) case let .verticalCards(cards): Spacer().frame(height: 6) VerticalCardsView(cards: cards) case let .wideHeroWithNumbered(wide, sections, hero, footer): Spacer().frame(height: 6) WideHeroView(imageURL: wide) Spacer().frame(height: 24) NumberedSectionsView(sections: sections) Spacer().frame(height: 28) HeroCardView(card: hero) Spacer().frame(height: 18) FooterLinksView(items: footer) case let .plainList(items): PlainListView(items: items) } } // Claim the full page width so .leading alignment inside // the VStack anchors content to the left of the page, // not the center. Without this, the VStack hugs the // longest row's natural width and the vertical ScrollView // centers the (narrow) column in its viewport. .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 24) } // Side padding lives *outside* the ScrollView so it constrains // the ScrollView's width directly. Putting padding inside the // ScrollView's VStack lets the ScrollView take full width and // the padding only applies to the inner content, which makes // photo strips and other `maxWidth: .infinity` rows bleed // past the intended page margin. TabView page-style children // also strip padding applied to them directly (the bridged // UIPageViewController sizes its child to the full page). .padding(.horizontal, Config.pageHorizontalPadding) } } // MARK: - Page layout: numbered sections /// Renders a vertical list of `|0N| LABEL` rows, each with optional /// sub-items right below the label. The number sits in a fixed-width /// column on the left so sub-items down the page all line up. private struct NumberedSectionsView: View { let sections: [MenuSection] var body: some View { VStack(alignment: .leading, spacing: Config.menuSectionSpacing) { ForEach(Array(sections.enumerated()), id: \.offset) { _, section in HStack(alignment: .top, spacing: 0) { // |0N| LABEL column HStack(spacing: 12) { Text(section.number) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) Text(section.label) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) } .frame(width: Config.menuLabelColumnWidth, alignment: .leading) // Sub-items column VStack(alignment: .leading, spacing: Config.menuRowSpacing) { ForEach(Array(section.items.enumerated()), id: \.offset) { _, item in menuItem(item) } } } } } .padding(.top, 4) } /// One sub-item line. Two source-design touches are encoded in the /// flat item string so the data stays plain text: /// - A caret splits off a small raised badge, e.g. /// "SPRING EDIT^HOLI" renders "SPRING EDIT" with a superscript /// "HOLI", matching the little editorial tags in the video. /// - Some labels are tinted (purple for editorial pushes, gold /// for "NEW" badges), detected by keyword in `tint(for:)`. @ViewBuilder private func menuItem(_ raw: String) -> some View { let parts = raw.split(separator: "^", maxSplits: 1).map(String.init) let label = parts.first ?? raw let badge = parts.count > 1 ? parts[1] : nil let tinted = tint(for: label) HStack(alignment: .top, spacing: 3) { Text(label) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(tinted ?? Config.inkColor) .tracking(0.5) if let badge { Text(badge) .font(.system(size: Config.menuFontSize * 0.6, weight: .regular, design: .default)) .foregroundStyle(tinted ?? Config.mutedInkColor) .baselineOffset(Config.menuFontSize * 0.45) } } } /// Per-keyword accent picker. Returns nil for the common case so /// the caller can fall back to the default ink color. private func tint(for label: String) -> Color? { if label.contains("SPRING EDIT") { return Config.accentHighlight } if label.contains("SPECIAL OCCASION") { return Config.accentBadge } return nil } } // MARK: - Page layout: hero card /// Wide photo on the left, cream text panel on the right with a short /// caption + body line. The photo and the panel share a single /// horizontal frame so they always line up edge to edge. private struct HeroCardView: View { let card: HeroCard var body: some View { // Measure the row's own width so the cream text panel can // claim a precise fraction of it (~32%). Using // `containerRelativeFrame` here would size against the whole // TabView, not this row, and the panel would end up wrong. GeometryReader { proxy in let panelWidth = proxy.size.width * Config.heroCardTextFraction HStack(spacing: 0) { AsyncImage(url: URL(string: card.imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .font(.system(size: 32)) .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(width: proxy.size.width - panelWidth, height: Config.heroCardHeight) .clipped() VStack(alignment: .leading, spacing: 8) { Text(card.caption) .font(.system(size: 16, weight: .bold, design: .default)) .foregroundStyle(Config.inkColor) Text(card.body) .font(.system(size: 11, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) .lineSpacing(2) Spacer(minLength: 0) } .padding(12) .frame(width: panelWidth, height: Config.heroCardHeight, alignment: .topLeading) .background(Config.heroCardBackground) } } .frame(height: Config.heroCardHeight) } } // MARK: - Page layout: footer links /// Vertical list of small uppercase labels with generous row spacing. /// Used as the WOMAN tab's tail (TRAVEL MODE / GIFT CARD / etc.) and /// shortened for the PERFUMES tab. private struct FooterLinksView: View { let items: [String] var body: some View { VStack(alignment: .leading, spacing: Config.footerRowSpacing) { ForEach(Array(items.enumerated()), id: \.offset) { _, item in Text(item) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) .tracking(0.5) } } .padding(.top, 18) } } // MARK: - Page layout: horizontal photo strip /// Row of equal-width photos with small captions underneath. Spans /// the page horizontally with a tight inter-photo gap. private struct PhotoStripView: View { let photos: [StripPhoto] /// Gap between two photos in the strip. private let photoSpacing: CGFloat = 8 var body: some View { // Measure the row's own width and compute a fixed per-photo // width from it. With `.frame(maxWidth: .infinity)` on every // photo, the HStack ended up wider than the page (TabView's // bridged page view doesn't propagate finite width proposals // through `.frame(maxWidth: .infinity)` children the way a // normal layout does), and the strip bled off both screen // edges. Reading our own width and computing exact pixel // widths is the dependable fix. GeometryReader { proxy in let totalSpacing = photoSpacing * CGFloat(photos.count - 1) let perPhotoWidth = max( (proxy.size.width - totalSpacing) / CGFloat(photos.count), 10 ) HStack(spacing: photoSpacing) { ForEach(Array(photos.enumerated()), id: \.offset) { _, photo in VStack(alignment: .leading, spacing: 8) { AsyncImage(url: URL(string: photo.imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(width: perPhotoWidth, height: Config.photoStripHeight) .clipped() Text(photo.caption) .font(.system(size: Config.captionFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) .frame(width: perPhotoWidth, alignment: .leading) } } } } .frame(height: Config.photoStripHeight + 24) } } // MARK: - Page layout: vertical cards /// Stack of rows, each row a square-ish thumbnail on the left with /// caption + subtitle on the right. The KIDS tab uses this for its /// GIRL / BOY age-bracket landings. private struct VerticalCardsView: View { let cards: [VerticalCard] var body: some View { VStack(spacing: 18) { ForEach(Array(cards.enumerated()), id: \.offset) { _, card in HStack(spacing: 18) { AsyncImage(url: URL(string: card.imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(width: Config.verticalCardImageSize, height: Config.verticalCardImageSize) .clipped() VStack(alignment: .leading, spacing: 6) { Text(card.caption) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) Text(card.subtitle) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) .tracking(0.5) } Spacer(minLength: 0) } } } } } // MARK: - Page layout: wide hero /// Single wide photo filling the page width. The PERFUMES tab opens /// with one of these as a category hero. private struct WideHeroView: View { let imageURL: String var body: some View { AsyncImage(url: URL(string: imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(maxWidth: .infinity) .frame(height: Config.wideHeroHeight) .clipped() } } // MARK: - Page layout: plain list /// Two tiny links and nothing else. The ABOUT tab is just this. private struct PlainListView: View { let items: [String] var body: some View { VStack(alignment: .leading, spacing: Config.menuRowSpacing) { ForEach(Array(items.enumerated()), id: \.offset) { _, item in Text(item) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) } } .padding(.top, 4) } } #Preview { ZaraEditorialMenuSwipeSnippet() }
import SwiftUI // ZaraEditorialMenuSwipeSnippet // // A horizontal paginated menu in an editorial / high-end-fashion style. // Each page is its own category (WOMAN, MAN, KIDS, PERFUMES, TRAVEL MODE). // // 1. Tab strip (top): a row of large serif tab labels. Each title // takes its intrinsic text width with a uniform gap between // neighbours — short tabs like "MAN" stay narrow, long ones // like "PERFUMES" take more room. The active tab slides to a // fixed left inset with a small dot under it; the strip clamps // at its ends like a scroll view, so the last tabs stop at the // right edge instead of leaving blank space. The slide uses the // same spring as the page swipe so the two motions read locked. // 2. Body (below): a SwiftUI `TabView` with `.page` style. One // page per tab. Each page has its own layout: a numbered menu, // sometimes a hero image strip, sometimes a wide hero card, // sometimes nothing but a couple of links. The user drives // navigation by swiping horizontally; the header strip follows // via the shared selection binding. // // All photos load from Unsplash via AsyncImage. Edit `Config.tabs` to // swap them, or rewrite the page content blocks below to fit your // own categories. // // One file, no external dependencies. Drop into any iOS 26+ app or // Swift Playground. Network is required for the hero photos. // MARK: - Config /// All values a copy-paster might want to tweak. The implementation /// below reads everything from here, so renaming a tab, swapping a /// photo, or retuning the swipe feel never needs touching the views. private enum Config { // MARK: Copy /// Tab strip across the top, left to right. Each tab is a separate /// page in the horizontal pager. The user swipes the body or drags /// the strip / taps a tab to move between them. static let tabs: [PageSpec] = [ .init( title: "WOMAN", kind: .numberedWithHeroCard( sections: [ .init(number: "|05|", label: "PERFUMES", items: ["PERFUMES"]), .init(number: "|06|", label: "SPECIAL EDITION", items: []), .init(number: "|07|", label: "", items: ["SALE", "VIEW ALL"]), ], hero: .init( imageURL: unsplash("1483729558449-99ef09a8c325"), caption: "Brazil", body: "Some places ask to be planned. Rio asks to be lived." ), footer: ["TRAVEL MODE", "GIFT CARD", "STORES", "JOIN LIFE", "CAREERS"] ) ), .init( title: "MAN", kind: .stripWithNumbered( strip: [ .init(imageURL: unsplash("1488161628813-04466f872be2"), caption: "SPECIAL OCCASION"), .init(imageURL: unsplash("1542327897-d73f4005b533"), caption: "THE NEW"), .init(imageURL: unsplash("1517649763962-0c623066013b"), caption: "ATHLETICZ"), .init(imageURL: unsplash("1499529112087-3cb3b73cec95"), caption: "LINEN"), ], sections: [ .init(number: "|01|", label: "NEW IN", items: ["THE NEW", "SPRING EDIT^HOLI", "WAYS TO WEAR", "SPECIAL OCCASION^NEW"]), .init(number: "|02|", label: "COLLECTION", items: ["VIEW ALL", "BEST SELLERS", "JACKETS | GILETS", "SHIRTS", "LINEN", "T-SHIRTS"]), ] ) ), .init( // The category landings (GIRL / BOY + age range) match the // source design verbatim. The imagery uses editorial, // model-released children's-fashion photos from Unsplash as // stand-ins for the brand's own catalogue shots. title: "KIDS", kind: .verticalCards(cards: [ .init(imageURL: unsplash("1476234251651-f353703a034d"), caption: "GIRL", subtitle: "6 - 14 YEARS"), .init(imageURL: unsplash("1503919545889-aef636e10ad4"), caption: "BOY", subtitle: "6 - 14 YEARS"), .init(imageURL: unsplash("1518831959646-742c3a14ebf7"), caption: "GIRL", subtitle: "1½ - 6 YEARS"), .init(imageURL: unsplash("1471286174890-9c112ffca5b4"), caption: "BOY", subtitle: "1½ - 6 YEARS"), ]) ), .init( title: "PERFUMES", kind: .wideHeroWithNumbered( wideImageURL: unsplash("1485968579580-b6d095142e6e"), sections: [ .init(number: "|01|", label: "WOMAN", items: ["PERFUMES", "SALE"]), .init(number: "|02|", label: "MAN", items: ["PERFUMES"]), .init(number: "|03|", label: "KIDS", items: ["PERFUMES"]), ], hero: .init( imageURL: unsplash("1483729558449-99ef09a8c325"), caption: "Brazil", body: "Some places ask to be planned. Rio asks to be lived." ), footer: ["TRAVEL MODE"] ) ), .init( // The tab reads "TRAVEL MODE"; its page is a terse pair of // links (ABOUT / THE GUIDES), matching the source design. title: "TRAVEL MODE", kind: .plainList(items: ["ABOUT", "THE GUIDES"]) ), ] // MARK: Theme /// Page background. The source design is on pure-white paper. static let backgroundColor: Color = Color(white: 0.99) /// Primary ink color (tab text, menu numbers, body labels). static let inkColor: Color = .black /// Muted body text (sub-labels under menu numbers, footer links). static let mutedInkColor: Color = Color(white: 0.20) /// Accent used for the "highlighted" sub-items (the source shows /// occasional purple and yellow labels). Two-color accent kept here /// so a copy-paster can rebrand without hunting through the views. static let accentHighlight: Color = Color(red: 0.55, green: 0.2, blue: 0.85) static let accentBadge: Color = Color(red: 0.85, green: 0.65, blue: 0.10) /// Background of the small text card sitting next to a hero photo /// (the "Brazil / Coast" panel). Cream tone, not pure white, so it /// reads as a distinct surface against the page. static let heroCardBackground: Color = Color(red: 0.93, green: 0.92, blue: 0.88) // MARK: Layout (points) /// Side padding on every page's content column. static let pageHorizontalPadding: CGFloat = 32 /// Vertical gap between the tab strip and the dot indicator. static let tabToDotSpacing: CGFloat = 4 /// Tab strip font size. Big serif caps so the strip reads as the /// main page title, not as a small tab control. static let tabFontSize: CGFloat = 30 /// Horizontal gap between two adjacent tab titles. The titles /// themselves take their intrinsic text width, so a short tab /// like "MAN" occupies less room than "PERFUMES". This matches /// the source design where the strip reads as type set /// editorially, not as a tab control with uniform slot widths. static let tabSpacing: CGFloat = 32 /// Leading/trailing inset on the scrollable tab strip. The active /// tab scrolls to sit this far from the screen's leading edge, /// and the first/last tab keep this margin from the edges. /// Mirrors `pageHorizontalPadding` so the active tab's left edge /// stacks vertically with the page body's left margin below. static let tabLeadingInset: CGFloat = 32 /// Breathing room above the tab strip. The reference design has /// noticeable air between the status bar and the first tab /// title; 36pt feels editorial without pushing content too far /// down the page. static let tabStripTopPadding: CGFloat = 36 /// Breathing room below the dot indicator (between the dot row /// and the page body). The source design leaves a clear gap /// here, so the body never crowds the tab strip. static let tabStripBottomPadding: CGFloat = 30 /// Size of the active-tab indicator dot. static let activeDotSize: CGFloat = 4 /// Vertical spacing between numbered menu rows on a page. static let menuRowSpacing: CGFloat = 14 /// Vertical spacing between two numbered sections (|01|, |02|, ...). static let menuSectionSpacing: CGFloat = 18 /// Width allotted to the |0N| LABEL column. Keeps the sub-items /// aligned in a consistent vertical line down the page even when /// the section label is long. static let menuLabelColumnWidth: CGFloat = 170 /// Body font size for the numbered menu labels and items. static let menuFontSize: CGFloat = 14 /// Height of the horizontal photo strip on MAN-style pages. static let photoStripHeight: CGFloat = 180 /// Per-photo caption font size in the MAN strip and KIDS list. static let captionFontSize: CGFloat = 12 /// Height of the wide hero photo on the PERFUMES-style page. static let wideHeroHeight: CGFloat = 220 /// Hero card (image + text panel) overall height. static let heroCardHeight: CGFloat = 200 /// Width of the text panel on the right side of the hero card, /// as a fraction of the card's total width. ~30% gives the panel /// enough room for a short paragraph without dwarfing the photo. static let heroCardTextFraction: CGFloat = 0.32 /// Vertical thumbnail size in the KIDS-style vertical list. static let verticalCardImageSize: CGFloat = 130 /// Footer link row spacing on WOMAN-style pages. static let footerRowSpacing: CGFloat = 12 // MARK: Motion /// Spring used by the tab strip when it slides to follow the /// active page. Tuned to feel like the same gesture as the /// TabView's own page swipe, so the header reads as locked to /// the body even though SwiftUI animates them independently. static let pageSpring: Animation = .spring(duration: 0.7, bounce: 0.15) // MARK: Helpers /// Builds an Unsplash CDN URL. Caller passes only the photo ID /// (the part after `photo-` in any unsplash.com URL). `crop` /// optionally biases the auto-crop toward a side (`top`, `bottom`, /// `entropy`, etc.) when the image is being squeezed into a /// shorter frame than its native aspect. fileprivate static func unsplash(_ id: String, crop: String = "entropy") -> String { "https://images.unsplash.com/photo-\(id)?w=900&fit=crop&crop=\(crop)&auto=format&q=70" } } // MARK: - Page spec types /// One tab / page in the pager. `title` is what shows in the tab strip; /// `kind` defines what layout the body uses. struct PageSpec: Identifiable, Hashable { let id = UUID() let title: String let kind: PageKind } /// Each page in the source design follows one of a handful of layout /// recipes. Encoding them as enum cases (rather than per-tab View /// types) keeps the page-driving code small and lets the snippet /// declare its tabs as pure data inside `Config`. enum PageKind: Hashable { /// Numbered menu + a side-by-side hero card + a footer link list. /// Used by the WOMAN-style landing tab. case numberedWithHeroCard( sections: [MenuSection], hero: HeroCard, footer: [String] ) /// A horizontal photo strip at the top, then numbered menu sections. /// Used by the MAN-style tabs that lead with imagery. case stripWithNumbered( strip: [StripPhoto], sections: [MenuSection] ) /// A vertical list of cards, each with a thumbnail + caption + subtitle. /// Used by the KIDS-style tab where the page is a list of category /// landings (GIRL / BOY plus an age range). case verticalCards(cards: [VerticalCard]) /// One wide hero photo, then numbered menu sections, then a small /// hero card and a footer line. Used by the PERFUMES tab. case wideHeroWithNumbered( wideImageURL: String, sections: [MenuSection], hero: HeroCard, footer: [String] ) /// Bare list of label links, no numbers or imagery. Used by the /// terse ABOUT tab. case plainList(items: [String]) } /// One numbered section on a menu page (`|01| LABEL` plus sub-items). struct MenuSection: Hashable { /// The bracketed number like "|01|". Stored as a string so the /// brackets are part of the data, not assembled at render time. let number: String /// Section label shown to the right of the number, e.g. "NEW IN". let label: String /// Sub-items rendered as a vertical list inside the section. let items: [String] } /// One photo in a horizontal strip with a label underneath. struct StripPhoto: Hashable { let imageURL: String let caption: String } /// One card in the KIDS-style vertical list: thumbnail on the left, /// caption + subtitle stacked on the right. struct VerticalCard: Hashable { let imageURL: String let caption: String let subtitle: String } /// Wide photo + small cream text panel on its right, with a short /// poetic line of body copy. Appears on the WOMAN and PERFUMES tabs /// in the source design. struct HeroCard: Hashable { let imageURL: String let caption: String let body: String } // MARK: - Root view /// Editorial menu pager. Horizontally paginated tabs with a synced /// header strip on top. The user navigates three ways: swipe the body, /// drag the scrollable header strip, or tap a tab. All three share the /// same `currentIndex`, so the strip and the visible page stay in sync. struct ZaraEditorialMenuSwipeSnippet: View { /// The current page's index, shared by the `TabView` and the /// header strip so a swipe, a strip tap, or a programmatic scroll /// all read and write the same source of truth. @State private var currentIndex: Int = 0 var body: some View { // White paper background under everything. Sits inside a ZStack // so safe-area-extending bg stays separate from content layout. ZStack(alignment: .top) { Config.backgroundColor.ignoresSafeArea() VStack(spacing: 0) { TabHeaderStrip( tabs: Config.tabs, currentIndex: $currentIndex ) .padding(.top, Config.tabStripTopPadding) .padding(.bottom, Config.tabStripBottomPadding) // TabView in `.page` style gives us swipeable horizontal // paging for free, with proper paging gestures and // animated programmatic page changes via the binding. // Custom ScrollView + scrollPosition setups end up // fighting layout (LazyHStack widths, alignment in // nested ScrollViews); TabView avoids all of that. TabView(selection: $currentIndex) { ForEach(Array(Config.tabs.enumerated()), id: \.offset) { index, tab in PageBody(spec: tab) .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never)) } } .preferredColorScheme(.light) } } // MARK: - Tab header strip /// The big serif tab titles across the top, plus a small dot under /// the active one. The whole strip is a real horizontal `ScrollView`, /// so the user can drag it on its own to peek at other tabs. It also /// stays in sync with the pager three ways: /// - Swiping the body changes `currentIndex`; an `onChange` scrolls /// the active tab to the leading inset. /// - Tapping a tab sets `currentIndex`, which pages the body and /// scrolls the strip. /// - The dot lives inside the scroll content under each tab and is /// only shown for the active one, so it tracks position for free /// whether the strip is driven or hand-dragged. /// `contentMargins` gives the row a leading/trailing inset, and the /// ScrollView clamps at its ends, so the last tabs stop at the right /// edge instead of dragging blank space onto the screen. private struct TabHeaderStrip: View { let tabs: [PageSpec] @Binding var currentIndex: Int var body: some View { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: Config.tabSpacing) { ForEach(Array(tabs.enumerated()), id: \.offset) { index, tab in tabLabel(index: index, title: tab.title) .id(index) } } } // Symmetric inset so the first/last tab keep a margin from // the screen edges, and the active tab lands at this inset // when scrolled into view. .contentMargins(.horizontal, Config.tabLeadingInset, for: .scrollContent) .onChange(of: currentIndex) { _, newValue in withAnimation(Config.pageSpring) { proxy.scrollTo(newValue, anchor: .leading) } } .onAppear { proxy.scrollTo(currentIndex, anchor: .leading) } } // Fixed height (title + gap + dot) so the strip sizes tightly // inside the root VStack instead of stretching. .frame(height: Config.tabFontSize * 1.2 + Config.tabToDotSpacing + Config.activeDotSize) } /// One tab: the serif title with the active dot directly beneath /// it. Tapping anywhere on the column selects that page. private func tabLabel(index: Int, title: String) -> some View { VStack(spacing: Config.tabToDotSpacing) { Text(title) .font(.system(size: Config.tabFontSize, weight: .regular, design: .serif)) .foregroundStyle(Config.inkColor) .lineLimit(1) .fixedSize() Circle() .fill(Config.inkColor) .frame(width: Config.activeDotSize, height: Config.activeDotSize) .opacity(index == currentIndex ? 1 : 0) } .contentShape(Rectangle()) .onTapGesture { withAnimation(Config.pageSpring) { currentIndex = index } } } } // MARK: - Page body /// Dispatches a `PageSpec` to the right layout based on its `kind`. /// Each layout is its own private view below; keeping this dispatcher /// thin makes adding a new `PageKind` case a one-place change. private struct PageBody: View { let spec: PageSpec var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { switch spec.kind { case let .numberedWithHeroCard(sections, hero, footer): NumberedSectionsView(sections: sections) Spacer().frame(height: 28) HeroCardView(card: hero) Spacer().frame(height: 18) FooterLinksView(items: footer) case let .stripWithNumbered(strip, sections): Spacer().frame(height: 6) PhotoStripView(photos: strip) Spacer().frame(height: 22) NumberedSectionsView(sections: sections) case let .verticalCards(cards): Spacer().frame(height: 6) VerticalCardsView(cards: cards) case let .wideHeroWithNumbered(wide, sections, hero, footer): Spacer().frame(height: 6) WideHeroView(imageURL: wide) Spacer().frame(height: 24) NumberedSectionsView(sections: sections) Spacer().frame(height: 28) HeroCardView(card: hero) Spacer().frame(height: 18) FooterLinksView(items: footer) case let .plainList(items): PlainListView(items: items) } } // Claim the full page width so .leading alignment inside // the VStack anchors content to the left of the page, // not the center. Without this, the VStack hugs the // longest row's natural width and the vertical ScrollView // centers the (narrow) column in its viewport. .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 24) } // Side padding lives *outside* the ScrollView so it constrains // the ScrollView's width directly. Putting padding inside the // ScrollView's VStack lets the ScrollView take full width and // the padding only applies to the inner content, which makes // photo strips and other `maxWidth: .infinity` rows bleed // past the intended page margin. TabView page-style children // also strip padding applied to them directly (the bridged // UIPageViewController sizes its child to the full page). .padding(.horizontal, Config.pageHorizontalPadding) } } // MARK: - Page layout: numbered sections /// Renders a vertical list of `|0N| LABEL` rows, each with optional /// sub-items right below the label. The number sits in a fixed-width /// column on the left so sub-items down the page all line up. private struct NumberedSectionsView: View { let sections: [MenuSection] var body: some View { VStack(alignment: .leading, spacing: Config.menuSectionSpacing) { ForEach(Array(sections.enumerated()), id: \.offset) { _, section in HStack(alignment: .top, spacing: 0) { // |0N| LABEL column HStack(spacing: 12) { Text(section.number) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) Text(section.label) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) } .frame(width: Config.menuLabelColumnWidth, alignment: .leading) // Sub-items column VStack(alignment: .leading, spacing: Config.menuRowSpacing) { ForEach(Array(section.items.enumerated()), id: \.offset) { _, item in menuItem(item) } } } } } .padding(.top, 4) } /// One sub-item line. Two source-design touches are encoded in the /// flat item string so the data stays plain text: /// - A caret splits off a small raised badge, e.g. /// "SPRING EDIT^HOLI" renders "SPRING EDIT" with a superscript /// "HOLI", matching the little editorial tags in the video. /// - Some labels are tinted (purple for editorial pushes, gold /// for "NEW" badges), detected by keyword in `tint(for:)`. @ViewBuilder private func menuItem(_ raw: String) -> some View { let parts = raw.split(separator: "^", maxSplits: 1).map(String.init) let label = parts.first ?? raw let badge = parts.count > 1 ? parts[1] : nil let tinted = tint(for: label) HStack(alignment: .top, spacing: 3) { Text(label) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(tinted ?? Config.inkColor) .tracking(0.5) if let badge { Text(badge) .font(.system(size: Config.menuFontSize * 0.6, weight: .regular, design: .default)) .foregroundStyle(tinted ?? Config.mutedInkColor) .baselineOffset(Config.menuFontSize * 0.45) } } } /// Per-keyword accent picker. Returns nil for the common case so /// the caller can fall back to the default ink color. private func tint(for label: String) -> Color? { if label.contains("SPRING EDIT") { return Config.accentHighlight } if label.contains("SPECIAL OCCASION") { return Config.accentBadge } return nil } } // MARK: - Page layout: hero card /// Wide photo on the left, cream text panel on the right with a short /// caption + body line. The photo and the panel share a single /// horizontal frame so they always line up edge to edge. private struct HeroCardView: View { let card: HeroCard var body: some View { // Measure the row's own width so the cream text panel can // claim a precise fraction of it (~32%). Using // `containerRelativeFrame` here would size against the whole // TabView, not this row, and the panel would end up wrong. GeometryReader { proxy in let panelWidth = proxy.size.width * Config.heroCardTextFraction HStack(spacing: 0) { AsyncImage(url: URL(string: card.imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .font(.system(size: 32)) .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(width: proxy.size.width - panelWidth, height: Config.heroCardHeight) .clipped() VStack(alignment: .leading, spacing: 8) { Text(card.caption) .font(.system(size: 16, weight: .bold, design: .default)) .foregroundStyle(Config.inkColor) Text(card.body) .font(.system(size: 11, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) .lineSpacing(2) Spacer(minLength: 0) } .padding(12) .frame(width: panelWidth, height: Config.heroCardHeight, alignment: .topLeading) .background(Config.heroCardBackground) } } .frame(height: Config.heroCardHeight) } } // MARK: - Page layout: footer links /// Vertical list of small uppercase labels with generous row spacing. /// Used as the WOMAN tab's tail (TRAVEL MODE / GIFT CARD / etc.) and /// shortened for the PERFUMES tab. private struct FooterLinksView: View { let items: [String] var body: some View { VStack(alignment: .leading, spacing: Config.footerRowSpacing) { ForEach(Array(items.enumerated()), id: \.offset) { _, item in Text(item) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) .tracking(0.5) } } .padding(.top, 18) } } // MARK: - Page layout: horizontal photo strip /// Row of equal-width photos with small captions underneath. Spans /// the page horizontally with a tight inter-photo gap. private struct PhotoStripView: View { let photos: [StripPhoto] /// Gap between two photos in the strip. private let photoSpacing: CGFloat = 8 var body: some View { // Measure the row's own width and compute a fixed per-photo // width from it. With `.frame(maxWidth: .infinity)` on every // photo, the HStack ended up wider than the page (TabView's // bridged page view doesn't propagate finite width proposals // through `.frame(maxWidth: .infinity)` children the way a // normal layout does), and the strip bled off both screen // edges. Reading our own width and computing exact pixel // widths is the dependable fix. GeometryReader { proxy in let totalSpacing = photoSpacing * CGFloat(photos.count - 1) let perPhotoWidth = max( (proxy.size.width - totalSpacing) / CGFloat(photos.count), 10 ) HStack(spacing: photoSpacing) { ForEach(Array(photos.enumerated()), id: \.offset) { _, photo in VStack(alignment: .leading, spacing: 8) { AsyncImage(url: URL(string: photo.imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(width: perPhotoWidth, height: Config.photoStripHeight) .clipped() Text(photo.caption) .font(.system(size: Config.captionFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) .frame(width: perPhotoWidth, alignment: .leading) } } } } .frame(height: Config.photoStripHeight + 24) } } // MARK: - Page layout: vertical cards /// Stack of rows, each row a square-ish thumbnail on the left with /// caption + subtitle on the right. The KIDS tab uses this for its /// GIRL / BOY age-bracket landings. private struct VerticalCardsView: View { let cards: [VerticalCard] var body: some View { VStack(spacing: 18) { ForEach(Array(cards.enumerated()), id: \.offset) { _, card in HStack(spacing: 18) { AsyncImage(url: URL(string: card.imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(width: Config.verticalCardImageSize, height: Config.verticalCardImageSize) .clipped() VStack(alignment: .leading, spacing: 6) { Text(card.caption) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) Text(card.subtitle) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) .tracking(0.5) } Spacer(minLength: 0) } } } } } // MARK: - Page layout: wide hero /// Single wide photo filling the page width. The PERFUMES tab opens /// with one of these as a category hero. private struct WideHeroView: View { let imageURL: String var body: some View { AsyncImage(url: URL(string: imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(maxWidth: .infinity) .frame(height: Config.wideHeroHeight) .clipped() } } // MARK: - Page layout: plain list /// Two tiny links and nothing else. The ABOUT tab is just this. private struct PlainListView: View { let items: [String] var body: some View { VStack(alignment: .leading, spacing: Config.menuRowSpacing) { ForEach(Array(items.enumerated()), id: \.offset) { _, item in Text(item) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) } } .padding(.top, 4) } } #Preview { ZaraEditorialMenuSwipeSnippet() }
import SwiftUI // ZaraEditorialMenuSwipeSnippet // // A horizontal paginated menu in an editorial / high-end-fashion style. // Each page is its own category (WOMAN, MAN, KIDS, PERFUMES, TRAVEL MODE). // // 1. Tab strip (top): a row of large serif tab labels. Each title // takes its intrinsic text width with a uniform gap between // neighbours — short tabs like "MAN" stay narrow, long ones // like "PERFUMES" take more room. The active tab slides to a // fixed left inset with a small dot under it; the strip clamps // at its ends like a scroll view, so the last tabs stop at the // right edge instead of leaving blank space. The slide uses the // same spring as the page swipe so the two motions read locked. // 2. Body (below): a SwiftUI `TabView` with `.page` style. One // page per tab. Each page has its own layout: a numbered menu, // sometimes a hero image strip, sometimes a wide hero card, // sometimes nothing but a couple of links. The user drives // navigation by swiping horizontally; the header strip follows // via the shared selection binding. // // All photos load from Unsplash via AsyncImage. Edit `Config.tabs` to // swap them, or rewrite the page content blocks below to fit your // own categories. // // One file, no external dependencies. Drop into any iOS 26+ app or // Swift Playground. Network is required for the hero photos. // MARK: - Config /// All values a copy-paster might want to tweak. The implementation /// below reads everything from here, so renaming a tab, swapping a /// photo, or retuning the swipe feel never needs touching the views. private enum Config { // MARK: Copy /// Tab strip across the top, left to right. Each tab is a separate /// page in the horizontal pager. The user swipes the body or drags /// the strip / taps a tab to move between them. static let tabs: [PageSpec] = [ .init( title: "WOMAN", kind: .numberedWithHeroCard( sections: [ .init(number: "|05|", label: "PERFUMES", items: ["PERFUMES"]), .init(number: "|06|", label: "SPECIAL EDITION", items: []), .init(number: "|07|", label: "", items: ["SALE", "VIEW ALL"]), ], hero: .init( imageURL: unsplash("1483729558449-99ef09a8c325"), caption: "Brazil", body: "Some places ask to be planned. Rio asks to be lived." ), footer: ["TRAVEL MODE", "GIFT CARD", "STORES", "JOIN LIFE", "CAREERS"] ) ), .init( title: "MAN", kind: .stripWithNumbered( strip: [ .init(imageURL: unsplash("1488161628813-04466f872be2"), caption: "SPECIAL OCCASION"), .init(imageURL: unsplash("1542327897-d73f4005b533"), caption: "THE NEW"), .init(imageURL: unsplash("1517649763962-0c623066013b"), caption: "ATHLETICZ"), .init(imageURL: unsplash("1499529112087-3cb3b73cec95"), caption: "LINEN"), ], sections: [ .init(number: "|01|", label: "NEW IN", items: ["THE NEW", "SPRING EDIT^HOLI", "WAYS TO WEAR", "SPECIAL OCCASION^NEW"]), .init(number: "|02|", label: "COLLECTION", items: ["VIEW ALL", "BEST SELLERS", "JACKETS | GILETS", "SHIRTS", "LINEN", "T-SHIRTS"]), ] ) ), .init( // The category landings (GIRL / BOY + age range) match the // source design verbatim. The imagery uses editorial, // model-released children's-fashion photos from Unsplash as // stand-ins for the brand's own catalogue shots. title: "KIDS", kind: .verticalCards(cards: [ .init(imageURL: unsplash("1476234251651-f353703a034d"), caption: "GIRL", subtitle: "6 - 14 YEARS"), .init(imageURL: unsplash("1503919545889-aef636e10ad4"), caption: "BOY", subtitle: "6 - 14 YEARS"), .init(imageURL: unsplash("1518831959646-742c3a14ebf7"), caption: "GIRL", subtitle: "1½ - 6 YEARS"), .init(imageURL: unsplash("1471286174890-9c112ffca5b4"), caption: "BOY", subtitle: "1½ - 6 YEARS"), ]) ), .init( title: "PERFUMES", kind: .wideHeroWithNumbered( wideImageURL: unsplash("1485968579580-b6d095142e6e"), sections: [ .init(number: "|01|", label: "WOMAN", items: ["PERFUMES", "SALE"]), .init(number: "|02|", label: "MAN", items: ["PERFUMES"]), .init(number: "|03|", label: "KIDS", items: ["PERFUMES"]), ], hero: .init( imageURL: unsplash("1483729558449-99ef09a8c325"), caption: "Brazil", body: "Some places ask to be planned. Rio asks to be lived." ), footer: ["TRAVEL MODE"] ) ), .init( // The tab reads "TRAVEL MODE"; its page is a terse pair of // links (ABOUT / THE GUIDES), matching the source design. title: "TRAVEL MODE", kind: .plainList(items: ["ABOUT", "THE GUIDES"]) ), ] // MARK: Theme /// Page background. The source design is on pure-white paper. static let backgroundColor: Color = Color(white: 0.99) /// Primary ink color (tab text, menu numbers, body labels). static let inkColor: Color = .black /// Muted body text (sub-labels under menu numbers, footer links). static let mutedInkColor: Color = Color(white: 0.20) /// Accent used for the "highlighted" sub-items (the source shows /// occasional purple and yellow labels). Two-color accent kept here /// so a copy-paster can rebrand without hunting through the views. static let accentHighlight: Color = Color(red: 0.55, green: 0.2, blue: 0.85) static let accentBadge: Color = Color(red: 0.85, green: 0.65, blue: 0.10) /// Background of the small text card sitting next to a hero photo /// (the "Brazil / Coast" panel). Cream tone, not pure white, so it /// reads as a distinct surface against the page. static let heroCardBackground: Color = Color(red: 0.93, green: 0.92, blue: 0.88) // MARK: Layout (points) /// Side padding on every page's content column. static let pageHorizontalPadding: CGFloat = 32 /// Vertical gap between the tab strip and the dot indicator. static let tabToDotSpacing: CGFloat = 4 /// Tab strip font size. Big serif caps so the strip reads as the /// main page title, not as a small tab control. static let tabFontSize: CGFloat = 30 /// Horizontal gap between two adjacent tab titles. The titles /// themselves take their intrinsic text width, so a short tab /// like "MAN" occupies less room than "PERFUMES". This matches /// the source design where the strip reads as type set /// editorially, not as a tab control with uniform slot widths. static let tabSpacing: CGFloat = 32 /// Leading/trailing inset on the scrollable tab strip. The active /// tab scrolls to sit this far from the screen's leading edge, /// and the first/last tab keep this margin from the edges. /// Mirrors `pageHorizontalPadding` so the active tab's left edge /// stacks vertically with the page body's left margin below. static let tabLeadingInset: CGFloat = 32 /// Breathing room above the tab strip. The reference design has /// noticeable air between the status bar and the first tab /// title; 36pt feels editorial without pushing content too far /// down the page. static let tabStripTopPadding: CGFloat = 36 /// Breathing room below the dot indicator (between the dot row /// and the page body). The source design leaves a clear gap /// here, so the body never crowds the tab strip. static let tabStripBottomPadding: CGFloat = 30 /// Size of the active-tab indicator dot. static let activeDotSize: CGFloat = 4 /// Vertical spacing between numbered menu rows on a page. static let menuRowSpacing: CGFloat = 14 /// Vertical spacing between two numbered sections (|01|, |02|, ...). static let menuSectionSpacing: CGFloat = 18 /// Width allotted to the |0N| LABEL column. Keeps the sub-items /// aligned in a consistent vertical line down the page even when /// the section label is long. static let menuLabelColumnWidth: CGFloat = 170 /// Body font size for the numbered menu labels and items. static let menuFontSize: CGFloat = 14 /// Height of the horizontal photo strip on MAN-style pages. static let photoStripHeight: CGFloat = 180 /// Per-photo caption font size in the MAN strip and KIDS list. static let captionFontSize: CGFloat = 12 /// Height of the wide hero photo on the PERFUMES-style page. static let wideHeroHeight: CGFloat = 220 /// Hero card (image + text panel) overall height. static let heroCardHeight: CGFloat = 200 /// Width of the text panel on the right side of the hero card, /// as a fraction of the card's total width. ~30% gives the panel /// enough room for a short paragraph without dwarfing the photo. static let heroCardTextFraction: CGFloat = 0.32 /// Vertical thumbnail size in the KIDS-style vertical list. static let verticalCardImageSize: CGFloat = 130 /// Footer link row spacing on WOMAN-style pages. static let footerRowSpacing: CGFloat = 12 // MARK: Motion /// Spring used by the tab strip when it slides to follow the /// active page. Tuned to feel like the same gesture as the /// TabView's own page swipe, so the header reads as locked to /// the body even though SwiftUI animates them independently. static let pageSpring: Animation = .spring(duration: 0.7, bounce: 0.15) // MARK: Helpers /// Builds an Unsplash CDN URL. Caller passes only the photo ID /// (the part after `photo-` in any unsplash.com URL). `crop` /// optionally biases the auto-crop toward a side (`top`, `bottom`, /// `entropy`, etc.) when the image is being squeezed into a /// shorter frame than its native aspect. fileprivate static func unsplash(_ id: String, crop: String = "entropy") -> String { "https://images.unsplash.com/photo-\(id)?w=900&fit=crop&crop=\(crop)&auto=format&q=70" } } // MARK: - Page spec types /// One tab / page in the pager. `title` is what shows in the tab strip; /// `kind` defines what layout the body uses. struct PageSpec: Identifiable, Hashable { let id = UUID() let title: String let kind: PageKind } /// Each page in the source design follows one of a handful of layout /// recipes. Encoding them as enum cases (rather than per-tab View /// types) keeps the page-driving code small and lets the snippet /// declare its tabs as pure data inside `Config`. enum PageKind: Hashable { /// Numbered menu + a side-by-side hero card + a footer link list. /// Used by the WOMAN-style landing tab. case numberedWithHeroCard( sections: [MenuSection], hero: HeroCard, footer: [String] ) /// A horizontal photo strip at the top, then numbered menu sections. /// Used by the MAN-style tabs that lead with imagery. case stripWithNumbered( strip: [StripPhoto], sections: [MenuSection] ) /// A vertical list of cards, each with a thumbnail + caption + subtitle. /// Used by the KIDS-style tab where the page is a list of category /// landings (GIRL / BOY plus an age range). case verticalCards(cards: [VerticalCard]) /// One wide hero photo, then numbered menu sections, then a small /// hero card and a footer line. Used by the PERFUMES tab. case wideHeroWithNumbered( wideImageURL: String, sections: [MenuSection], hero: HeroCard, footer: [String] ) /// Bare list of label links, no numbers or imagery. Used by the /// terse ABOUT tab. case plainList(items: [String]) } /// One numbered section on a menu page (`|01| LABEL` plus sub-items). struct MenuSection: Hashable { /// The bracketed number like "|01|". Stored as a string so the /// brackets are part of the data, not assembled at render time. let number: String /// Section label shown to the right of the number, e.g. "NEW IN". let label: String /// Sub-items rendered as a vertical list inside the section. let items: [String] } /// One photo in a horizontal strip with a label underneath. struct StripPhoto: Hashable { let imageURL: String let caption: String } /// One card in the KIDS-style vertical list: thumbnail on the left, /// caption + subtitle stacked on the right. struct VerticalCard: Hashable { let imageURL: String let caption: String let subtitle: String } /// Wide photo + small cream text panel on its right, with a short /// poetic line of body copy. Appears on the WOMAN and PERFUMES tabs /// in the source design. struct HeroCard: Hashable { let imageURL: String let caption: String let body: String } // MARK: - Root view /// Editorial menu pager. Horizontally paginated tabs with a synced /// header strip on top. The user navigates three ways: swipe the body, /// drag the scrollable header strip, or tap a tab. All three share the /// same `currentIndex`, so the strip and the visible page stay in sync. struct ZaraEditorialMenuSwipeSnippet: View { /// The current page's index, shared by the `TabView` and the /// header strip so a swipe, a strip tap, or a programmatic scroll /// all read and write the same source of truth. @State private var currentIndex: Int = 0 var body: some View { // White paper background under everything. Sits inside a ZStack // so safe-area-extending bg stays separate from content layout. ZStack(alignment: .top) { Config.backgroundColor.ignoresSafeArea() VStack(spacing: 0) { TabHeaderStrip( tabs: Config.tabs, currentIndex: $currentIndex ) .padding(.top, Config.tabStripTopPadding) .padding(.bottom, Config.tabStripBottomPadding) // TabView in `.page` style gives us swipeable horizontal // paging for free, with proper paging gestures and // animated programmatic page changes via the binding. // Custom ScrollView + scrollPosition setups end up // fighting layout (LazyHStack widths, alignment in // nested ScrollViews); TabView avoids all of that. TabView(selection: $currentIndex) { ForEach(Array(Config.tabs.enumerated()), id: \.offset) { index, tab in PageBody(spec: tab) .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never)) } } .preferredColorScheme(.light) } } // MARK: - Tab header strip /// The big serif tab titles across the top, plus a small dot under /// the active one. The whole strip is a real horizontal `ScrollView`, /// so the user can drag it on its own to peek at other tabs. It also /// stays in sync with the pager three ways: /// - Swiping the body changes `currentIndex`; an `onChange` scrolls /// the active tab to the leading inset. /// - Tapping a tab sets `currentIndex`, which pages the body and /// scrolls the strip. /// - The dot lives inside the scroll content under each tab and is /// only shown for the active one, so it tracks position for free /// whether the strip is driven or hand-dragged. /// `contentMargins` gives the row a leading/trailing inset, and the /// ScrollView clamps at its ends, so the last tabs stop at the right /// edge instead of dragging blank space onto the screen. private struct TabHeaderStrip: View { let tabs: [PageSpec] @Binding var currentIndex: Int var body: some View { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: Config.tabSpacing) { ForEach(Array(tabs.enumerated()), id: \.offset) { index, tab in tabLabel(index: index, title: tab.title) .id(index) } } } // Symmetric inset so the first/last tab keep a margin from // the screen edges, and the active tab lands at this inset // when scrolled into view. .contentMargins(.horizontal, Config.tabLeadingInset, for: .scrollContent) .onChange(of: currentIndex) { _, newValue in withAnimation(Config.pageSpring) { proxy.scrollTo(newValue, anchor: .leading) } } .onAppear { proxy.scrollTo(currentIndex, anchor: .leading) } } // Fixed height (title + gap + dot) so the strip sizes tightly // inside the root VStack instead of stretching. .frame(height: Config.tabFontSize * 1.2 + Config.tabToDotSpacing + Config.activeDotSize) } /// One tab: the serif title with the active dot directly beneath /// it. Tapping anywhere on the column selects that page. private func tabLabel(index: Int, title: String) -> some View { VStack(spacing: Config.tabToDotSpacing) { Text(title) .font(.system(size: Config.tabFontSize, weight: .regular, design: .serif)) .foregroundStyle(Config.inkColor) .lineLimit(1) .fixedSize() Circle() .fill(Config.inkColor) .frame(width: Config.activeDotSize, height: Config.activeDotSize) .opacity(index == currentIndex ? 1 : 0) } .contentShape(Rectangle()) .onTapGesture { withAnimation(Config.pageSpring) { currentIndex = index } } } } // MARK: - Page body /// Dispatches a `PageSpec` to the right layout based on its `kind`. /// Each layout is its own private view below; keeping this dispatcher /// thin makes adding a new `PageKind` case a one-place change. private struct PageBody: View { let spec: PageSpec var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { switch spec.kind { case let .numberedWithHeroCard(sections, hero, footer): NumberedSectionsView(sections: sections) Spacer().frame(height: 28) HeroCardView(card: hero) Spacer().frame(height: 18) FooterLinksView(items: footer) case let .stripWithNumbered(strip, sections): Spacer().frame(height: 6) PhotoStripView(photos: strip) Spacer().frame(height: 22) NumberedSectionsView(sections: sections) case let .verticalCards(cards): Spacer().frame(height: 6) VerticalCardsView(cards: cards) case let .wideHeroWithNumbered(wide, sections, hero, footer): Spacer().frame(height: 6) WideHeroView(imageURL: wide) Spacer().frame(height: 24) NumberedSectionsView(sections: sections) Spacer().frame(height: 28) HeroCardView(card: hero) Spacer().frame(height: 18) FooterLinksView(items: footer) case let .plainList(items): PlainListView(items: items) } } // Claim the full page width so .leading alignment inside // the VStack anchors content to the left of the page, // not the center. Without this, the VStack hugs the // longest row's natural width and the vertical ScrollView // centers the (narrow) column in its viewport. .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 24) } // Side padding lives *outside* the ScrollView so it constrains // the ScrollView's width directly. Putting padding inside the // ScrollView's VStack lets the ScrollView take full width and // the padding only applies to the inner content, which makes // photo strips and other `maxWidth: .infinity` rows bleed // past the intended page margin. TabView page-style children // also strip padding applied to them directly (the bridged // UIPageViewController sizes its child to the full page). .padding(.horizontal, Config.pageHorizontalPadding) } } // MARK: - Page layout: numbered sections /// Renders a vertical list of `|0N| LABEL` rows, each with optional /// sub-items right below the label. The number sits in a fixed-width /// column on the left so sub-items down the page all line up. private struct NumberedSectionsView: View { let sections: [MenuSection] var body: some View { VStack(alignment: .leading, spacing: Config.menuSectionSpacing) { ForEach(Array(sections.enumerated()), id: \.offset) { _, section in HStack(alignment: .top, spacing: 0) { // |0N| LABEL column HStack(spacing: 12) { Text(section.number) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) Text(section.label) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) } .frame(width: Config.menuLabelColumnWidth, alignment: .leading) // Sub-items column VStack(alignment: .leading, spacing: Config.menuRowSpacing) { ForEach(Array(section.items.enumerated()), id: \.offset) { _, item in menuItem(item) } } } } } .padding(.top, 4) } /// One sub-item line. Two source-design touches are encoded in the /// flat item string so the data stays plain text: /// - A caret splits off a small raised badge, e.g. /// "SPRING EDIT^HOLI" renders "SPRING EDIT" with a superscript /// "HOLI", matching the little editorial tags in the video. /// - Some labels are tinted (purple for editorial pushes, gold /// for "NEW" badges), detected by keyword in `tint(for:)`. @ViewBuilder private func menuItem(_ raw: String) -> some View { let parts = raw.split(separator: "^", maxSplits: 1).map(String.init) let label = parts.first ?? raw let badge = parts.count > 1 ? parts[1] : nil let tinted = tint(for: label) HStack(alignment: .top, spacing: 3) { Text(label) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(tinted ?? Config.inkColor) .tracking(0.5) if let badge { Text(badge) .font(.system(size: Config.menuFontSize * 0.6, weight: .regular, design: .default)) .foregroundStyle(tinted ?? Config.mutedInkColor) .baselineOffset(Config.menuFontSize * 0.45) } } } /// Per-keyword accent picker. Returns nil for the common case so /// the caller can fall back to the default ink color. private func tint(for label: String) -> Color? { if label.contains("SPRING EDIT") { return Config.accentHighlight } if label.contains("SPECIAL OCCASION") { return Config.accentBadge } return nil } } // MARK: - Page layout: hero card /// Wide photo on the left, cream text panel on the right with a short /// caption + body line. The photo and the panel share a single /// horizontal frame so they always line up edge to edge. private struct HeroCardView: View { let card: HeroCard var body: some View { // Measure the row's own width so the cream text panel can // claim a precise fraction of it (~32%). Using // `containerRelativeFrame` here would size against the whole // TabView, not this row, and the panel would end up wrong. GeometryReader { proxy in let panelWidth = proxy.size.width * Config.heroCardTextFraction HStack(spacing: 0) { AsyncImage(url: URL(string: card.imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .font(.system(size: 32)) .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(width: proxy.size.width - panelWidth, height: Config.heroCardHeight) .clipped() VStack(alignment: .leading, spacing: 8) { Text(card.caption) .font(.system(size: 16, weight: .bold, design: .default)) .foregroundStyle(Config.inkColor) Text(card.body) .font(.system(size: 11, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) .lineSpacing(2) Spacer(minLength: 0) } .padding(12) .frame(width: panelWidth, height: Config.heroCardHeight, alignment: .topLeading) .background(Config.heroCardBackground) } } .frame(height: Config.heroCardHeight) } } // MARK: - Page layout: footer links /// Vertical list of small uppercase labels with generous row spacing. /// Used as the WOMAN tab's tail (TRAVEL MODE / GIFT CARD / etc.) and /// shortened for the PERFUMES tab. private struct FooterLinksView: View { let items: [String] var body: some View { VStack(alignment: .leading, spacing: Config.footerRowSpacing) { ForEach(Array(items.enumerated()), id: \.offset) { _, item in Text(item) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) .tracking(0.5) } } .padding(.top, 18) } } // MARK: - Page layout: horizontal photo strip /// Row of equal-width photos with small captions underneath. Spans /// the page horizontally with a tight inter-photo gap. private struct PhotoStripView: View { let photos: [StripPhoto] /// Gap between two photos in the strip. private let photoSpacing: CGFloat = 8 var body: some View { // Measure the row's own width and compute a fixed per-photo // width from it. With `.frame(maxWidth: .infinity)` on every // photo, the HStack ended up wider than the page (TabView's // bridged page view doesn't propagate finite width proposals // through `.frame(maxWidth: .infinity)` children the way a // normal layout does), and the strip bled off both screen // edges. Reading our own width and computing exact pixel // widths is the dependable fix. GeometryReader { proxy in let totalSpacing = photoSpacing * CGFloat(photos.count - 1) let perPhotoWidth = max( (proxy.size.width - totalSpacing) / CGFloat(photos.count), 10 ) HStack(spacing: photoSpacing) { ForEach(Array(photos.enumerated()), id: \.offset) { _, photo in VStack(alignment: .leading, spacing: 8) { AsyncImage(url: URL(string: photo.imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(width: perPhotoWidth, height: Config.photoStripHeight) .clipped() Text(photo.caption) .font(.system(size: Config.captionFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) .frame(width: perPhotoWidth, alignment: .leading) } } } } .frame(height: Config.photoStripHeight + 24) } } // MARK: - Page layout: vertical cards /// Stack of rows, each row a square-ish thumbnail on the left with /// caption + subtitle on the right. The KIDS tab uses this for its /// GIRL / BOY age-bracket landings. private struct VerticalCardsView: View { let cards: [VerticalCard] var body: some View { VStack(spacing: 18) { ForEach(Array(cards.enumerated()), id: \.offset) { _, card in HStack(spacing: 18) { AsyncImage(url: URL(string: card.imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(width: Config.verticalCardImageSize, height: Config.verticalCardImageSize) .clipped() VStack(alignment: .leading, spacing: 6) { Text(card.caption) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) Text(card.subtitle) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.mutedInkColor) .tracking(0.5) } Spacer(minLength: 0) } } } } } // MARK: - Page layout: wide hero /// Single wide photo filling the page width. The PERFUMES tab opens /// with one of these as a category hero. private struct WideHeroView: View { let imageURL: String var body: some View { AsyncImage(url: URL(string: imageURL)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: Image(systemName: "photo") .foregroundStyle(.white.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.2)) case .empty: Color.gray.opacity(0.15) @unknown default: Color.gray.opacity(0.15) } } .frame(maxWidth: .infinity) .frame(height: Config.wideHeroHeight) .clipped() } } // MARK: - Page layout: plain list /// Two tiny links and nothing else. The ABOUT tab is just this. private struct PlainListView: View { let items: [String] var body: some View { VStack(alignment: .leading, spacing: Config.menuRowSpacing) { ForEach(Array(items.enumerated()), id: \.offset) { _, item in Text(item) .font(.system(size: Config.menuFontSize, weight: .regular, design: .default)) .foregroundStyle(Config.inkColor) .tracking(0.5) } } .padding(.top, 4) } } #Preview { ZaraEditorialMenuSwipeSnippet() }