loading animation swiftui

Creating a Loading Animation with SwiftUI: Introducing ScrimLoader

Are you looking to add some eye-catching loading animations to your SwiftUI app? Look no further! In this tutorial, we’ll explore how to create a sleek and stylish loading animation called “ScrimLoader” using SwiftUI.

The ScrimLoader consists of a smooth transition between two phases, making it an engaging visual element to keep your users entertained while they wait for content to load.

Introduction to ScrimLoader

The ScrimLoader is a custom SwiftUI View that displays a horizontal progress bar-like animation. It has two distinct phases, each with its unique visual effects:

First Phase

In the first phase, a gray-colored capsule gradually changes its opacity, creating a pulsing effect.

This phase is designed to give a subtle visual cue that something is happening in the background.

The duration of this phase is set to 2 seconds, ensuring a smooth and visually appealing fade-in and fade-out pattern.

Second Phase

Once the first phase completes, the animation enters the second phase.

Here, a black-colored capsule appears and starts trimming (revealing) a portion of the gray capsule underneath it.

This gives the impression that the loader is being filled.

The second phase is further divided into three parts, each lasting 0.25 seconds, making the progression appear smooth and seamless.

Implementing the ScrimLoader

To begin creating the ScrimLoader, open your Xcode project and create a new SwiftUI View named “ScrimLoader”.

This view will represent the loading animation and handle the phases and timing logic.

// Import SwiftUI

struct ScrimLoader: View {
    // ViewModel manages the animation state
    @StateObject private var viewModel = ViewModel()

    var body: some View {
        // The loading animation components go here
        // ...
    }

    // View-related code and helper methods
    // ...
}

The ViewModel: Managing Animation State

The ScrimLoader utilizes a ViewModel class to manage the animation state. This class is an ObservableObject, which means it can publish changes to its properties, and SwiftUI will automatically update the views that depend on these properties.

// ViewModel to manage the animation state

private final class ViewModel: ObservableObject {
    @Published var startDate: Date?
    
    // Computed property to check if the animation is paused
    var animationPaused: Bool { startDate == nil }
    
    // Method to start the loading animation
    func startLoading() { startDate = .now }
    
    // Method to stop the loading animation
    func stopLoading() { startDate = nil }
}

Phase Timing and Opacity

The first phase of the animation manages the opacity of the gray capsule. It fades in and out smoothly, creating a pulsing effect.

The second phase determines the trimming of the black capsule to make it appear as if the loader is filling up.

The trackOpacity method calculates the opacity of the gray capsule during the first phase.

The opacity oscillates based on the elapsed time, creating the pulsing effect.

// Helper method to calculate opacity during the first phase
private func trackOpacity(elapsed: TimeInterval) -> CGFloat {
    guard elapsed < FirstPhase.duration else { return 0.25 }
    let opacity = elapsed.truncatingRemainder(dividingBy: FirstPhase.opacityFadeDuration) / FirstPhase.opacityFadeDuration
    return Int(elapsed / FirstPhase.opacityFadeDuration).isMultiple(of: 2) ? opacity : 1 - opacity
}

The Second Phase: Trimming the Capsule

In the second phase, the LeadingToTrailingRectangle shape is used to trim the black capsule, revealing the gray capsule beneath it.

The from and to properties of this shape control the trimming range, giving the impression of a progress bar being filled.

// Shape to represent the trimming of the black capsule
private struct LeadingToTrailingRectangle: Shape {
    let from: CGFloat
    let to: CGFloat

    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: .init(x: rect.minX + rect.width * from, y: rect.minY))
        path.addLine(to: .init(x: rect.minX + rect.width * to, y: rect.minY))
        path.addLine(to: .init(x: rect.minX + rect.width * to, y: rect.maxY))
        path.addLine(to: .init(x: rect.minX + rect.width * from, y: rect.maxY))
        path.closeSubpath()
        return path
    }
}

Putting It All Together

Now that we have the ViewModel managing the animation state and the helper methods for the phases, we can integrate them into the ScrimLoader view.

// ScrimLoader: Integrating the animation components

struct ScrimLoader: View {
    // ...
    var body: some View {
        VStack {
            TimelineView(.animation(paused: viewModel.animationPaused)) { context in
                // First Phase: Gray Capsule with opacity animation
                // ...
                // Second Phase: Black Capsule with trimming animation
                // ...
            }
            .frame(width: 80, height: 4)

            HStack {
                Button("Start") { viewModel.startLoading() }
                Button("Stop") { viewModel.stopLoading() }
            }
        }
    }
    // ...
}

As a complete example for those who don’t want to discover and learn, and want a ready-to-use sample, here it is:

import SwiftUI

struct ScrimLoader: View {
    @StateObject private var viewModel = ViewModel()

    var body: some View {
        VStack {
            TimelineView(.animation(paused: viewModel.animationPaused)) { context in
                let elapsed = context.date.timeIntervalSince(viewModel.startDate ?? .now)

                ZStack {
                    Capsule(style: .continuous)
                        .fill(.gray)
                        .opacity(trackOpacity(elapsed: elapsed))

                    let isInSecondPhase = elapsed >= FirstPhase.duration
                    let secondPhaseRemaining = elapsed - FirstPhase.duration
                    let trimStart: CGFloat = {
                        let elapsed = secondPhaseRemaining.truncatingRemainder(dividingBy: SecondPhase.duration)
                        guard ![SecondPhase.first, .third].map(\.range).contains(where: { $0.contains(elapsed) }) else { return 0 }
                        return (elapsed - SecondPhase.second.range.lowerBound) / SecondPhase.second.range.lowerBound
                    }()

                    let trimEnd: CGFloat = {
                        let elapsed = secondPhaseRemaining.truncatingRemainder(dividingBy: SecondPhase.duration)
                        guard !SecondPhase.third.range.contains(elapsed) else { return 0 }
                        guard !SecondPhase.second.range.contains(elapsed) else { return 1 }
                        return elapsed / SecondPhase.first.range.upperBound
                    }()

                    Capsule(style: .continuous)
                        .fill(.black)
                        .clipShape(LeadingToTrailingRectangle(from: trimStart, to: trimEnd))
                        .opacity(isInSecondPhase ? 1 : 0)
                }
            }
            .frame(width: 80, height: 4)

            HStack {
                Button("Start") { viewModel.startLoading() }
                Button("Stop") { viewModel.stopLoading() }
            }
        }
    }

    private enum FirstPhase {
        static let opacityFadeDuration: TimeInterval = 0.5
        static let duration: TimeInterval = 2
    }

    private enum SecondPhase {
        case first, second, third

        var range: ClosedRange<TimeInterval> {
            switch self {
            case .first: return (0...0.25)
            case .second: return (0.25...0.5)
            case .third: return (0.5...0.75)
            }
        }

        static var duration: TimeInterval { Self.third.range.upperBound }
    }

    private func trackOpacity(elapsed: TimeInterval) -> CGFloat {
        guard elapsed < FirstPhase.duration else { return 0.25 }
        let opacity = elapsed.truncatingRemainder(dividingBy: FirstPhase.opacityFadeDuration) / FirstPhase.opacityFadeDuration
        return Int(elapsed / FirstPhase.opacityFadeDuration).isMultiple(of: 2) ? opacity : 1 - opacity
    }

    private final class ViewModel: ObservableObject {
        @Published var startDate: Date?
        var animationPaused: Bool { startDate == nil }
        func startLoading() { startDate = .now }
        func stopLoading() { startDate = nil }
    }
}

private struct LeadingToTrailingRectangle: Shape {
    let from: CGFloat
    let to: CGFloat

    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: .init(x: rect.minX + rect.width * from, y: rect.minY))
        path.addLine(to: .init(x: rect.minX + rect.width * to, y: rect.minY))
        path.addLine(to: .init(x: rect.minX + rect.width * to, y: rect.maxY))
        path.addLine(to: .init(x: rect.minX + rect.width * from, y: rect.maxY))
        path.closeSubpath()
        return path
    }
}

Conclusion

Congratulations! You have successfully created a beautiful and engaging loading animation named “ScrimLoader” using SwiftUI.

The animation’s two phases, along with the smooth transitions, will surely delight your users and make the waiting time more enjoyable.

Feel free to customize the colors, durations, or add more phases to suit your app’s style and requirements. SwiftUI offers endless possibilities for creating stunning animations that enhance the user experience.

I hope you found this tutorial helpful in understanding how to build animations in SwiftUI. Happy coding!