Greetings, traveler!
Mascot characters work well in apps that need a little warmth. They are especially useful when the app has quiet moments: an empty state, a waiting screen, a habit tracker, a pet project, a learning app, a finance app with onboarding, or anything that could otherwise feel too static. A small animated character can make the interface feel alive without turning the whole app into a game.
This example shows one way to build that kind of character in SwiftUI. The goal is simple: show a mascot, play an idle animation by default, and let the user trigger short actions such as smoking, blinking, waving, eating, sleeping, or reacting to a tap.
The approach is frame-by-frame animation. You prepare several image assets, keep track of the current frame, and switch images over time.
It is not the most advanced animation system. That is the point.
When this approach is enough
Frame-by-frame animation is a good fit when the character has a small number of states.
For example:
idle: 5 frames
first action: 13 frames
second action: 6 framesThis works nicely when the character mostly stays in one place and performs short animations. You get full visual control because every frame is drawn by hand or exported from a design tool. SwiftUI only needs to display the correct image at the correct time.
This approach is also easy to debug. If frame 7 looks wrong, you open the asset named action-7 and fix it. There is no physics engine, no scene graph, no texture atlas requirement, and no game loop to manage.
For many app mascots, that is enough.
When SpriteKit is a better choice
SpriteKit makes more sense when the character becomes part of a real interactive scene.
Use SpriteKit if you need collisions, physics, particles, camera movement, many moving sprites, tile maps, game-like input, or a scene that runs independently from normal SwiftUI layout. SpriteKit also gives you a mature animation model for texture sequences, actions, nodes, and timing.
SwiftUI is a better fit when the mascot is part of the interface. SpriteKit is a better fit when the mascot is part of a game world.
There is overlap, of course. You can embed SpriteKit inside SwiftUI with SpriteView. But if the mascot just needs to idle, blink, react, and return to its default state, a small SwiftUI implementation is easier to maintain.
The basic model
The character has three states:
private enum AnimationKind {
case idle
case firstAction
case secondAction
}Each state has its own frames:
private var idleFrames: [Image]
private var firstActionFrames: [Image]
private var secondActionFrames: [Image]And each frame sequence has its own animator:
@State private var idleAnimator: FrameAnimator
@State private var firstActionAnimator: FrameAnimator
@State private var secondActionAnimator: FrameAnimatorThe view uses the current state to decide which image to show.
- When the character is idle, it reads the frame from
idleAnimator. - When the first action is running, it reads from
firstActionAnimator. - When the second action is running, it reads from
secondActionAnimator.
That gives us a tiny state machine.
Creating the frame animator
The FrameAnimator is responsible for one thing: moving from frame to frame over time.
It does not know anything about the character. It does not know about buttons, images, or SwiftUI layout. It only tracks the current frame.
import SwiftUI
@MainActor
@Observable
final class FrameAnimator {
var frame: Int = 1
@ObservationIgnored
private var animationTask: Task<Void, Never>?
private let totalFrames: Int
private let interval: TimeInterval
private let loopPause: TimeInterval
init(
interval: TimeInterval,
totalFrames: Int,
loopPause: TimeInterval = 0
) {
self.interval = max(0, interval)
self.totalFrames = max(1, totalFrames)
self.loopPause = max(0, loopPause)
}
func start(
repeats: Bool = true,
onCompletion: (() -> Void)? = nil
) {
stop()
animationTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
await self.sleep(for: self.interval)
guard !Task.isCancelled else { return }
if self.frame >= self.totalFrames {
if repeats {
self.frame = 1
await self.sleep(for: self.loopPause)
guard !Task.isCancelled else { return }
} else {
self.animationTask = nil
onCompletion?()
return
}
} else {
self.frame += 1
if !repeats, self.frame >= self.totalFrames {
self.animationTask = nil
onCompletion?()
return
}
}
}
}
}
func stop() {
animationTask?.cancel()
animationTask = nil
}
func reset() {
stop()
frame = 1
}
private func sleep(for duration: TimeInterval) async {
guard duration > 0 else { return }
let nanoseconds = UInt64(duration * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
}
}The start method supports two modes.
For idle animation, we use the default:
animator.start()For a one-time action, we pass repeats: false:
animator.start(repeats: false) {
// Action finished
}The reset method only stops the task and returns to the first frame. It does not restart the animation. That keeps the call sites predictable.
Building the animated character view
Now we can build the SwiftUI view.
struct AnimatedCharacterView: View {
private enum AnimationKind {
case idle
case firstAction
case secondAction
}
private var idleFrames: [Image]
private var firstActionFrames: [Image]
private var secondActionFrames: [Image]
private var actionFinishPause: TimeInterval = 1
private var firstActionTitle: LocalizedStringKey = "First action"
private var secondActionTitle: LocalizedStringKey = "Second action"
@State private var idleAnimator: FrameAnimator
@State private var firstActionAnimator: FrameAnimator
@State private var secondActionAnimator: FrameAnimator
@State private var type: AnimationKind = .idle
@State private var finishActionTask: Task<Void, Never>?
init(
idleFrames: [Image] = .defaultIdleFrames,
firstActionFrames: [Image] = .defaultFirstActionFrames,
secondActionFrames: [Image] = .defaultSecondActionFrames,
idleInterval: TimeInterval = 0.08,
firstActionInterval: TimeInterval = 0.1,
secondActionInterval: TimeInterval = 0.1,
idleLoopPause: TimeInterval = 4
) {
let idleFrames = idleFrames.isEmpty ? [Image(systemName: "photo")] : idleFrames
let firstActionFrames = firstActionFrames.isEmpty ? idleFrames : firstActionFrames
let secondActionFrames = secondActionFrames.isEmpty ? idleFrames : secondActionFrames
self.idleFrames = idleFrames
self.firstActionFrames = firstActionFrames
self.secondActionFrames = secondActionFrames
let idleInterval = max(0, idleInterval)
let firstActionInterval = max(0, firstActionInterval)
let secondActionInterval = max(0, secondActionInterval)
let idleLoopPause = max(0, idleLoopPause)
idleAnimator = FrameAnimator(
interval: idleInterval,
totalFrames: idleFrames.count,
loopPause: idleLoopPause
)
firstActionAnimator = FrameAnimator(
interval: firstActionInterval,
totalFrames: firstActionFrames.count
)
secondActionAnimator = FrameAnimator(
interval: secondActionInterval,
totalFrames: secondActionFrames.count
)
}
var body: some View {
VStack {
currentImage
.resizable()
.interpolation(.none)
.scaledToFit()
.onAppear {
guard type == .idle else { return }
idleAnimator.start()
}
.onDisappear {
finishActionTask?.cancel()
finishActionTask = nil
idleAnimator.stop()
firstActionAnimator.stop()
secondActionAnimator.stop()
}
HStack {
Button(firstActionTitle) {
startAction(.firstAction, animator: firstActionAnimator)
}
Button(secondActionTitle) {
startAction(.secondAction, animator: secondActionAnimator)
}
}
}
}
}
The initializer accepts timing values because those values are needed when we create the animators. Other settings, like button titles and the pause after an action, can be configured with small builder-style functions.
extension AnimatedCharacterView {
func actionFinishPause(_ value: TimeInterval) -> Self {
var copy = self
copy.actionFinishPause = max(0, value)
return copy
}
func firstActionTitle(_ value: LocalizedStringKey) -> Self {
var copy = self
copy.firstActionTitle = value
return copy
}
func secondActionTitle(_ value: LocalizedStringKey) -> Self {
var copy = self
copy.secondActionTitle = value
return copy
}
}This keeps the initializer from turning into a long list of every possible setting.
Choosing the current frame
The view needs to convert the current animation state into an image.
private extension AnimatedCharacterView {
var currentImage: Image {
switch type {
case .idle:
idleFrames[frameIndex(for: idleAnimator.frame, in: idleFrames)]
case .firstAction:
firstActionFrames[frameIndex(for: firstActionAnimator.frame, in: firstActionFrames)]
case .secondAction:
secondActionFrames[frameIndex(for: secondActionAnimator.frame, in: secondActionFrames)]
}
}
func frameIndex(for frame: Int, in frames: [Image]) -> Int {
min(max(frame - 1, 0), frames.count - 1)
}
}The animator uses frame numbers starting at 1, while arrays use indexes starting at 0. That is why frameIndex subtracts one.
The method also clamps the value. If something goes out of range, the view still uses the closest valid frame instead of crashing.
Starting an action
The character should not start a second action while another one is already running. This is handled by a simple guard:
private extension AnimatedCharacterView {
func startAction(_ animationKind: AnimationKind, animator: FrameAnimator) {
guard type == .idle else { return }
idleAnimator.stop()
animator.reset()
type = animationKind
animator.start(repeats: false) {
finishAction(animationKind, animator: animator)
}
}
}The idle animation stops. The selected action resets to the first frame. The view switches to the selected state. Then the action animator plays once.
When it reaches the last frame, it calls finishAction.
Returning to idle
After an action finishes, it is often better to keep the last frame on screen for a short moment. Otherwise the character can snap back too quickly.
private extension AnimatedCharacterView {
func finishAction(_ animationKind: AnimationKind, animator: FrameAnimator) {
animator.stop()
guard type == animationKind else { return }
finishActionTask?.cancel()
finishActionTask = Task { @MainActor in
await sleep(for: actionFinishPause)
guard !Task.isCancelled, type == animationKind else { return }
type = .idle
idleAnimator.reset()
idleAnimator.start()
}
}
func sleep(for duration: TimeInterval) async {
guard duration > 0 else { return }
let nanoseconds = UInt64(duration * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
}
}The extra task gives us a small pause after the action. It is also cancelled in onDisappear, so the view does not continue updating after it leaves the screen.
Providing default frames
For a small demo, it is convenient to define default frames by asset name.
extension Array where Element == Image {
static let defaultIdleFrames = (1...5).map {
Image("idle-\($0)")
}
static let defaultFirstActionFrames = (1...13).map {
Image("first-action-\($0)")
}
static let defaultSecondActionFrames = (1...6).map {
Image("second-action-\($0)")
}
}Using the view
The final API stays small.
AnimatedCharacterView(
idleInterval: 0.08,
firstActionInterval: 0.1,
secondActionInterval: 0.1,
idleLoopPause: 4
)
.actionFinishPause(1)
.firstActionTitle("Wave")
.secondActionTitle("Blink")
.padding()This creates a mascot with an idle loop and two button-triggered actions.
The idle animation repeats with a pause between loops. Each action plays once, holds the last frame for a short moment, and returns to idle.
Why this works well in SwiftUI
This implementation fits SwiftUI because the character is still part of normal UI layout.
The image is just a view. The buttons are just buttons. The state is local to the component. When the current frame changes, SwiftUI redraws the current image.
There is no need to introduce SpriteKit if the animation is only a small visual element inside the interface. SwiftUI can handle this kind of mascot well, especially when there is only one character on screen.
The code also stays easy to change. Add more frames, tune the interval, rename the buttons, or replace the assets. The model does not fight you.
Final thoughts
A mascot does not need a full game engine by default.If the character is there to make an app feel more personal, a small frame-by-frame SwiftUI implementation can be enough. It gives you predictable timing, simple state transitions, and a clean place to add more actions later.
SpriteKit is still the right tool when the character becomes part of a real scene. But for a UI mascot that idles, reacts, and returns to its default state, SwiftUI is a surprisingly comfortable place to start.
This example is available on my GitHub.
