The Matrix in SwiftUI
Today I want to share a weekend experiment where I built the classic Matrix effect in SwiftUI. It all started after seeing this tweet of the effect implemented in Compose. I immediately wondered what would it take me to build that in SwiftUI, so when I had some spare time in a weekend I gave it a go.
I like to do this kind of small experiments from now and then. It satisfies my curiosity, but doesn’t force me to focus on a single thing for a long time. I finish it before it gets boring :) Because of this you won’t see the most perfect code in here, but if you have some interesting improvements or alternative implementations share them ^^.
You can find the code in GitHub. Note that the linked tweet code uses a port of p5 for Compose. I’ve talked about something like this for Swift before in Processing and p5 in Swift, but I’m just using vanilla SwiftUI in this experiment.
A first idea for implementing this it’s using a Canvas
view. But, for some reason I don’t remember, I opted to use views for this, although I’m pretty sure using a Canvas
would be better. After all, the only layout I’m using is the VStack
for the columns. I’m calculating myself the layout of everything else so I don’t think I would lose much just dropping to the lower drawing level.
One of the nicest things about building something like this in SwiftUI is the TimelineView
. This view recomputes the provided view, providing an up to date Date
value, based on the refresh interval you specify. You don’t have to hook into any timer to do this, the view takes care of it for you. From the date, it’s quite easy to get a number (TimeInterval
) from any reference date. It doesn’t really matter which reference you use, since the only thing you care about is to have a number that is incremented as time passes. This is very reminiscent of how game engines work.
One thing I do often when working on these experiments is to try to drive the UI from data as much as possible. By this I mean to not use view state or anything like that. It makes it easier to write algorithms to compute the UI instead of having to mess around with the views themselves. That’s why I have a ColumnData
struct that has all the properties from the columns that I need to render.
struct ColumnData: Identifiable {
let id = UUID()
let characters: [Character]
let glyphSize: Int
let x: Double
let speed: Double
}
I also have a function that creates the data for all the columns that need to be rendered in a given width.
static func columns(_ proxy: GeometryProxy) -> [ColumnData] {
let estimatedColumnWidth = 26.0
let width = proxy.size.width
let count = Int(width / estimatedColumnWidth)
return (0..<count).map { i in
ColumnData(
proxy,
x: (Double(i) * estimatedColumnWidth) + (estimatedColumnWidth / 4)
)
}
}
Well, I lied a bit. The ColumnData
doesn’t contain all the data needed to render the column. There are a couple of pieces that are decided on the UI on the fly, and that’s because I want to change them during the lifetime of the animation.
This mix of approaches worked out nicely, but I really don’t recommend it for anything serious. I’m abusing SwiftUI’s lifecycle here. I generate the data inside the body function of the root view, which won’t be recomputed again.
struct ContentView: View {
var body: some View {
GeometryReader { proxy in
let columnsData = ColumnData.columns(proxy)
Matrix(data: columnsData, fullHeight: proxy.size.height)
}
.clipped()
}
}
Technically, this is recomputed if the window size changes. Which you can see happening on macOS.
This means that all the random generation is only run once. This makes up for the constant part of the data needed to draw the views. The part of the data that can’t change during the animation. But be careful because this is relying on internal decisions of the framework. If SwiftUI recomputes bodies I’m not expecting, the data won’t be stable anymore and you will see weird jumps on the screen.
The other parts, the dynamic bits that need to change, are all calculated inside the TimelineView
. Things like the y
coordinate of the column as shown here. Note how the x
position is defined in the constant data, but the y
is driven by a tick and the speed of the animation.
.offset(x: data.x)
.offset(y: (tick * data.speed).truncatingRemainder(dividingBy: fullHeight + columnHeight) - columnHeight)
Another part that changes outside the column data is the glyph. The column draws the provided list of glyphs except that sometimes one of the characters is changed for another random one. For fun.
TimelineView(.animation(minimumInterval: 0.5, paused: false)) { _ in
let pickRandom = Int.random(in: 0...100) < 2
Text(String(pickRandom ? glyphs.randomElement()! : character))
}
You can see how I’m using yet another TimelineView
, this one with its own interval. It’s quite a powerful technique to be able to compose views that have their own independent refresh intervals.
One thing that keeps getting me every time is how the offset
modifier is purely a rendering modifier. By that I mean it doesn’t affect the layout computation at all. It’s a quick way of moving things on the screen, but if you rely on layout considering those positions, you will be in trouble.
And that’s it. I just wanted to share this little and fun experiment with you. Some techniques are not desirable in production environments but they are quite fun to use in a weekend project ^^