SwiftUI Custom Loading

abraao nascimento
5 min readSep 14, 2023

--

LoadingPacmanView

Introduction

I was studying SwiftUI in Swiftful Thinking channel on YouTube when I saw this video about shape and animation. Basically, Nick made a Pac-Man shaped animation and teaches how to configure it.

Based on what I learned, I started to question myself about what could I do to explore more this structure. So I started coding this Pac-Man with a custom path using .addArc with a startAngle of 30 degree and endAngle of 330 degree.

struct Pacman: Shape {
func path(in rect: CGRect) -> Path {
Path { path in
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addArc(center: CGPoint(x: rect.midX, y: rect.minY),
radius: rect.height / 2,
startAngle: Angle(degrees: 30),
endAngle: Angle(degrees: 330),
clockwise: false)
path.closeSubpath()
}
}
}

To test it, I created an instance of Pacman in a SwiftUI View.

struct SwiftUIView: View {
var body: some View {
VStack {
Pacman()
.fill(.yellow)
.aspectRatio(1.5, contentMode: .fit)
}
}
}

And this was the result:

After, I tried to create the ghost shape. To do so, I started making a rectangle shape using .addLine for the body; for the head I used the .addArch again to make half of a circle and for the feet I made some triangles using a looping with .addLines[…]. For the eyes I got some help with this Apple tutorial and found out how to make layers.

So, the structure for the Ghost body shape is:

struct GhostBodyShape: Shape {
var legsOffset: Double
var animatableData: Double {
get { legsOffset }
set { legsOffset = newValue }
}

func path(in rect: CGRect) -> Path {
Path { path in
let spacing = rect.width / 100
let headSize = (rect.width - (spacing * 2)) / 2
let legHeight = rect.width / 6
let height = (rect.width * 1.2) - headSize
let qtdTriangles = 3 // Number of legs
let rect = CGRect(x: rect.origin.x + spacing,
y: rect.origin.y + headSize / 2 + 10,
width: rect.width - (spacing * 2),
height: height)

// body
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))

// head
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addArc(center: CGPoint(x: rect.midX, y: rect.minY),
radius: headSize,
startAngle: Angle(degrees: 180),
endAngle: Angle(degrees: 0),
clockwise: false)

// legs
for i in 0..<qtdTriangles {
let base: CGFloat = rect.width / CGFloat(qtdTriangles)
let midX: CGFloat = base / 2
let next: CGFloat = base * CGFloat(i)
let modifier = 2.0 * legsOffset

path.addLines([
CGPoint(x: (rect.maxX - next), y: (rect.maxY)),
CGPoint(x: rect.maxX - (midX + next + modifier), y: rect.maxY - legHeight),
CGPoint(x: rect.maxX - (base + next), y: rect.maxY),
])
}
}
}
}

I also did a lot of Views to color and organize the layers. So if you want to see more about it, take a look to the repo.

This is the complete ghost result:

Animation

First I tried to center all animation in the LoadingView .onAppear, but some things like the track view had its own animation. To animate Pac-man, I used the animatableData to control the angle of the mouth. For the ghosts, I animated the legs and the eyes. To center all animation in one place, I passed by dependency some informations like eyeOffset, legsOffset, color…

Track View

To make the dots pass infinitely, I used some code that I saw in canopas blog and tried to apply it. I think I got a good result. Basically, what it does is to make a scrollView and change the offset. I love simple solutions 🥰.

struct InfiniteScroller<Content: View>: View {
var contentWidth: CGFloat
var content: (() -> Content)
@State var xOffset: CGFloat = 0

var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
content()
}
.offset(x: xOffset, y: 0)
}
.disabled(true)
.ignoresSafeArea()
.onAppear {
withAnimation(.linear(duration: 5).repeatForever(autoreverses: false)) {
xOffset = -contentWidth
}
}
}
}

And finally all the rest

Besides aligning all the characters in place, I did the logic that changes the ghost color and animation. So this it, a lot of variables and ternary conditions but it's working. I tried to make this as simple as possible.

struct LoadingView: View {
@State private var isAnimation = false
@State private var offset: CGFloat = 0
@State private var isReversed = false
@State private var name = GhostName.blinky

var body: some View {
ZStack {
GeometryReader { geometry in
TrackView()
.position(CGPoint(x: geometry.size.width / 2,
y: geometry.size.height / 2 - (20)))
}

GeometryReader { geometry in
let legOffset = isReversed ? 1.0 : -1.0
let ghostOffset = !isReversed ? (offset * 0.15) : 0
let pacmanOffset = isReversed ? (offset * 0.15) : 0
let distance = isReversed ? 40.0 : 105.0

Ghost(name: isReversed ? .crazyBlue : name,
eyeOffset: isReversed ? -1 : 1,
legsOffset: isAnimation ? legOffset : 0)
.frame(width: 40)
.position(CGPoint(x: Constants.initialPosition + offset + ghostOffset,
y: (geometry.size.height / 2) - 28.0))

Pacman(offsetAmount: isAnimation ? 20 : 0, isReversed: isReversed)
.fill(Constants.pacmanColor)
.frame(height: Constants.pacManSize)
.position(CGPoint(x: Constants.initialPosition + distance + offset + pacmanOffset,
y: geometry.size.height / 2))
}
}
.frame(maxWidth: .infinity, maxHeight: 60)
.aspectRatio(contentMode: .fit)
.onAppear {
withAnimation(Animation.easeInOut(duration: 0.25).repeatForever()) {
isAnimation.toggle()
}

animateRace()
}
}

func animateRace() {
withAnimation(Animation.linear(duration: Constants.duration)) {
offset = isReversed ? -100 : UIScreen.main.bounds.width + 70
}

DispatchQueue.main.asyncAfter(deadline: .now() + Constants.duration + 0.05) {
isReversed.toggle()
getNextGhost()
animateRace()
}
}

func getNextGhost() {
let name = GhostName.allCases.randomElement() ?? .blinky
guard name != .crazyBlue else {
getNextGhost()
return
}

self.name = name
}
}

extension LoadingView {
private enum Constants {
static let duration = 3.30
static let pacmanColor = Color(red: 1.0, green: 0.90, blue: 0.21)
static let pacManSize = 50.0
static let initialPosition = -60.0
}
}

And

Conclusion

I must say that use something like GCD to make a completion block is not the most secure thing to do. But I wanted to show you how things can be explored using simple structures and a lot of variables 😝.

I hope you like it. Please feel free to comment improvements here or in the repo page.

Special thanks for Leonardo Firmino Soa

Thanks!

--

--