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 .removed for the exit animation and .logicallyComplete for 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 .task ensures the animation starts once and supports async sleep for the initial delay.
  • Safe area handling: Use GeometryReader to read safeAreaInsets and pad the bottom buttons accordingly.

iOS Version: iOS 17+ (requires .snappy animation with completion)

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.