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.
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:
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!