Level Up Coding

Coding tutorials and news. The developer homepage gitconnected.com && skilled.dev && levelup.dev

Follow publication

SwiftUI: Caching in Layout Protocol

By Chat GPT, Dalle 3

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 of subviews.
  • Optionally implement the updateCache(subviews:). This method is called when changes are detected. The default implementation is recreating the cache by calling makeCache above.
  • Update the type of the cache parameter in sizeThatFits and placeSubviews, and remove the calculations but use cache 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

  1. Update the cache parameter type
  2. Calculate radius if nil and store it in cache
  3. 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

  1. Only calculating the radius on the first placeSubviews
  2. 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!

No responses yet

Write a response