[SwiftUI] Develop Layout With TDD

     
⌛️ 7 min.

In this post, I’ll propose how to test SwiftUI Layout.

Environment

Following are used for this post.

  • macOS14.3
  • Xcode 15.2
  • iOS 17.2
  • Swift 5.9

Overlapped Layout

When we want to show some views, usually we’ll arrange those views along horizontally or vertically.

HStack
import SwiftUI
import SDSLayout

struct ContentView: View {
    let colors: [Color] = [.red, .blue, .yellow, .gray, .orange]
    var body: some View {
        HStack {
            ForEach(colors, id: \.self) { color in
                color.frame(width: 60, height: 60)
            }
        }
        .padding()
    }
}

But if views increase in number, we want to have optimized layout.

In case with using VStack/HStack, it is possible to adjust with using spacing parameter.

With spacing parameter, we can have overlapped layout.

HStackWithSpacing
import SwiftUI
import SDSLayout

struct ContentView: View {
    let colors: [Color] = [.red, .blue, .yellow, .gray, .orange]
    var body: some View {
        HStack (spacing: -20) {
            ForEach(colors, id: \.self) { color in
                color.frame(width: 60, height: 60)
            }
        }
        .padding()
    }
}

Diagonal direction Layout

For vertical layout, VStack can be used. For horizontal layout, HStack can be used.
Currently no stack container for diagonal direction.

MEMO

From visual point of view, we can layout in diagonal direction with using HStack/VStack and offset.
but the area that layout declares to use is different from actual area that is used by layout…

So, let’s implement diagonal layout !

I hope we can arrange like followings in the end of this post.

DiagonalStackGoal

requirement

Let’s name the layout “DiagonalStack”. (maybe DStack in short, but “D” looks too short to understand what it means.)

Let’s define the requirements for the layout.

layout along diagonal direction

Like VStack/HStack, DiagonalStack will accept parameter hSpacing and vSpacing for horizontal/vertical spacing between views.

For other things, think while testing/developing.

What is Layout, how to test it

In SwiftUI world, we need to conform to Layout protocol to develop new Layout.
as mentioned earlier, let’s use TDD for this development.

But it is not easy to do the first unit test for Layout….

Followings are the issues we need to solve.

  • In SwiftUI, layout will be triggered by SwiftUI system. It is not disclosed how to trigger the layout.
  • Methods which is implemented in Layout will be called from SwiftUI. Especially some arguments are used/generated only in SwiftUI system. It is not disclosed to developer. example: LayoutSubview

In Layout, cache is defined/used to optimize layout calculation.
Looks like this is the only thing that developer can control.

So use this cache also for testing.

DiagnoalStack

First, let’s make DiagonalStack skeleton code, then check necessary methods one by one.

preparation

As first step, prepare for the testing.

Basic concepts are followings.

  • define class named DiagonalStackCache for storing calculation result for testing
  • testing will check the cache whether calculation was done as expected
  • check next values
    • value (CGSize) is returned from sizeThatFits
    • values(CGPoint) that placeSubviews used to place each child views

Note1 :
sizeThatFits might return different size depending on requested ProposedViewSize, so use dictionary([ProposedViewSize: CGSize]) to store necessary information.

Note2:
placeSubviews will place child views separately. So needs to store each location information for each child view.
But we can not distinguish each view with using LayoutSubview, so use layoutValue to distinguish.

Note3:
CGRect that placeSubviews will receive may not have (0,0) as origin.

Now we can define DiagonalStackCache as follows.

public class DiagonalStackCache {
    var sizeThatFit: [ProposedViewSize: CGSize] = [:]
    var locDic: [String: CGVector] = [:]
}

We need to refer above cache information even after layout is done.
So let’s have it in Layout structure.

Following code are skeleton code which conform to Layout protocol. (and have cache info in it)

public struct DiagonalStack: Layout {
    var hSpacing: CGFloat? = nil
    var vSpacing: CGFloat? = nil

    var cache: DiagonalStackCache

    public init(hSpacing: CGFloat? = nil, vSpacing: CGFloat? = nil) {
        self.hSpacing = hSpacing
        self.vSpacing = vSpacing
        self.cache = DiagonalStackCache()
    }

    public typealias Cache = DiagonalStackCache
    public func makeCache(subviews: Subviews) -> DiagonalStackCache {
        return self.cache
    }

    public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout DiagonalStackCache) -> CGSize {
        return .zero
    }

    public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews,
                              cache: inout DiagonalStackCache) {

    }

}

necessary methods

Implementing layout means implementing sizeThatFits and placeSubviews.

Develop layout along with TDD.

TDD with sizeThatFits

First, let’s implement sizeThatFits.

sizeThatFits needs to return necessary size for given condition.

test

Test topics are followings.

  • when laying out one view, layout should return child view’s size
  • when laying out two views, layout should return view’s size + spacing

Implemented test codes are as below.

  • when laying out one view, sizeThatFits will return view’s size
  • when laying out two views with “+” spacing, sizeThatFits will return sum of view’s size + spacing
  • when laying out two views with “-” spacing, sizeThatFits will return sum of view’s size – spacing
@MainActor
final class DiagonalStackTests: XCTestCase {
    func test_oneView() async throws {
        let sut = DiagonalStack()
        let view = sut {
            Color.blue.frame(width: 30, height: 30)
        }
        let _ = ImageRenderer(content: view).nsImage

        XCTAssertNotNil(sut.cache)
        XCTAssertEqual(sut.cache.sizeThatFit[.unspecified], CGSize(width: 30, height: 30))
    }

    func test_twoView_spacingPlus() async throws {
        let sut = DiagonalStack(hSpacing: 40, vSpacing: 40)

        let view = sut {
            Color.blue.frame(width: 30, height: 30)
            Color.red.frame(width: 30, height: 30)
        }
        let _ = ImageRenderer(content: view).nsImage

        XCTAssertNotNil(sut.cache)
        XCTAssertEqual(sut.cache.sizeThatFit[.unspecified], CGSize(width: 100, height: 100))
    }

    func test_twoView_spacingMinus() async throws {
        let sut = DiagonalStack(hSpacing: -20, vSpacing: -20)
        let view = sut {
            Color.blue.frame(width: 30, height: 30)
            Color.red.frame(width: 30, height: 30)
        }
        let _ = ImageRenderer(content: view).nsImage

        XCTAssertNotNil(sut.cache)
        XCTAssertEqual(sut.cache.sizeThatFit[.unspecified], CGSize(width: 40, height: 40))
    }

notes:

  • need to have DiagonalStack instance to access cache
  • To force to layout, need to generate image with using ImageRenderer. (note: nsImage is for macOS, use uiImage for iOS)
  • In this layout, sizeThatFits does not adjust return value depending on ProposedViewSize, so check the value for .unspecified

implementation

Implementing Layout is fairly simple. Add up each subview’s size including hspacing/vspacing values.

MEMO

PairIterator which is used in following code iterates collection’s 2 elements (at max).
You can see its implementation here

cache will be given as argument, put info just before return.

    public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout DiagonalStackCache) -> CGSize {
        var overAllSize: CGSize = .zero
        var viewIterator = PairIterator(subviews)

        while let (current, next) = viewIterator.next() {
            let currentSize = current.sizeThatFits(proposal)
            overAllSize.width  += currentSize.width
            overAllSize.height += currentSize.height

            if let next = next {
                if let hSpacing = hSpacing { overAllSize.width += hSpacing
                } else { overAllSize.width  += current.spacing.distance(to: next.spacing, along: .horizontal) }
                if let vSpacing = vSpacing { overAllSize.height += vSpacing
                } else { overAllSize.height += current.spacing.distance(to: next.spacing, along: .vertical) }
            }
        }
        OSLog.logger.debug("sizeThatFits returns \(overAllSize.debugDescription)")
        cache.sizeThatFit[proposal] = overAllSize
        return overAllSize
    }

With above implementation, tests will become green.

Note: ProposedViewSize can NOT become key for dictionary, because it does not conform hashable by default. Add following extension.

extension ProposedViewSize: Hashable {
    public func hash(into hasher: inout Hasher) {
        hasher.combine(width)
        hasher.combine(height)
    }
}

TDD with placeSubviews

Lastly implement placeSubviews.

placeSubviews will place each child views.

test

Test topics are followings.

  • when laying out one view, each view should have appropriate location
  • when laying out two views, each view should have appropriate location
MEMO

bounds(CGRect) which will be passed to placeSubviews may not have (0,0) as origin.

Implemented test codes are as below.

@MainActor
final class DiagonalStackTests: XCTestCase {

    func test_oneView() async throws {
        let sut = DiagonalStack()
        let view = sut {
            Color.blue.frame(width: 30, height: 30).layoutValue(key: LayoutInfo.self, value: "blue30")
        }
        let _ = ImageRenderer(content: view).nsImage

        XCTAssertEqual(sut.cache.sizeThatFit[.unspecified], CGSize(width: 30, height: 30))

        XCTAssertEqual(sut.cache.locDic["blue30"], CGVector(dx: 0, dy: 0))
    }

    func test_twoView_spacingPlus() async throws {
        let sut = DiagonalStack(hSpacing: 40, vSpacing: 40)
        let view = sut {
            Color.blue.frame(width: 30, height: 30).layoutValue(key: LayoutInfo.self, value: "blue30")
            Color.red.frame(width: 30, height: 30).layoutValue(key: LayoutInfo.self, value: "red30")
        }
        let _ = ImageRenderer(content: view).nsImage

        XCTAssertEqual(sut.cache.sizeThatFit[.unspecified], CGSize(width: 100, height: 100))

        XCTAssertEqual(sut.cache.locDic["blue30"], CGVector(dx: 0, dy: 0))
        XCTAssertEqual(sut.cache.locDic["red30"], CGVector(dx: 70, dy: 70))
    }

    func test_twoView_spacingMinus() async throws {
        let sut = DiagonalStack(hSpacing: -20, vSpacing: -20)
        let view = sut {
            Color.blue.frame(width: 30, height: 30).layoutValue(key: LayoutInfo.self, value: "blue30")
            Color.red.frame(width: 30, height: 30).layoutValue(key: LayoutInfo.self, value: "red30")
        }
        let _ = ImageRenderer(content: view).nsImage

        XCTAssertEqual(sut.cache.sizeThatFit[.unspecified], CGSize(width: 40, height: 40))

        XCTAssertEqual(sut.cache.locDic["blue30"], CGVector(dx: 0, dy: 0))
        XCTAssertEqual(sut.cache.locDic["red30"], CGVector(dx: 10, dy: 10))
    }
}

Note for tests:

  • bounds might not have (0,0) as origin, calc and keep offset value for each view from top-left corner
  • use LayoutInfo as layoutValue to record each layout coordinate of views.

Used LayoutInfo definition is as below.

struct LayoutInfo: LayoutValueKey {
    static let defaultValue: String = ""
}

implementation

Again implementation is fairly simple. almost same with sizeThatFits, but in placeSubviews, need to call place method to place each child views.

MEMO

In test code, separately defined operator “+” for CGPoint and CGVecror is used. but it is easy to imagine what it means.

as mentioned, bounds(CGRect) might not have (0,0) as origin. Needs to calc offset for each subviews (and store it in cache for testing).

    public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout DiagonalStackCache) {
        var pos: CGPoint = CGPoint(x: bounds.minX, y: bounds.minY)
        var offset: CGVector = CGVector(dx: 0, dy: 0)
        var viewIterator = PairIterator(subviews)

        while let (current, next) = viewIterator.next() {
            current.place(at: pos + offset, anchor: .topLeading, proposal: proposal)
            if current[LayoutInfo.self] != "" {
                cache.locDic[current[LayoutInfo.self]] = offset
            }

            OSLog.logger.debug("palce at \(pos.debugDescription)")

            let currentSize = current.sizeThatFits(proposal)
            offset.dx += currentSize.width
            offset.dy += currentSize.height

            if let next = next {
                if let hSpacing = hSpacing { offset.dx += hSpacing
                } else { offset.dx += current.spacing.distance(to: next.spacing, along: .horizontal) }
                if let vSpacing = vSpacing { offset.dy += vSpacing
                } else { offset.dy += current.spacing.distance(to: next.spacing, along: .vertical) }
            }
        }
    }

Consideration about Layout test

I believe it is recommended to make generalized layout and re-use it.

But in my cases, frequently my layout does not cover corner-cases. So I need to debug my layout at same time with developing app itself.
UnitTesting will improve the situation.

Every time I saw such situations, I tried to search how to test Layout. but no luck….
This time, I tried to find the way to test SwiftUI Layout by myself.

Basically cache is used to optimize layout calculation (especially to prevent redundant calculations). It is not for test.

In this test, we put various information for testing purpose. It means it will reduce performance.

But I believe we can get enough benefit from having unit testing for layout.
and if you have concerns, you can use “#if DEBUG” to remove test code from production code.
(In that case, you need to be careful that test code is a little bit different from production code.)

As of now, cache is the only space we(developer) can inject something in layout.
There might be better way to test Layout which I could not find. As always if you know better ways, please share it with me.

With a different approach, you can use snapshot-testing.
In that case, you might need to consider how to launch simulator for swift package (in case layout is defined in swift package).

Summary

In this post, explained how to develop Layout with TDD.

how to test Layout
  • It is not easy to apply unit test to Layout
  • Use ImageRenderer to trigger the layout
  • Use cache to validate layout behavior
  • In sizeThatFits/placeSubviews, put necessary information into cache to validate layout behavior from outside
  • Use layoutValue to associate subview and layout behavior values

Please feel free to contact me at X.

SwiftUI おすすめ本

Following books are really good to understand Swift/SwiftUI. (some are written in Japanese…)

SwiftUI ViewMatery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

Leave a Reply

Your email address will not be published. Required fields are marked *