A mesh gradient app should have a beautiful onboarding experience. With that in mind, I decided to work on a beautifully animated onboarding screen for my app, Meshing.

Join the Meshing: AI Mesh Gradient Tool beta
Available on iOS

In this post, we will create a playful onboarding experience using SwiftUI. I would prefer to hold your hands and walk you through each step, from setting up the basic structure to adding delightful animations that captivate your users.

Structuring the OnboardingView

Even though you will get a lot of errors if you copy-paste the code below, I want to start by creating the main structure of the onboarding screen:

public struct OnboardingView: View {
  @Binding var hasCompletedOnboarding: Bool
  @StateObject private var viewModel = OnboardingViewModel()
  
  public init(hasCompletedOnboarding: Binding<Bool>) {
    self._hasCompletedOnboarding = hasCompletedOnboarding
  }
  
  public var body: some View {
    ZStack {
      AnimatedMeshGradientBackground()
      
      VStack(spacing: 30) {
        Spacer()
        
        TitleView(offset: $viewModel.titleOffset, opacity: $viewModel.titleOpacity)
        
        Spacer()
        
        AnimatedFeatureGrid()
        
        Spacer()
        
        GetStartedButton(scale: $viewModel.buttonScale, opacity: $viewModel.buttonOpacity) {
          withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
            hasCompletedOnboarding = true
          }
        }
      }
      .padding()
    }
    .onAppear(perform: viewModel.onAppear)
  }
}
  • We use a ZStack to layer the content for the animated mesh gradient background.
  • We have a title view, a grid of animated features, and a "Get Started" button.
  • The @Binding property wrapper allows to control when the onboarding is complete from the parent view.

Managing State with OnboardingViewModel

To keep our view clean and focused on the presentation, I use a view model to manage the state and logic:

final class OnboardingViewModel: ObservableObject {
  @Published var titleOffset: CGFloat = -50
  @Published var titleOpacity: Double = 0
  @Published var buttonScale: CGFloat = 0.8
  @Published var buttonOpacity: Double = 0

  func onAppear() {
    withAnimation(.spring(response: 0.8, dampingFraction: 0.7).delay(0.3)) {
      titleOffset = 0
      titleOpacity = 1
    }

    withAnimation(.spring(response: 0.6, dampingFraction: 0.7).delay(0.9)) {
      buttonScale = 1
      buttonOpacity = 1
    }
  }
}

This view model handles the animation states for the title and button. When the view appears, it triggers animations to bring these elements into view with a subtle, playful motion.

Creating Components

Next, we create the components that make the onboarding screen:

struct TitleView: View {
  @Binding var offset: CGFloat
  @Binding var opacity: Double
  
  var body: some View {
    Text("Happy \nMeshing!")
      .font(.system(size: 48))
      .fontWidth(.expanded)
      .fontWeight(.heavy)
      .foregroundStyle(.white.gradient)
      .multilineTextAlignment(.center)
      .offset(y: offset)
      .opacity(opacity)
      .fixedSize(horizontal: false, vertical: true)
  }
}

The @Binding properties allow us to animate the title's position and opacity from the parent view.

struct GetStartedButton: View {
  @Binding var scale: CGFloat
  @Binding var opacity: Double
  let action: () -> Void
  
  var body: some View {
    AnimatedButton(text: "Get Started", action: action)
      .scaleEffect(scale)
      .opacity(opacity)
  }
}

struct AnimatedButton: View {
  let text: String
  let action: () -> Void
  @State private var isHovered = false
  
  var body: some View {
    Button(action: action) {
      Text(text)
        .foregroundStyle(.white.gradient)
        .font(.headline)
        .bold()
        .padding()
        .padding(.horizontal)
        .background(
          RoundedRectangle(cornerRadius: 12)
            .fill(.ultraThinMaterial)
            .overlay(
              RoundedRectangle(cornerRadius: 12)
                .stroke(.white.opacity(0.5), lineWidth: 1)
            )
        )
        .scaleEffect(isHovered ? 1.05 : 1)
    }
    .buttonStyle(.plain)
    .onHover { hovering in
      withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
        isHovered = hovering
      }
    }
  }
}

The GetStartedButton wraps the AnimatedButton, which includes a hover effect for added interactivity. The button uses a frosted glass effect with .ultraThinMaterial and a subtle white border.

To highlight the key features of the app, we will create an animated grid:

struct AnimatedFeatureCard: View {
  let feature: Feature
  let delay: Double
  
  @State private var opacity: Double = 0
  @State private var offset: CGFloat = 50
  
  var body: some View {
    FeatureCard(feature: feature)
      .opacity(opacity)
      .offset(y: offset)
      .onAppear {
        withAnimation(.spring(response: 0.6, dampingFraction: 0.8).delay(delay)) {
          opacity = 1
          offset = 0
        }
      }
  }
}

struct FeatureCard: View {
  let feature: Feature
  
  var body: some View {
    HStack {
      Image(systemName: feature.icon)
        .font(.largeTitle)
        .frame(minWidth: 50)
      
      VStack(alignment: .leading) {
        Text(feature.title)
          .font(.title3)
          .bold()
        
        Text(feature.description)
          .font(.subheadline)
          .fixedSize(horizontal: false, vertical: true)
      }
      
      Spacer()
    }
    .foregroundStyle(.white.gradient)
    .padding()
    .cornerRadius(15)
  }
}

This set of views creates the animated feature grid. Each feature card fades in and slides up with a slight delay, creating a cascading effect.

To make the feature grid flexible, we will define a Feature struct:

struct Feature: Identifiable {
  let id = UUID()
  let icon: String
  let title: String
  let description: String
  
  static let allFeatures = [
    Feature(icon: "paintpalette", title: "Create Gradients", description: "Design stunning mesh gradients with intuitive controls"),
    Feature(icon: "camera", title: "Image to Gradient", description: "Extract colors from your photos to generate unique gradients"),
    Feature(icon: "wand.and.stars", title: "AI Generation", description: "Let Meshing AI create beautiful gradients based on your prompts"),
    Feature(icon: "square.and.arrow.up", title: "Export & Share", description: "Save your creations and share them easily")
  ]
}

Now, finally. The stunning animated background that will tie everything together:

struct AnimatedMeshGradientBackground: View {
  @StateObject private var viewModel = MeshGradientViewModel()

  var body: some View {
    TimelineView(.animation) { phase in
      MeshGradient(
        width: 4,
        height: 4,
        locations: .points(viewModel.animatedPositions(for: phase.date)),
        colors: .colors(viewModel.colors),
        background: Color(hex: "#00264d"),
        smoothsColors: true
      )
    }
    .overlay(content: {
      Color.black.opacity(0.5)
        .ignoresSafeArea()
    })
    .ignoresSafeArea()
  }
}

class MeshGradientViewModel: ObservableObject {
  let colors: [Color] = [
    Color(hex: "#00264d"), Color(hex: "#004080"), Color(hex: "#0059b3"), Color(hex: "#0073e6"),
    Color(hex: "#1a8cff"), Color(hex: "#4da6ff"), Color(hex: "#80bfff"), Color(hex: "#b3d9ff"),
    Color(hex: "#00ff80"), Color(hex: "#33ff99"), Color(hex: "#66ffb3"), Color(hex: "#99ffcc"),
    Color(hex: "#004d40"), Color(hex: "#00665c"), Color(hex: "#008577"), Color(hex: "#00a693")
  ]

  private let initialPositions: [SIMD2<Float>] = [
    SIMD2<Float>(0.00, 0.00), SIMD2<Float>(0.33, 0.00), SIMD2<Float>(0.67, 0.00), SIMD2<Float>(1.00, 0.00),
    SIMD2<Float>(0.00, 0.33), SIMD2<Float>(0.33, 0.33), SIMD2<Float>(0.67, 0.33), SIMD2<Float>(1.00, 0.33),
    SIMD2<Float>(0.00, 0.67), SIMD2<Float>(0.33, 0.67), SIMD2<Float>(0.67, 0.67), SIMD2<Float>(1.00, 0.67),
    SIMD2<Float>(0.00, 1.00), SIMD2<Float>(0.33, 1.00), SIMD2<Float>(0.67, 1.00), SIMD2<Float>(1.00, 1.00)
  ]

  func animatedPositions(for date: Date) -> [SIMD2<Float>] {
    let phase = CGFloat(date.timeIntervalSince1970)
    var animatedPositions = initialPositions

    // Animate edge points
    animatedPositions[1].x = 0.33 + 0.1 * Float(cos(phase * 0.7))  // Top edge
    animatedPositions[2].x = 0.67 - 0.1 * Float(cos(phase * 0.8))  // Top edge
    animatedPositions[4].y = 0.33 + 0.1 * Float(cos(phase * 0.9))  // Left edge
    animatedPositions[7].y = 0.33 - 0.1 * Float(cos(phase * 0.6))  // Right edge
    animatedPositions[13].x = 0.33 + 0.1 * Float(cos(phase * 1.3)) // Bottom edge
    animatedPositions[14].x = 0.67 - 0.1 * Float(cos(phase * 1.4)) // Bottom edge

    // Animate inner points
    animatedPositions[5].x = 0.33 + 0.1 * Float(cos(phase * 0.8))
    animatedPositions[5].y = 0.33 + 0.1 * Float(cos(phase * 0.9))
    animatedPositions[6].x = 0.67 - 0.1 * Float(cos(phase * 1.0))
    animatedPositions[6].y = 0.33 + 0.1 * Float(cos(phase * 1.1))
    animatedPositions[9].x = 0.33 + 0.1 * Float(cos(phase * 1.2))
    animatedPositions[9].y = 0.67 - 0.1 * Float(cos(phase * 1.3))
    animatedPositions[10].x = 0.67 - 0.1 * Float(cos(phase * 1.4))
    animatedPositions[10].y = 0.67 - 0.1 * Float(cos(phase * 1.5))

    return animatedPositions
  }
}

This background uses the new MeshGradient view to create a smoothly animated gradient effect. The MeshGradientViewModel handles the maths of animating the gradient points over time.

And there you have it, your playful onboarding screen:

0:00
/0:12

Moving Forward

Play around with this code. Change the colours, tweak the animations, add flair, and make it yours. I am not proud of hardcoding the points for the animations and looking for a better way to do it.

Use this as a starting point for your next project. Although it may not be an onboarding screen, the concepts you have learned here can be applied to all UI elements.

Do not be afraid to break things. I spent half a decade fearful of playing with code as if my precious MacBook would catch fire if I did something outside the tutorial's instructions.

Discovery requires experimentation. Some of my best learning experiences came from "accidentally" creating wild animations or effects.

Happy meshing!

String Catalog

String Catalog - App Localization on Autopilot

Push to GitHub, and we'll automatically localize your app for 40+ languages, saving you hours of manual work.

Tagged in: