Skip to content

Instantly share code, notes, and snippets.

@mariosaputra
Created September 13, 2025 12:26
Show Gist options
  • Select an option

  • Save mariosaputra/c034c752d1026b526af0b86ab2579f50 to your computer and use it in GitHub Desktop.

Select an option

Save mariosaputra/c034c752d1026b526af0b86ab2579f50 to your computer and use it in GitHub Desktop.
import Foundation
import SwiftUI
struct OnboardingView: View {
@State private var currentStep = 0
@State private var isAnimating = false
@State private var tattooPosition = 0
@State private var tattooTimer: Timer?
@State private var tattooOpacity: Double = 0
@State private var tattooScale: Double = 0.5
// Onboarding steps data
private let onboardingSteps = [
OnboardingStep(
title: "Personalized",
subtitle: "Tattoo Designs",
description: "Craft unique tattoos that reflect your individuality, powered by AI.",
buttonText: "Continue",
imageName: "onboarding1"
),
OnboardingStep(
title: "AI-Powered",
subtitle: "Tattoo Creation",
description: "Generate stunning tattoo designs tailored to your style and preferences.",
buttonText: "Continue",
imageName: "onboarding2"
),
OnboardingStep(
title: "Try Before",
subtitle: "You Ink",
description: "Visualize how your tattoo will look on your skin before making it permanent.",
buttonText: "Get Started",
imageName: "onboarding3"
)
]
var body: some View {
GeometryReader { proxy in
ZStack {
// Background image (fills entire screen including safe areas)
Image(onboardingSteps[currentStep].imageName)
.resizable()
.scaledToFill()
.frame(width: proxy.size.width, height: proxy.size.height)
.clipped()
.ignoresSafeArea(.all)
// Tattoo overlay for step 3 (Try Before You Ink)
if currentStep == 2 {
tattooOverlayView(proxy: proxy)
}
// Dark gradient overlay to make content readable
LinearGradient(
gradient: Gradient(colors: [
Color.clear,
Color.black.opacity(0.25),
Color.black.opacity(0.55),
Color.black.opacity(1)
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea(.all)
// Bottom content overlay (keeps same look as WelcomeGridView)
VStack {
Spacer()
VStack(spacing: 16) {
// Title row: gradient + white text
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(onboardingSteps[currentStep].title)
.font(.system(size: adaptiveTitleFont(proxy: proxy), weight: .bold))
.foregroundStyle(
LinearGradient(
gradient: Gradient(colors: [Color.appPrimary, Color.appAccent]),
startPoint: .leading,
endPoint: .trailing
)
)
.minimumScaleFactor(0.5)
.lineLimit(1)
Text(onboardingSteps[currentStep].subtitle)
.font(.system(size: adaptiveTitleFont(proxy: proxy), weight: .bold))
.foregroundColor(.white)
.minimumScaleFactor(0.5)
.lineLimit(1)
}
.padding(.horizontal, 24)
.opacity(isAnimating ? 1 : 0)
.offset(y: isAnimating ? 0 : 12)
.animation(.easeOut(duration: 0.5).delay(0.12), value: isAnimating)
// Description — allow wrapping and vertical growth. limit width to avoid ultra-long lines on iPad.
Text(onboardingSteps[currentStep].description)
.font(.system(size: adaptiveSubtitleFont(proxy: proxy), weight: .medium))
.foregroundColor(.white.opacity(0.92))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true) // important: lets it expand vertically
.frame(maxWidth: min(720, proxy.size.width - 64)) // cap width on large screens
.padding(.horizontal, 26)
.opacity(isAnimating ? 1 : 0)
.offset(y: isAnimating ? 0 : 12)
.animation(.easeOut(duration: 0.5).delay(0.22), value: isAnimating)
// CTA button
Button(action: {
if currentStep < onboardingSteps.count - 1 {
// Add haptic feedback for step progression
Haptics.impact(.light)
withAnimation(.easeInOut(duration: 0.3)) {
currentStep += 1
isAnimating = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
withAnimation(.easeOut(duration: 0.45)) {
isAnimating = true
}
}
} else {
// Add success haptic feedback for onboarding completion
Haptics.notification(.success)
// Complete onboarding and navigate to main app
NotificationCenter.default.post(name: .onboardingCompleted, object: nil)
}
}) {
Text(onboardingSteps[currentStep].buttonText)
.font(.system(size: adaptiveButtonFont(proxy: proxy), weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(
LinearGradient(
gradient: Gradient(colors: [Color.appPrimary, Color.appAccent]),
startPoint: .leading,
endPoint: .trailing
)
)
.cornerRadius(12)
.padding(.horizontal, 24)
}
.opacity(isAnimating ? 1 : 0)
.offset(y: isAnimating ? 0 : 12)
.animation(.easeOut(duration: 0.5).delay(0.32), value: isAnimating)
Spacer().frame(height: max(20, proxy.safeAreaInsets.bottom))
}
// keep a small transparent background so the gradient beneath is visible but layout is stable
.padding(.vertical, 6)
.background(Color.black.opacity(0.001))
} // VStack
.ignoresSafeArea(edges: .bottom)
} // ZStack
.onAppear {
withAnimation(.easeOut(duration: 0.5)) {
isAnimating = true
}
}
.onChange(of: currentStep) { newStep in
// reset & replay entrance animation
isAnimating = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
withAnimation(.easeOut(duration: 0.45)) { isAnimating = true }
}
// Handle tattoo animation for step 3
if newStep == 2 {
startTattooAnimation()
} else {
stopTattooAnimation()
}
}
} // GeometryReader
}
// adaptive font sizes so the text isn't too big on very large screens
private func adaptiveTitleFont(proxy: GeometryProxy) -> CGFloat {
let w = proxy.size.width
if w > 900 { return 44 }
if w > 600 { return 36 }
return 28
}
private func adaptiveSubtitleFont(proxy: GeometryProxy) -> CGFloat {
let w = proxy.size.width
if w > 900 { return 20 }
if w > 600 { return 18 }
return 16
}
private func adaptiveButtonFont(proxy: GeometryProxy) -> CGFloat {
let w = proxy.size.width
if w > 900 { return 20 }
if w > 600 { return 18 }
return 16
}
// MARK: - Tattoo Animation Functions
private func startTattooAnimation() {
// Add haptic feedback when tattoo animation starts
Haptics.pulse()
// Start with first position visible
tattooPosition = 0
withAnimation(.easeOut(duration: 1.0)) {
tattooOpacity = 0.8
tattooScale = 1.0
}
// Set up timer for subsequent animations
tattooTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in
animateToNextPosition()
}
}
private func animateToNextPosition() {
// Fade out current tattoo
withAnimation(.easeOut(duration: 0.8)) {
tattooOpacity = 0
tattooScale = 0.5
}
// After fade out, change position and fade in (no movement animation)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
// Change position instantly while invisible
tattooPosition = (tattooPosition + 1) % 3
// Fade in at new position
withAnimation(.easeOut(duration: 1.0)) {
tattooOpacity = 0.8
tattooScale = 1.0
}
}
}
private func stopTattooAnimation() {
tattooTimer?.invalidate()
tattooTimer = nil
// Fade out and reset
withAnimation(.easeOut(duration: 0.4)) {
tattooOpacity = 0
tattooScale = 0.5
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
tattooPosition = 0
}
}
// MARK: - Tattoo Overlay View
@ViewBuilder
private func tattooOverlayView(proxy: GeometryProxy) -> some View {
Image("six_pack_tattoo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 250, height: 150)
.opacity(tattooOpacity)
.scaleEffect(tattooScale)
.position(tattooPosition(for: proxy))
}
private func tattooPosition(for proxy: GeometryProxy) -> CGPoint {
let width = proxy.size.width
let height = proxy.size.height
switch tattooPosition {
case 0:
// Position 1: Upper chest area
return CGPoint(x: width * 0.5, y: height * 0.1)
case 1:
// Position 2: Lower chest/upper abs
return CGPoint(x: width * 0.7, y: height * 0.4)
case 2:
// Position 3: Lower abs
return CGPoint(x: width * 0.5, y: height * 0.55)
default:
return CGPoint(x: width * 0.3, y: height * 0.25)
}
}
}
// MARK: - Onboarding Step Model
struct OnboardingStep {
let title: String
let subtitle: String
let description: String
let buttonText: String
let imageName: String
}
// MARK: - Previews
struct OnboardingView_Previews: PreviewProvider {
static var previews: some View {
Group {
OnboardingView()
.previewDevice("iPhone 14 Pro")
OnboardingView()
.previewDevice("iPhone 13 mini")
OnboardingView()
.previewDevice("iPad Pro (12.9-inch) (6th generation)")
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment