Creating an initial app loading screen is probably not thought about too much when developing iOS apps. Today we'll take a quick look at how we can create a relatively simple and elegant loading screen.

Example Loading View

We want to see your work! If you've built something using this tutorial, send us pics! Find us on Twitter @TrailingClosure, or email us at [email protected]

This tutorial is split into two parts:

  • Creating the ScaledMaskModifier
  • Creating the LoadingView

Create The ScaledMaskModifier

Our ScaledMaskModifier is going to be used to animate the display of our loading view. It takes a Mask as an input that will scale up over time to display the loading view. See below for a glimpse.

ScaledMaskModifier using a Circle as the mask

First, create a new ViewModifier called ScaledMaskModifier. We're going to add two variables to the class:

  1. mask - The Mask described earlier. It will grow over time to display the underlying loading view.
  2. progress - The value to keep track of where in the animation we are.

Here is the template of the struct so far:

import SwiftUI

struct ScaledMaskModifier<Mask: View>: ViewModifier {
    
    var mask: Mask
    var progress: CGFloat
    
    func body(content: Content) -> some View {
        content
    }

}

Next we need to calculate the height and width of the mask and then apply it to our content. The tricky thing here is we want to ensure that the mask will shape will grow to the full size of the content. Think of it similarly to how we use scaleToFill() on an Image.

  1. Create a function called calculateSize(geometry: GeometryProxy). This will return the size of the mask at it's maximum (at the end of the animation). It will figure out whether or not the width or height of the content is larger and return that as the size.
// Calculate Max Size of Mask
func calculateSize(geometry: GeometryProxy) -> CGFloat {
    if geometry.size.width > geometry.size.height {
        return geometry.size.width
    }
    return geometry.size.height
}
  1. Apply the mask to our content while using calculateSize(geometry:) and progress to scale the mask size over time.
import SwiftUI

struct ScaledMaskModifier<Mask: View>: ViewModifier {
    
    var mask: Mask
    var progress: CGFloat
    
    func body(content: Content) -> some View {
        content
            .mask(GeometryReader(content: { geometry in
                self.mask.frame(width: self.calculateSize(geometry: geometry) * self.progress,
                                height: self.calculateSize(geometry: geometry) * self.progress,
                                alignment: .center)
            }))
    }
    
    // Calculate Max Size of Mask
    func calculateSize(geometry: GeometryProxy) -> CGFloat {
        if geometry.size.width > geometry.size.height {
            return geometry.size.width
        }
        return geometry.size.height
    }

}

Create the LoadingView

Next up we'll put the ScaledMaskModifier to work by creating the LoadingView. Also inside the LoadingView is where we'll handle the animations.

  1. Create a new View and call it LoadingView. Inside you need to define 3 variables: one for the contents of the LoadingView, one for the progress of the animation, and another for the Y offset (bouncing animation). See below for an example.
import SwiftUI

struct LoadingView<Content: View>: View {

    var content: Content
    @Binding var progress: CGFloat
    @State var logoOffset: CGFloat = 0 //Animation Y Offset
    
    var body: some View {
        content
    }
}
  1. Next, we'll need to apply the ScaledMaskModifier to the content. Here we used a Circle as the mask, but really you could use any Shape or View such as a Rectangle or any other custom SwiftUI View.
var body: some View {
    content
        .modifier(ScaledMaskModifier(mask: Circle(), progress: progress))
}
  1. In addition the ScaledMaskModifier we will make the content bounce up and down. To do this, we're going to animate its y offset.
var body: some View {
    content
        .modifier(ScaledMaskModifier(mask: Circle(), progress: progress))
        .offset(x: 0, y: logoOffset)
}
  1. Time to add the animations. Here we'll animate the content's progress change from 0 to 1, while at the same time bouncing it up and down.
import SwiftUI

struct LoadingView<Content: View>: View {

    var content: Content
    @Binding var progress: Double
    @State var logoOffset: CGFloat = 0 //Animation Y Offset
    
    var body: some View {
        content
            .modifier(ScaledMaskModifier(mask: Circle(), progress: progress))
            .offset(x: 0, y: logoOffset)
            .onAppear {
                withAnimation(Animation.easeInOut(duration: 1)) {
                    self.progress = 1.0
                }
                withAnimation(Animation.easeInOut(duration: 0.4).repeatForever(autoreverses: true)) {
                    self.logoOffset = 10
                }
            }
    }
}

Creating an Example

Now we're going to put our LoadingView to the test in an example. See below:

Here we've created a fake HomeView that is displayed once the loading is finished. As you can see we use the variable doneLoading to decide which vie to render on screen.

For the LoadingView I've passed in a Image that will be used as the content of the loading screen.

Finally, if you're curious as to why I've used the onAppear() function, it is so I can simulate the asynchronous nature of loading data for your app.

import SwiftUI

struct ContentView: View {

    @State var progress: CGFloat = 0
    @State var doneLoading: Bool = false
    
    var body: some View {
        ZStack {
            if doneLoading {
                HomeView()
                    .transition(AnyTransition.opacity.animation(.easeInOut(duration: 1.0)))
            } else {
                LoadingView(content: Image("PathMaskLogo-Dark")
                                        .resizable()
                                        .scaledToFit()
                                        .padding(.horizontal, 50),
                            progress: $progress)
                    // Added to simulate asynchronous data loading
                    .onAppear {
                        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                            withAnimation {
                                self.progress = 0
                            }
                            DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
                                withAnimation {
                                    self.doneLoading = true
                                }
                                
                            }
                        }
                    }
            }
        }
    }
}
Example Use

Support Future Tutorials Like This One!

Please consider subscribing using this link. If you aren't reading this on TrailingClosure.com, please come check us out sometime!

We want to see your work! If you've built something using this tutorial, send us pics! Find us on Twitter @TrailingClosure, or email us at [email protected]