Sponsor Link
環境&対象
- macOS14.3
- Xcode 15.2
- iOS 17.2
- Swift 5.9
重ねて表示する Layout
複数の要素を表示するときに、一般的には、並べて表示するのが普通です。
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 指定を調整することで、重ねて表示することが可能です。
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 を使用することで実現できるのですが、斜め方向を簡単に実現する方法がありません。
見た目的には、VStack/HStack と offset を組み合わせれば 斜め方向への配置もできますが、Layout が占有する領域は、offset によって調整されないため、VStack/HStack の周囲の要素との調整が難しくなってしまいます。
ということで(?)、斜め方向に重ねていく Layout を作ってみます。
以下のようなレイアウトが目標です。
要件
作りたいレイアウトの名前は、”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 の値をその間に入れて 計算していきます。
コードで使用している 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つ)のビューが渡されたとき、各ビューが 適切な位置に配置されるか
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 の 位置を計算して、配置していくだけです。
コードで、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 を テストする方法を説明しました。
- UnitTest でそのままするのは、難しい
- ImageRenderer 等を使用すると Layout を実際に行わせることができる
- cache にテストしたい項目を設定できるようにする
- sizeThatFits/placeSubviews で適宜 情報を設定する
- subview 毎に設定を確認するには、layoutValue を使用して 情報を関連づける
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
SwiftUI おすすめ本
SwiftUI を理解するには、以下の本がおすすめです。
SwiftUI ViewMatery
SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。
英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。
View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。
超便利です
販売元のページは、こちらです。
SwiftUI 徹底入門
# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。
Swift学習におすすめの本
詳解Swift
Swift の学習には、詳解 Swift という書籍が、おすすめです。
著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。
最新版を購入するのがおすすめです。
現時点では、上記の Swift 5 に対応した第5版が最新版です。
Sponsor Link