What You Will Build
An animated walkthrough screen where motivational phrases slide in with a colored circle, the background color transitions between pages, and the text sweeps offscreen before the next phrase appears. This creates a polished onboarding experience with sign-in buttons at the bottom.
Why This Pattern Matters
First impressions determine whether users keep your app. An animated walkthrough builds excitement during onboarding without requiring the user to swipe. The auto-playing loop showcases your app personality while the sign-in buttons remain accessible at the bottom.
Key SwiftUI Concepts
- .snappy animation with completion handlers chains sequential animations.
- NSString.size(withAttributes:) measures text width for precise offset calculations.
- Recursive async function loops through walkthrough pages automatically.
Step 1: Define the Intro Model
private struct WalkthroughIntro: Identifiable {
var id = UUID()
var text: String
var textColor: Color
var circleColor: Color
var bgColor: Color
var circleOffset: CGFloat = 0
var textOffset: CGFloat = 0
}
private var walkthroughSampleIntros: [WalkthroughIntro] = [
.init(text: "Let's Create", textColor: .yellow,
circleColor: .yellow, bgColor: .indigo),
.init(text: "Let's Brain Storm", textColor: .indigo,
circleColor: .indigo, bgColor: .mint),
.init(text: "Let's Explore", textColor: .mint,
circleColor: .mint, bgColor: .orange),
.init(text: "Let's Invent", textColor: .orange,
circleColor: .orange, bgColor: .cyan),
]
Step 2: Build the Animated Text and Circle
The core visual is a circle that sits to the right of the text. Both share a horizontal offset that animates to sweep the current phrase off screen, then resets with the next phrase:
Circle()
.fill(activeIntro.circleColor)
.frame(width: 38, height: 38)
.background(alignment: .leading) {
Capsule()
.fill(activeIntro.bgColor)
.frame(width: size.width)
}
.background(alignment: .leading) {
Text(activeIntro.text)
.font(.largeTitle)
.foregroundStyle(activeIntro.textColor)
.frame(width: walkthroughTextSize(activeIntro.text))
.offset(x: 10)
.offset(x: activeIntro.textOffset)
}
.offset(x: -activeIntro.circleOffset)
Step 3: The Animation Loop
A recursive function uses .snappy animation with completion handlers to chain the exit and entry animations:
func walkthroughAnimate(_ index: Int, _ loop: Bool = true) {
if intros.indices.contains(index + 1) {
activeIntro?.text = intros[index].text
activeIntro?.textColor = intros[index].textColor
withAnimation(.snappy(duration: 1), completionCriteria: .removed) {
activeIntro?.textOffset = -(walkthroughTextSize(intros[index].text) + 20)
activeIntro?.circleOffset = -(walkthroughTextSize(intros[index].text) + 20) / 2
} completion: {
withAnimation(.snappy(duration: 0.8), completionCriteria: .logicallyComplete) {
activeIntro?.textOffset = 0
activeIntro?.circleOffset = 0
activeIntro?.circleColor = intros[index + 1].circleColor
activeIntro?.bgColor = intros[index + 1].bgColor
} completion: {
walkthroughAnimate(index + 1, loop)
}
}
} else if loop {
walkthroughAnimate(0, loop)
}
}
func walkthroughTextSize(_ text: String) -> CGFloat {
NSString(string: text).size(withAttributes: [
.font: UIFont.preferredFont(forTextStyle: .largeTitle)
]).width
}
Step 4: Assemble with Sign-In Buttons
VStack(spacing: 12) {
Button {} label: {
Label("Continue With Apple", systemImage: "applelogo")
.foregroundStyle(.black)
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(.white, in: .rect(cornerRadius: 15))
}
Button {} label: {
Label("Continue With Phone", systemImage: "phone.fill")
.foregroundStyle(.white)
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(.gray.opacity(0.3), in: .rect(cornerRadius: 15))
}
}
.padding(15)
.background(.black, in: .rect(topLeadingRadius: 25, topTrailingRadius: 25))
Tips and Pitfalls
- completionCriteria matters: Use
.removedfor the exit animation and.logicallyCompletefor the entry to properly chain them. - Measure text accurately:
NSString.size(withAttributes:)gives pixel-accurate widths. Using estimated values causes text to clip or leave gaps. - Duplicate the last intro: Add the first item again at the end of the array so the loop transitions smoothly from the last page back to the first.
- Task vs onAppear: Using
.taskensures the animation starts once and supports async sleep for the initial delay. - Safe area handling: Use
GeometryReaderto readsafeAreaInsetsand pad the bottom buttons accordingly.
iOS Version: iOS 17+ (requires .snappy animation with completion)