[SwiftUI] Layout を TDD で開発する(DiagonalStack)

SwiftUI2021

     
⌛️ 5 min.

Layout をテストする方法を考えます。重ねるような表示となる Layout を TDD で作ってみます。

環境&対象

以下の環境で動作確認を行なっています。

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

重ねて表示する Layout

複数の要素を表示するときに、一般的には、並べて表示するのが普通です。

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()
    }
}

ですが、要素数が多くなってくると 少し重ねて表示してスペースを節約したくなる時もあります。

そのようなときには、縦方向であれば VStack、横方向であれば HStack で spacing 指定を調整することで、重ねて表示することが可能です。

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()
    }
}

斜めに重ねていく Layout

縦方向・横方向であれば、VStack/HStack を使用することで実現できるのですが、斜め方向を簡単に実現する方法がありません。

MEMO

見た目的には、VStack/HStack と offset を組み合わせれば 斜め方向への配置もできますが、Layout が占有する領域は、offset によって調整されないため、VStack/HStack の周囲の要素との調整が難しくなってしまいます。

ということで(?)、斜め方向に重ねていく Layout を作ってみます。

以下のようなレイアウトが目標です。

DiagonalStackGoal

要件

作りたいレイアウトの名前は、”DiagonalStack” とします。

以下、要件を考えてみます。

斜め方向にレイアウトするということ

VStack/HStack のように spacing を指定して 要素を重ねてレイアウトするために、hSpacing, vSpacing という横方向/縦方向それぞれの spacing を指定できるようにします。

それ以外は、作りながら(汗)、考えることにします。

テストできるということ

Layout プロトコルに準拠したレイアウトを作っていきますが、最初に書いたように、TDD で作っていきます。

ですが、まず テストできるようにするのが大変です。

Layout に準拠したレイアウトは、そのまま単純にはテストできません。

以下の課題があります。

  • レイアウトをトリガーするのは、SwiftUI のシステムであり、レイアウトを単体でトリガーする仕組みが公開されていない
  • Layout を実装した struct の各メソッドを呼び出すのは、SwiftUI 側であり 個別に呼び出す方法は公開されていない
  • 特に Layout 各メソッドに渡される引数は LayoutSubview 等 であり、インスタンスを生成することが難しく、SwiftUI からしか渡すことができない

唯一、外部(開発者)からコントロールできそうなのが、レイアウト計算に使用できるように用意されている cache という仕組みです。

この cache を使って テストを作ってみます。

DiagnoalStack を定義

まずは、DiagonalStack を定義して、実装が必要なメソッドを確認していきます。

準備

最初にテストできるような準備をします。

方針としては、以下です。

  • DiagonalStackCache なる class を定義して、レイアウト計算結果を記録する
  • テスト/TDD では、その cache に記録された結果を見て 期待通りの結果になっているかを確認する
  • テスト対象としたいのは以下の結果(必要に応じて、追加削除予定・・・)
    • sizeThatFits で返した値(CGSize)
    • placeSubviews での各ビューの配置位置(CGPoint)

sizeThatFits では、引数として与えられた ProposedViewSize に応じて返す値が異なることが考えられますので、
[ProposedViewSize: CGSize] という Dictionary で記録することにします。

placeSubviews では、当然ですが、ビューごとに配置位置が変わるのでビューごとに記録することが必要です。

が、placeSubviews では、ビューを識別することはできないので、layoutValue で外部からビューに 付与したパラメータをキーに記録することにします。
(placeSubviews では、与えられた CGRect 内に配置していきますが、CGRect は、(0,0) を原点に持たないこともあるため、オフセット値である CGVector を持つようにしています。)

上記のテスト対象を保持できるような cache を以下のように定義しました。

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

上記を、テスト側からも参照できるように Layout 内部に持つようにします。

以下は、cache に関連する 実装だけを行なったものです。(sizeThatFits/placeSubviews は未実装です)

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) {

    }

}

実装が必要なメソッド

いつも通り(?)、sizeThatFits と placeSubviews を実装することが、Layout の実装です。

順番に、テストを作りながら実装していきます。

TDD with sizeThatFits

最初は、sizeThatFits です。

要求された状況で、必要なサイズを返すメソッドです。

テスト

テストとしては、以下を考えます。

  • 1つのビューが 渡されたとき、そのビューの持つサイズが Layout のサイズになっているか
  • 複数(2つ)のビューが渡されたとき、各ビューのサイズ+spacing で指定されたサイズになっているか

以下のようなテストにしました。

  • ビューが1つの時は、ビューのサイズが、レイアウトの返すサイズになる
  • ビューが2つの時には、ビューのサイズ+spacing の値が 返すサイズになる
  • ビューが2つで、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))
    }

以下は、テストコードで考慮した点です。

  • DiagonalStack が保持する cache にアクセスしたいので、一度 DiagonalStack をインスタンス化して変数に持つ
  • Layout を行わせるために、ImageRenderer を使って、image を生成させる(上記は、macOS でのテストですが、iOS では、uiImageを取得することになります)
  • 今回は、ProposedViewSize に応じて返す値を調整する予定はないので、.unspecified についての返り値を調べる

実装

実装は、単純です。順番に subview の必要サイズを足し合わせ、hspacing, vspacing の値をその間に入れて 計算していきます。

MEMO

コードで使用している PairIterator は、コレクション内の要素を 最大2つづつ iterate する Iterator です。
実装は、こちらで公開してます。

cache は、引数として渡されていますので、return 直前に 情報を設定しています。

OSLog も入れていますが、ログなので、適宜 無視してください。

    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
    }

上記の実装で、先のテストはパスします。

sizeThatFits のテストでは、個別のビューについての計算途中の確認をしていませんが、必要ならば、layoutSubviews で使用しているような layoutValue を使って ビューと計算結果を関連づけた情報を cache に保存することになります。

なお、ProposedViewSize は Hashable ではないために、Dictionary の Key になれません。ですので、以下のように extension で Hashable を追加しました。

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

TDD with placeSubviews

次に、placeSubviews を実装します。

placeSubviews は サブビューを配置していくメソッドです。

テスト

テストとしては、以下を考えます。

  • 1つのビューが 渡されたとき、そのビューが 適切な位置に配置されるか
  • 複数(2つ)のビューが渡されたとき、各ビューが 適切な位置に配置されるか
MEMO

placeSubviews に渡されてくる bounds は、(0,0) を原点にもたないかもしれないので、実際には、オフセット相当の値を確認することが必要です。

以下のようなテストにしました。

@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))
    }
}

以下、テストコードで考慮した点です。
・placeSubviews に与えられる CGRect は、(0,0) 原点ではないこともあるため、左上の点からのオフセット量(CGVector) の値を確認しています
・どのビューをどの位置に配置したかを組み合わせて記録するために、LayoutInfo という layoutValue を設定して、ビューの識別をしています
・DiagonalStack 自身を変数に持つことや Layout を行わせるために、ImageRenderer を使っている点は、sizeThatFits のテストで説明した通りです

使用した LayoutInfo の定義は以下です。

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

実装

実装は、単純です。順番に subview の 位置を計算して、配置していくだけです。

MEMO

コードで、CGPoint と CGVector を + で和をとっていますが、別ライブラリの extension で定義しています。

placeSubviews に渡される CGRect は、(0,0) が原点であることが確定していないので、offset を 各ビューの配置位置として記録し、テスト対象としています。
OSLog も入れていますが、ログなので、適宜 無視してください。

    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("place 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) }
            }
        }
    }

Layout テストについての考察等

汎用的(だと自分で思っている)レイアウトを作って、レイアウトも 一般化して再利用できるようにするのが好きです。

ですが、コーナーケースをカバーできておらず、アプリを作りながら レイアウトもデバッグするというケースが何回かあり、ユニットテストの必要性をその都度 感じてました。

困るたびに、毎回 検索するのですが、良い方法が見つからず・・・ということで、改めて テスト方法を考えてみました。

本来はLayout 内部で使用される cache は、レイアウト計算の効率向上のための仕組みであり、テスト向けではありません。
なので、すこし無理矢理感があります。

また、効率向上のための cache に テスト向けの情報を入れているので、パフォーマンス的には落ちる方向になってしまっているのも、気になる点ではあります。

ただ、個人的には、”テストできる” というメリットが 十分にそれを上回ると感じています。
気になる方は、#if DEBUG 等で、製品コードに含まれないようにすると、安心できるかもしれません。
(その場合は、製品コードとテストコードで異なるコードが動作するという問題も発生してしまいますが。)

ちなみに、cache を使ったのは、sizeThatFits と placeSubviews の両方に共通するもので 外部から手を入れられそうなのが、cache だけに見えたので使っています。
言い換えると、他の方法を思いつけなかっただけです・・・良い方法ありましたら、教えてください。

別案として、レイアウト結果を画像と考えて テストする snapshot-testing を使用することも考えられます。

その場合は、Layout を Swift Package 化することを考える場合、どのように simulator を使用するかを考えないといけません。

まとめ

TDD 的に開発することが可能となるように Layout を テストする方法を説明しました。

Layout を テストする方法
  • UnitTest でそのままするのは、難しい
  • ImageRenderer 等を使用すると Layout を実際に行わせることができる
  • cache にテストしたい項目を設定できるようにする
  • sizeThatFits/placeSubviews で適宜 情報を設定する
  • subview 毎に設定を確認するには、layoutValue を使用して 情報を関連づける

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

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版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です