How to Create a Splash Screen With SwiftUI

Learn to build a splash screen that uses animation and SwiftUI to go beyond the typical static launch screen and keeps users interested while the app loads. By Rony Rozen.

Leave a rating/review
Download materials
Save for later
Share
Update note: Rony Rozen updated this tutorial for SwiftUI, Xcode 11. Derek Selander wrote the original.

Oh, the wonderful splash screen — a chance for developers to go wild with fun animations as the app frantically pings API endpoints for the critical data it needs to function. The splash screen, as opposed to a static, animation-free launch screen, can play an important role in an app: Keeping users interested while they wait for the app to start.

This tutorial will guide you step by step from an app with no splash screen to one with a cool splash screen that will be the envy of others. So, what are you waiting for?

Note: This tutorial assumes you’re comfortable with SwiftUI animations, states and modifiers. Rather than introducing those concepts, this tutorial focuses on using them to replicate a cool animation. To learn more about SwiftUI, check out SwiftUI: Getting Started.

Getting Started

In this tutorial, you’ll enhance an app called Fuber. Fuber is an on-demand ride-sharing service that allows passengers to request Segway drivers to transport them to different locations in urban environments.

Fuber has grown rapidly and now serves Segway passengers in over 60 countries, but it faces opposition from numerous governments as well as Segway unions for its use of contract Segway drivers. :]

Splash screen

Use the Download Materials button at the top or bottom of the page to download the starter project for this tutorial. Then, open the starter project and take a look around.

As you can see in ContentView.swift, all the app currently does is show SplashScreen for two seconds, then fade it away to reveal a MapView.

Note: In a production app, the exit criteria for this loop could be a handshake success to an API endpoint, which provides the app with the data necessary to continue.

The splash screen lives in its own module: SplashScreen.swift. You can see that it has a Fuber-blue background with a “F ber” label that’s waiting for you to add the animated ‘U’.

Build and run the starter project.

You’ll see a not-very-exciting static splash screen that transitions into the map (Fuber’s main screen) after a few seconds.

You’ll spend the remainder of this tutorial transforming this boring static splash screen into a beautifully-animated screen that will make your users wish the main screen would never load. Take a look at what you’ll build:

Note: If you’re running the macOS Catalina beta, you can use live previews in lieu of building and running on the simulator.

Understanding the Composition of Views and Layers

The new and improved SplashScreen will consist of several subviews, all conveniently organized in a ZStack:

  • The grid background consisting of tiles of a smaller “Chimes” image, which comes with the starter project.
  • The “F ber” text, with room for the animated FuberU.
  • The FuberU, which represents the circular white background for the ‘U’.
  • A Rectangle representing the square in the middle of FuberU.
  • Another Rectangle representing the line that goes from the middle of FuberU to its outer edge.
  • A Spacer view to make sure that the ZStack size will cover the entire screen.

Combined, these views create the Fuber ‘U’ animation.

RiderIconView

The starter project provides the Text and Spacer views. You’ll add the rest of the views in the following sections.

Now that you know how you’ll compose these layers, you can start creating and animating the FuberU.

Animating the Circle

When working with animations, it’s best to focus on the animation you’re currently implementing. Open ContentView.swift and comment out the .onAppear closure. It should look like this:

.onAppear {
//DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
//  withAnimation() {
//    self.showSplash = false
//  }
//}
}

This way, you won’t be distracted by the splash screen fading out to reveal the MapView after X seconds. Don’t worry, you’ll uncomment that part when you’re done and ready to ship.

You can now focus on the animation. Start by opening SplashScreen.swift and, right below SplashScreen‘s closing bracket, add a new struct called FuberU:

struct FuberU: Shape {
  var percent: Double
  
  // 1
  func path(in rect: CGRect) -> Path {
    let end = percent * 360
    var p = Path()

    // 2
    p.addArc(center: CGPoint(x: rect.size.width/2, y: rect.size.width/2),
             radius: rect.size.width/2,
             startAngle: Angle(degrees: 0),
             endAngle: Angle(degrees: end),
             clockwise: false)
    
    return p
  }  
  // 3
  var animatableData: Double {
    get { return percent }
    set { percent = newValue }
  }
}

Here’s what you’re doing with this code:

  1. Implementing path(in:) as required by the Shape protocol.
  2. Using a path to draw an arc starting at 0 and ending at 360, i.e a full circle.
  3. Adding an extra property, so SwiftUI knows how to animate your shape.

In order to see your new type in action, you’ll set up some variables and an animation, then declare it on body with some modifiers.

Start by adding these variables right before the body element in the SplashScreen struct:

@State var percent = 0.0
let uLineWidth: CGFloat = 5

You’ll use these variables when initiating and modifying FuberU.

Then, add the following code after SplashScreen‘s struct closing bracket:

extension SplashScreen {
  var uAnimationDuration: Double { return 1.0 }
    
  func handleAnimations() {
    runAnimationPart1()
  }

  func runAnimationPart1() {
    withAnimation(.easeIn(duration: uAnimationDuration)) {
      percent = 1
    }
  }
}

handleAnimations() will be the basis for all of the different parts of the splash screen’s complex animation. It’s based on “magic numbers”, which you can play around with and tweak to match your exact taste later.

Finally, add the following code inside body, between the existing Text and Spacer elements.

FuberU(percent: percent)
 .stroke(Color.white, lineWidth: uLineWidth)
 .onAppear() {
   self.handleAnimations()
 }
 .frame(width: 45, height: 45, alignment: .center)

Here, you add the new circle, which will eventually represent a part of the Fuber ‘U’, to the stack at a specific position. In addition, you call handleAnimations() when the view appears.

Build and run your app:

You can see that something is happening, but it’s not exactly what you might expect. Your code is indeed drawing a circle, but only one time, and the circle’s border is way too thin. You want it to fill the entire circle. You’ll fix those problems right away.

Improving the Circle Animation

Start by adding this code right after runAnimationPart1():

func restartAnimation() {
  let deadline: DispatchTime = .now() + uAnimationDuration
  DispatchQueue.main.asyncAfter(deadline: deadline) {
    self.percent = 0
    self.handleAnimations()
  }
}

And to call this method, add this line at the end of handleAnimations():

restartAnimation()

This code loops the animation by waiting for its duration to reset percent and then calling it again.

Now that the circle animation repeats itself, you can add modifiers to FuberU to make it look exactly like you want. First, add these new variables before body:

@State var uScale: CGFloat = 1
let uZoomFactor: CGFloat = 1.4

Now, between the stroke(_:lineWidth:) and onAppear() modifiers on FuberU, add these three new modifiers:

.rotationEffect(.degrees(-90))
.aspectRatio(1, contentMode: .fit)
.padding(20)

Finally, add a scaleEffect(_:anchor:) right before frame(width:height:alignment:):

.scaleEffect(uScale * uZoomFactor)

Your FuberU declaration now looks like this:

FuberU(percent: percent)
  .stroke(Color.white, lineWidth: uLineWidth)
  .rotationEffect(.degrees(-90))
  .aspectRatio(1, contentMode: .fit)
  .padding(20)
  .onAppear() {
    self.handleAnimations()
  }
  .scaleEffect(uScale * uZoomFactor)
  .frame(width: 45, height: 45, alignment: .center)

This code has made the line wider, added a rotation so that the drawing starts from the top and added a scale effect so that the circle grows while it’s animating.

Finish this part by adding the following line in runAnimationPart1(), inside the animation block, right after you update percent to 1:

uScale = 5

With this code, you’re changing the uScale state from 1 to 5.

Build and run your app:

Now the circle behaves as you expected — your app draws a full white circle from 0 to 360 degrees, which grows a bit in the process.

You’ll probably notice that the circle only increases in size on the first draw cycle. That’s because you never re-initialized uScale. Don’t worry, you’ll address this in the next step of the animation.

Note: Try playing around with the FuberU modifiers — remove some, add new ones, change the values and so on. As you observe the view changes, you’ll better understand what each modifier does.

Adding the Square

With the animations of the Fuber ‘U’ complete, it’s time to add the square.

To start, add these new states and properties before body:

@State var squareColor = Color.white
@State var squareScale: CGFloat = 1

let uSquareLength: CGFloat = 12

Add a Rectangle view for the center square, right after FuberU in the ZStack:

Rectangle()
  .fill(squareColor)
  .scaleEffect(squareScale * uZoomFactor)
  .frame(width: uSquareLength, height: uSquareLength, alignment: .center)
  .onAppear() {
      self.squareColor = self.fuberBlue
  }

You’ve added a square, with a size and fill color that will each change throughout the animation.

Build and run your app:

As you can see, the circle appears behind the square at the expected size, but without animations. You still need to add all of the prep work, and then handle the animations in the right order.

Next up, the line!

Adding the Line

Now, you’ll need to add the line to make your ‘U’ look more like the letter ‘U’ and less like a circle with a square on top of it.

Add the following properties and states before body:

@State var lineScale: CGFloat = 1

let lineWidth:  CGFloat = 4
let lineHeight: CGFloat = 28

Then add a Rectangle view at the end of your ZStack, right before Spacer.

Rectangle()
  .fill(fuberBlue)
  .scaleEffect(lineScale, anchor: .bottom)
  .frame(width: lineWidth, height: lineHeight, alignment: .center)
  .offset(x: 0, y: -22)

Build and run your app:

Now that you have all of the elements for the Fuber ‘U’, you can make the animation a bit more complex. Are you ready for the challenge?

Completing the U Animation

The ‘U’ animation you want to make has three stages:

  • The circle zooms in as it’s drawn.
  • The circle quickly zooms out into a square.
  • The square fades away.

You’ll use these three stages as you expand your existing handleAnimations(). Start by adding these new properties right after uAnimationDuration:

var uAnimationDelay: Double { return  0.2 }
var uExitAnimationDuration: Double{ return 0.3 }
var finalAnimationDuration: Double { return 0.4 }
var minAnimationInterval: Double { return 0.1 }
var fadeAnimationDuration: Double { return 0.4 }

These magic numbers are the result of trial and error. Feel free to play around with them to see if you feel they improve the animation, or just to make it easier for you to understand how they work.

Add one more line to the end of runAnimationPart1(), right after uScale = 5:

lineScale = 1

Add the following code to the end of runAnimationPart1(), right after the animation block’s closing brace:


//TODO: Add code #1 for text here

let deadline: DispatchTime = .now() + uAnimationDuration + uAnimationDelay
DispatchQueue.main.asyncAfter(deadline: deadline) {
  withAnimation(.easeOut(duration: self.uExitAnimationDuration)) {
    self.uScale = 0
    self.lineScale = 0
  }
  withAnimation(.easeOut(duration: self.minAnimationInterval)) {
    self.squareScale = 0
  }
    
  //TODO: Add code #2 for text here
}   

Here, you use an async call with a deadline to run the code after the first animation runs. Notice you have some placeholders for text animations; you’ll address these soon.

It’s time for the second part of the animation. Add this after runAnimationPart1()‘s closing bracket:

func runAnimationPart2() {
  let deadline: DispatchTime = .now() + uAnimationDuration + 
    uAnimationDelay + minAnimationInterval
  DispatchQueue.main.asyncAfter(deadline: deadline) {
    self.squareColor = Color.white
    self.squareScale = 1
  }
}   

Make sure to add a call to your new function right after runAnimationPart1() in handleAnimations():

runAnimationPart2()

Now, add the third part of the animation after runAnimationPart2():

func runAnimationPart3() {
  DispatchQueue.main.asyncAfter(deadline: .now() + 2 * uAnimationDuration) {
  withAnimation(.easeIn(duration: self.finalAnimationDuration)) {
    //TODO: Add code #3 for text here
    self.squareColor = self.fuberBlue
  }
  }
}

Note that the code contains TODOs to show the exact places where you’ll animate the text later in this tutorial.

Now, add your new animation in handleAnimations(), right after runAnimationPart2():

runAnimationPart3()

To finish this stage, replace restartAnimation() with this new implementation:

func restartAnimation() {
    let deadline: DispatchTime = .now() + 2 * uAnimationDuration + 
      finalAnimationDuration
    DispatchQueue.main.asyncAfter(deadline: deadline) {
      self.percent = 0
      //TODO: Add code #4 for text here
      self.handleAnimations()
    }
}

Note that you scheduled each step of the animation to start at a certain point based on the “magic numbers” you defined for that specific step.

Build and run your app, then observe the prettiness! :]

If you look at the finished animation, you’ll see that the text starts transparent and small, then fades in, zooms in with a spring and, finally, fades away. It’s time to put all that in place.

Animating the Text

The “F ber” text has been there from the start, but it’s kind of boring because it’s not animating along with the ‘U’. To fix that, you’ll add two new modifiers to the Text. First, add two new states before body:

@State var textAlpha = 0.0
@State var textScale: CGFloat = 1

Now, it’s time to replace those placeholders with the actual animation.

Replace //TODO: Add code #1 for text here with:

withAnimation(Animation.easeIn(duration: uAnimationDuration).delay(0.5)) {
  textAlpha = 1.0
}

Second, replace //TODO: Add code #2 for text here with:

withAnimation(Animation.spring()) {
  self.textScale = self.uZoomFactor
}

Next, replace //TODO: Add code #3 for text here with:

self.textAlpha = 0

And finally, replace //TODO: Add code #4 for text here with:

self.textScale = 1

Now, replace Text with the following:

Text("F           BER")
  .font(.largeTitle)
  .foregroundColor(.white)
  .opacity(textAlpha)
  .offset(x: 20, y: 0)
  .scaleEffect(textScale)

Build and run your app:

The text view is responding to the changes in the two new state variables with animations! How cool is that? :]

Now, all that’s left is to add the background and your awesome splash screen animation will be complete. Take a deep breath and jump into the last part of the animation.

Animating the Background

You’ll start by adding the background of the ZStack. Since it’s the background, it should be the view at the back of the stack, so it has to appear first in the code. To do this, add a new Image view as the first element of SplashScreen‘s ZStack:

Image("Chimes")
  .resizable(resizingMode: .tile)
  .opacity(textAlpha)
  .scaleEffect(textScale)

This uses the Chimes asset to make tiles that fill the entire screen. Note that you’re using textAlpha and textScale as state variables, so the view will change its opacity and scale whenever these state variables change. Since they already change to animate the “F ber” text, you don’t have to do anything else to activate them.

Build and run the app and you’ll see the background animating along with the text:

You now need to add the ripple effect that fades the background when the Fuber ‘U’ shrinks into a square. You’ll do that by adding a semi-transparent circle right above the background view, below all the other views. That circle will animate its way from the middle of the Fuber ‘U’ to cover the entire screen and hide the background. Sounds easy enough, right?

Add these two new state variables which the circle’s animation needs:

@State var coverCircleScale: CGFloat = 1
@State var coverCircleAlpha = 0.0

Then add this new view in the ZStack, right after the background image view:

Circle()
  .fill(fuberBlue) 
  .frame(width: 1, height: 1, alignment: .center)
  .scaleEffect(coverCircleScale)
  .opacity(coverCircleAlpha)

Now, you need to change the values of these state variables at the exact right moment to initiate the animation. Add this clause to runAnimationPart2(), right below self.squareScale = 1:

withAnimation(.easeOut(duration: self.fadeAnimationDuration)) {
  self.coverCircleAlpha = 1
  self.coverCircleScale = 1000
}

Finally, don’t forget to initialize the circle’s size and opacity when the animation is complete and is getting ready to restart. Add this to restartAnimation(), right before re-calling handleAnimations():

self.coverCircleAlpha = 0
self.coverCircleScale = 1

Now build and run your app and — drum roll please — Ta-Da! You’ve implemented the full, complex, totally-awesome animation you set out to implement. Give yourself a pat on the back. This wasn’t trivial, but you made it all the way through.

Now sit back, relax and move on to complete a couple of finishing touches that are important to remember, especially in a real app.

Finishing Touches

The animation you’ve made is pretty cool, but the way you’ve currently implemented it, the animation will keep repeating itself long after the splash screen has faded away.

This is far from great. You need to prevent the animation from restarting after the splash screen fades since it doesn’t make sense for it to continue past that point. The user won’t see it anyway, and it uses unnecessary resources.

To stop the animation from displaying longer than it needs to, add a new static variable to SplashScreen:

static var shouldAnimate = true

In handleAnimations(), wrap restartAnimation() with an if statement, which prevents it from starting over once this new Boolean is false. It should look like this:

if SplashScreen.shouldAnimate {
  restartAnimation()
}

Now, go back to ContentView.swift, uncomment the .onAppear closure you commented out at the beginning and set shouldAnimate to false. Then, just for fun, also change the second constant to 10 so you’ll get a chance to enjoy the beautiful splash screen animation you created. It should now look like this:

.onAppear {
    DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
      SplashScreen.shouldAnimate = false
      withAnimation() {
        self.showSplash = false
      }
    }
}

Build and run your app:

You should see your cool splash screen display for 10 seconds, followed by the app’s main map view. And the best part about it is that once the splash screen disappears, it no longer animates in the background, so your users are free to experience the app in all its glory without any background animations slowing them down. Sweet!

Where to Go From Here?

You can download the final Fuber project by using the Download Materials button at the top or bottom of this tutorial.

If you’d like to learn more about animations, check out iOS Animations by Tutorials.

Have an animation question? Want to post an amusing photo you used as a tiled background? Feel free to join the forum discussion below!