SwiftUI: Caching in Layout Protocol

In this article, let’s do a little dive into caching in the Layout
protocol.
I assume that you know the basics of Layout
protocols here and how to implement a custom layout. If not, feel free to check out my previous article SwiftUI: Step 0 to Layout Protocol for a quick catch up!
Caching in Layout protocol can be MORE THAN just performance improvement .
Of course, it can server as a data store to keep the values we want to reuse in different functions between different calls, but there are also other interesting(?) ways of using it thanks to it being an inout
parameter in the sizeThatFits
and placeSubviews
methods!
You can grab the demo code from my GitHub!
Let’s get started!
Usage 1: Data Store
Since it is a cache, let’s start with what it should do, acting as a storage for saving values that’s shared among the methods of a particular layout instance.
Let’s check it out with a simple Circular Radial Stack.
Set Up
We will be starting with a Layout
with no caching.
import SwiftUI
struct SimpleRadialStack: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
proposal.replacingUnspecifiedDimensions()
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
print("placing subviews")
let sizes = sizes(subviews)
let maxSize = maxSize(sizes)
let radius = min(bounds.size.width, bounds.size.height)/2 - max(maxSize.width, maxSize.height)/2
let angle = Angle.degrees(360.0 / Double(subviews.count)).radians
for (index, subview) in subviews.enumerated() {
var point = CGPoint(x: 0, y: -radius)
.applying(CGAffineTransform(
rotationAngle: angle * Double(index) ))
point.x += bounds.midX
point.y += bounds.midY
subview.place(at: point, anchor: .center, proposal: .unspecified)
}
}
private func maxSize(_ sizes: [CGSize]) -> CGSize {
let maxSize: CGSize = sizes.reduce(.zero) { currentMax, subviewSize in
CGSize(width: max(currentMax.width, subviewSize.width),
height: max(currentMax.height, subviewSize.height))
}
return maxSize
}
private func sizes(_ subviews: Subviews) -> [CGSize] {
let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
return subviewSizes
}
}
Let’s check it out really quick.
struct RadialStackDemo: View {
private let images = ["sun.max.fill", "moon.fill", "star.fill", "cloud.fill", "bolt.fill", "wind.snow", "snowflake", "rainbow"]
@State private var padding: CGFloat = 32
var body: some View {
VStack {
SimpleRadialStack {
ForEach(images, id: \.self) { image in
makeImage(image)
}
}
.padding(.all, padding)
VStack {
Text("Padding: \(String(format: "%.1f", padding))")
Slider(value: $padding, in: 16...128, step: 8.0, onEditingChanged: {editing in
if editing {
print("padding changed")
}
})
}
.padding(.all, 32)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.gray.opacity(0.2))
}
private func makeImage(_ name: String) -> some View {
Image(systemName: name)
.resizable()
.scaledToFit()
.frame(width: 32, height: 32)
.foregroundColor(.white.opacity(0.8))
.padding(.all, 8)
.background(RoundedRectangle(cornerRadius: 4).fill(.mint.opacity(0.8)))
}
}

If you take a look at the console, you will see that every time the padding changed, placeSubviews
will be called twice which means angle
and radius
will be recalculated.
padding changed
placing subviews
placing subviews
padding changed
placing subviews
placing subviews
I meant, of course, it should the recalculated the radius when we change the padding since that will change the container size, but not for twice!
Also, there is no point to recalculate angle in this case.
Basic Steps
Here are the steps we have in order to add caching to our Layout.
Steps:
- Define a
CacheData
type for the storage. - Implement
makeCache(subviews:)
to create the cache for a set ofsubviews
. - Optionally implement the
updateCache(subviews:)
. This method is called when changes are detected. The default implementation is recreating the cache by callingmakeCache
above. - Update the type of the cache parameter in
sizeThatFits
andplaceSubviews
, and remove the calculations but usecache
instead.
Define Cache Data
Three parameters, the radius
of the stack, the angle
between the subviews, and the subviews’ count
.
struct CacheData {
var radius: CGFloat?
var angle: CGFloat
var count: Int
}
Note that our radius
is an optional of CGFloat
here. This is because we will not know the radius by the time we create our CacheData
in makeCache(subviews:)
since it will depend on the container size.
Also, you might be wondering why do we need the count
here. It seems like we are not really re-using it anywhere? Keep the question somewhere in your mind and the answer will come in the next*2 section.
Implement makeCache
Pretty straightforward!
Just moving what we used to have in placeSubviews
here.
func makeCache(subviews: Subviews) -> CacheData {
print("makeCache")
let count = subviews.count
let angle = Angle.degrees(360.0 / Double(count)).radians
return CacheData(radius: nil, angle: angle, count: count)
}
When will we calculate our actual radius
and store it? We will do that in the placeSubviews
only if the current value is nil
in couple seconds.
Implement updateCache
As I have mentioned above, this method is optional with a default implementation to recall makeCache
.
However, I do want to implement it here.
Reason: We will only recalculate the angle
if the subviews’ count
changes. (Yes! This is why we are keeping the current count in our storage!)
We will set the radius
back to nil
anyway though since there is no way for us to tell if the container size is changed at this point.
func updateCache(_ cache: inout CacheData, subviews: Subviews) {
print("update Cache")
cache.radius = nil
if cache.count != subviews.count {
print("update angle")
let count = subviews.count
cache.count = count
cache.angle = Angle.degrees(360.0 / Double(count)).radians
}
}
Update sizeThatFits and placeSubviews
Since we are not making any calculations in sizeThatFits
, all we have to do is to change the type of the cache
parameter.
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) -> CGSize {
proposal.replacingUnspecifiedDimensions()
}
In our placeSubviews
, there are couple things we need to do
- Update the
cache
parameter type - Calculate
radius
ifnil
and store it incache
- Update the calculations with
cache
parameters
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) {
print("place subview")
if cache.radius == nil {
print("calculating radius")
let sizes = sizes(subviews)
let maxSize = maxSize(sizes)
let radius = min(bounds.size.width, bounds.size.height)/2 - max(maxSize.width, maxSize.height)/2
cache.radius = radius
}
guard let radius = cache.radius else { return }
let angle = cache.angle
for (index, subview) in subviews.enumerated() {
var point = CGPoint(x: 0, y: -radius)
.applying(CGAffineTransform(
rotationAngle: angle * Double(index) ))
point.x += bounds.midX
point.y += bounds.midY
subview.place(at: point, anchor: .center, proposal: .unspecified)
}
}
*****************
That’s it for implementing the cache.
Let’s try our example out again and this time, we should get the following print out.
makeCache
place subview
calculating radius
place subview
padding changed
update Cache
place subview
calculating radius
place subview
As you can see, we are
- Only calculating the radius on the first
placeSubviews
- Not recalculating the
angle
even if padding is changed
Usage 2: Communicate Information DownStream
What do I mean by that?
Let’s use a SpiralStack
as an example here.

If we think about it, when we layout our subviews, we can basically call placeSubviews
recursively for the number of element we want in each layer with a smaller radius.
And this radius will be the information we pass downstream!
Let’s see the full code first.
(I am too lazy to implement the updateCache
here…Forgive me for that!)
struct SpiralStack: Layout {
var elementPerLayer: Int = 24
var spiralPadding: CGFloat = 4.0
struct CacheData {
var radius: CGFloat?
var maxSize: CGSize
var angle: CGFloat
var radiusChange: CGFloat
}
func makeCache(subviews: Subviews) -> CacheData {
let sizes = sizes(subviews)
let maxSize = maxSize(sizes)
let radiusChange = (max(maxSize.width, maxSize.height) + spiralPadding) / Double(elementPerLayer)
return CacheData(radius: nil, maxSize: maxSize, angle: Angle.degrees(360.0 / Double(elementPerLayer)).radians, radiusChange: radiusChange)
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) -> CGSize {
proposal.replacingUnspecifiedDimensions()
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) {
if cache.radius == nil {
let radius = min(bounds.size.width, bounds.size.height)/2 - max(cache.maxSize.width, cache.maxSize.height)/2
cache.radius = radius
}
guard let radius = cache.radius else { return }
let maxSize = cache.maxSize
let count = elementPerLayer
let angle = cache.angle
let radiusChange = cache.radiusChange
for (index, subview) in subviews[0..<min(subviews.count, count)].enumerated() {
var point = CGPoint(x: 0, y: -radius+radiusChange*Double(index))
.applying(CGAffineTransform(
rotationAngle: angle * Double(index) ))
point.x += bounds.midX
point.y += bounds.midY
subview.place(at: point, anchor: .center, proposal: .unspecified)
}
let saveCache = cache
cache.radius = radius - (max(maxSize.width, maxSize.height) + spiralPadding)
if subviews.count > count {
placeSubviews(in: bounds, proposal: proposal, subviews: subviews[count..<subviews.count], cache: &cache)
}
cache = saveCache
}
private func maxSize(_ sizes: [CGSize]) -> CGSize {
let maxSize: CGSize = sizes.reduce(.zero) { currentMax, subviewSize in
CGSize(width: max(currentMax.width, subviewSize.width),
height: max(currentMax.height, subviewSize.height))
}
return maxSize
}
private func sizes(_ subviews: Subviews) -> [CGSize] {
let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
return subviewSizes
}
}
Couple takeaway here!
One!
We are only iterating through and placing the elements we want for each layer in each placeSubviews
call.
for (index, subview) in subviews[0..<min(subviews.count, count)].enumerated() {
//...
}
Two!
We save the current cache
in saveCache
and update cache
with the new radius
.
let saveCache = cache
cache.radius = radius - (max(maxSize.width, maxSize.height) + spiralPadding)
Three!
We then call placeSubviews
recursively with the remaining views (if there is any) and the updated cache
.
if subviews.count > count {
placeSubviews(in: bounds, proposal: proposal, subviews: subviews[count..<subviews.count], cache: &cache)
}
Four!
We restore the cache
with the one we saved.
cache = saveCache
This is super important to make sure that when we call placeSubviews
from the start next time, we still get the correct behavior.
*****************
Spiral Time!
struct SpiralStackDemo: View {
private let images = Array.init(repeating: "star.fill", count: 64)
@State private var padding: CGFloat = 32
var body: some View {
VStack {
SpiralStack {
ForEach(images, id: \.self) { image in
makeImage(image)
}
}
.padding(.all, padding)
VStack {
Text("Padding: \(String(format: "%.1f", padding))")
Slider(value: $padding, in: 16...96, step: 8.0, onEditingChanged: {editing in
if editing {
print("padding changed")
}
})
}
.padding(.all, 32)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.gray.opacity(0.2))
}
private func makeImage(_ name: String) -> some View {
Image(systemName: name)
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
.foregroundColor(.white.opacity(0.8))
.padding(.all, 8)
.background(Circle().fill(.mint.opacity(0.8)))
}
}

Thank you for reading!
That’s all I have for today!
Happy Spiraling!