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!
Leave a Comment