🔌

SwiftUI でアイコン付き VStack を作る

2022/11/26に公開

Leading 側に Icon があり、他の View を VStack で整列するレイアウトはよく使いますね。こういうやつです。

HStack {
    Icon(url: ...)
    VStack {
        Text("Username").bold()
	Text("User Bio")
    }
}

HStack -> VStack とネストしていますが、頻出レイアウトなのでどう配置されるかはけっこう脳内でイメージができたりするので、これを簡単に記述する Custom View を作ってみます。

CellLayout {
    Icon(url: ...)
    Text("Username").bold()
    Text("User Bio")
}

Skeleton Image of CellLayout

このような CellLayout を作ります。(名前は何でもいいですが、ここでは List の Cell 1つをレイアウトするためのもの、という位置づけにしています)

こういうのは理想のAPIをどう実現するかを考えると、いろいろやることが増えてようやく面白い作業になりますね。

  • ネストが1つ浅くなります。これは意外とコードの読みやすさに影響します。
  • HStack -> VStack ではなくなるので、パラメーターが集約されます。それぞれに alignment や spacing を設定している場合、それらはこの View 全体のレイアウトのためのパラメーターであるにも関わらず、記述箇所が散らかってわかりにくい状態になります。CellLayout の引数に集約できれば、見通しが良くなりそうです。
  • デメリットもあります。当然ですが、Custom View は読み手にプロジェクトコードの知識を要求します。コメントを書きましょう。

最初のバージョン

さて、SwiftUI の VStack の亜種を作ればいいので、簡単ですね。嘘です。不可能です。VStack の内部実装が不明とか以前に、TupleView が使いづらすぎて終わっています。ていうか Swift の Tuple が使いにくいと言ったほうがいいのかもしれない。いや Tuple はどの言語でもこんなものかもしれない。これは推測ですが、SwiftUI には内部APIで Subview を Iterate する手段が存在しているような気がします。公開されている API では(私の知る限り)そういった手段は提供されていませんでした。

「でした」というのは、iOS16 から Layout がサポートされて、VStack のようなものを自作することができるようになったためです。ただ、iOS16 以降でのみ使えるものを作っても仕方がないので、今回は iOS 15 くらいで使えるコードを目指してみます。(実際には iOS14 から使えると思います)また、Layout を実際に使ってみたことはないです。

さて、目標とするDSLを実装するには resultBuilder を使う必要があります。resultBuilder は Swift の便利機能で、詳細は解説した記事がたくさんあるのでそちらを見てください。ViewBuilder では最終的に生成された View の「最初の1つ = CellLayout における Icon」を特別に取り出すことができないので、CellLayoutBuilder を新たに作ることにします。といってもスマートな実装はなさそうなので、泥臭くコードを書いていく必要があります。

struct CellLayout<Icon: View, Content: View>: View {
    
    let iconView: Icon
    let contentView: Content

    init(iconView: Icon, contentView: Content) {
        self.iconView = iconView
        self.contentView = contentView
    }
    
    var body: some View {
        /* ... */
    }
}

@resultBuilder
struct CellLayoutBuilder {

    static func buildBlock<V1>(_ v1: V1) -> (V1) where V1: View {
        (v1)
    }

    static func buildBlock<V1, V2>(_ v1: V1, _ v2: V2) -> (V1, V2) where V1: View, V2: View {
        (v1, v2)
    }

    static func buildFinalResult<V1>(_ c: (V1)) -> CellLayout<V1, EmptyView, EmptyView> where V1: View {
        .init(
            iconView: c,
            contentView: .init()
        )
    }

    static func buildFinalResult<V1, V2>(_ c: (V1, V2)) -> CellLayout<V1, V2, EmptyView> where V1: View, V2: View {
        .init(
            iconView: c.0,
            contentView: c.1
        )
    }
}

はい。一度 Tuple を作って、buildFinalResult で CellLayout に変換しています。これだと Icon と Content 1個しか対応していないですが、buildBlock / buildFinalResult を増やしていけばいくつでも対応できます。好きな数だけ増やしましょう。めんどくさいね。

Content が2個以上になるときは、TupleView にします。


    public static func buildBlock<V1, V2, V3>(_ v1: V1, _ v2: V2, _ v3: V3) -> (V1, V2, V3) where V1: View, V2: View, V3: View {
        (v1, v2, v3)
    }

    public static func buildFinalResult<V1, V2, V3>(_ c: (V1, V2, V3)) -> CellLayout<V1, TupleView<(V2, V3)>, EmptyView> where V1: View, V2: View, V3: View {
        .init(
            iconView: c.0,
            contentView: TupleView((c.1, c.2))
        )
    }

CellLayout の実装はこんな感じ。

public struct CellLayout<Icon: View, Content: View>: View {

    let iconView: Icon
    let contentView: Content

    public init(@CellLayoutBuilder builder: () -> Self) {
        self = builder()
    }

    init(iconView: Icon, contentView: Content) {
        self.iconView = iconView
        self.contentView = contentView
    }

    public var body: some View {
	HStack(alignment: .top, spacing: 8) {
	    iconView
	    VStack(alignment: .leading, spacing: verticalGap) {
		contentView
	    }
	    .frame(maxWidth: .infinity, alignment: .leading)
	}
    }
}

if Statement の対応

CellLayoutCellLayoutBuilder を使ったときに if Statement を使いたいですね。

@resultBuilder
struct CellLayoutBuilder {

    /* ... */

    public static func buildIf<V>(_ view: (V)?) -> V? where V: View {
        view
    }
}

resultBuilder に buildIf を定義すると、else のない if が使えるようになります。

buildIf の細かい挙動

buildIf の返り値は if Statement 全体の評価結果として使われます。if Statement の評価結果は、最終的に buildBlock で処理できなければいけません。

以下は具体的な説明。↑で理解できる人は読み飛ばして。

@resultBuilder
struct StringBuilder {

    static func buildBlock(_ compontns: String...) -> String {
        compontns
	    .joined(separator: "-")
    }

    static func buildIf(_ component: String?) -> String {
        component ?? ""
    }
}

extension String {

    init(@StringBuilder builder: () -> String) {
        self = builder()
    }
}

let result = String(builder: {
    "1"
    "2"
    if false {
        "3"
    }
})

print(result)

buildIf(_:String?) -> String で、buildBlock(_:String...) なので、result のブロック String, String, String は buildBlock で処理することができます。

例えば buildIf(_:String?) -> StringbuildIf(_:String?) -> String? とする(返り値を Optional にする)と、Value of optional type 'String?' must be unwrapped to a value of type 'String' が発生します。追従して buildBlock(_:String...)buildBlock(_:String?...) に変更すると、このエラーは消えます。

@resultBuilder
struct StringBuilder {

-    static func buildBlock(_ compontns: String...) -> String {
+    static func buildBlock(_ compontns: String?...) -> String {
         compontns
             .compactMap { $0 }
 	    .joined(separator: "-")
     }

+    static func buildIf(_ component: String?) -> String {
-    static func buildIf(_ component: String?) -> String? {
         component
     }
 }

buildIf の結果を含めた Block 全体がを受け入れられる buildBlock が存在していなければ、ビルドエラーになるというわけです。

これは言い換えれば、if Statement が以下のように変換されることを意味しています。

 String(builder: {
     "1"
     "2"
-    if isOn {
-        "3"
-    }
+    StringBuilder.buildIf(isOn ? "3" : nil)
 })

if-else Statement の対応

if-else Statement に対応するには buildEither を実装します。返り値として _ConditionalContent を使ってしまうのがシンプルですが、_ConditionalContent は将来的に変更されるかもしれないので、自作したバージョンを定義しておいてもいいかもしれません。ただ、こういう「パット見簡単そうなもの」はSDK内部で変なことをしていて同じ動作をするものを作るのが難しいことが少なくないので、今回は _ConditionalContent をそのまま使うことにします。

_ConditionalContentinit は internal なので、ViewBuilder を直接呼んでしまいます。

@resultBuilder
struct CellLayoutBuilder {

    /* ... */

    public static func buildEither<T, F>(first component: T) -> _ConditionalContent<T, F> where T: View, F: View {
        ViewBuilder.buildEither(first: component)
    }

    public static func buildEither<T, F>(second component: F) -> _ConditionalContent<T, F> where T: View, F: View {
        ViewBuilder.buildEither(second: component)
    }
}

_ConditionalContent<T, F> は T, F が共に View なら View となるようです。

buildEither の挙動

buildIf 同様に置き換え結果が buildBlock で評価されます。

 String(builder: {
     "1"
     "2"
-    if isOn {
-        "3"
-    } else {
-        "4"
-    }
+    isOn
+        ? StringBuilder.buildEither(first: "3")
+        : StringBuilder.buildEither(second: "4")
 })

buildEither(first:)buildEither(second:) の返り値は同じ型なので、3項演算子でこのように置き換えることができるようになっています。詳しく調べてはないですが、だいたいこんな感じのことをしてるっぽいです。

ただ、この StringBuilder の例だと _ConditionalContent を使う必要はないですね。

Accessory のサポート

iOS では Cell の Trailing 側に Accessory をつけることがあります。UITableViewCell の頃にもありましたね。

Skeleton Image of CellLayout with Accessory

黄色い部分です。

  • Vertical Alignment を設定できる。(デフォルトは .center
  • 最小の Width を占める。

を要件とします。

API

CellLayoutBuilder の最後の View を Accessory として使う、というアイディアは難しそうです。不可能ではないですが、buildBlock のパターンが2倍くらいに増えますね。(Accessory を struct で定義して View をラップする方法)めんどくさいので、Modifier を定義することにします。実際、Accessory は Optional なので、Modifier を使ったほうが直感に即した API になりそうです。

CellLayout {
    Icon(url: ...)
    Text("Username").bold()
    Text("User Bio")
}
.accessory { Image(systemName: "chevron.forward") }

こんなイメージ。

実装

struct CellLayout<Icon: View, Content: View, Accessory: View>: View {

    /* ... */

    @State var cellHeight: CGFloat? = nil

    private init(base: CellLayout<Icon, Content, EmptyView>, accessory: Accessory) {
        self.iconView = base.iconView
        self.contentView = base.contentView
        self.accessoryView = accessory
    }
    
    public var body: some View {
        HStack(alignment: .top, spacing: 8) {
            iconView
            VStack(alignment: .leading, spacing: 4) {
                contentView
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            accessoryView
                .frame(height: cellHeight)
        }
        .background(GeometryReader { geometry in
            Color.clear
                .onChange(of: geometry.size.height) { height in cellHeight = height }
                .onAppear { cellHeight = geometry.size.height }
        })
    }
}

extension CellLayout {

    func accessory<V>(alignment: VerticalAlignment = .center, @ViewBuilder content: @escaping () -> V) -> CellLayout<Icon, Content, some View> where V: View, Accessory == EmptyView {
        .init(base: self, accessory: CellAccessoryView(base: content(), alignment: alignment))
    }
}

private struct CellAccessoryView<BaseAccessory>: View where BaseAccessory: View {

    let base: BaseAccessory
    let alignment: VerticalAlignment

    var body: some View {
        base
            .frame(idealWidth: 0, maxHeight: .infinity, alignment: .init(horizontal: .center, vertical: alignment))
    }
}

長いね。順番に解説します。

.accessory Modifier

この手のメソッドって Modifier って呼んでいいのかな。厳密には ViewModifier 使ってないし Modifier ではない気がするが。Modifier Method?


extension CellLayout {

    func accessory<V>(alignment: VerticalAlignment = .center, @ViewBuilder content: @escaping () -> V) -> CellLayout<Icon, Content, some View> where V: View, Accessory == EmptyView {
        .init(base: self, accessory: CellAccessoryView(base: content(), alignment: alignment))
    }
}

CellLayout.accessory です。

VerticalAlignment は SwiftUI で定義されている垂直方向の整列条件の指定に使われている enum です。例えば HStackalignment などの型です。

CellLayout は CellLayout<Icon, Content> から CellLayout<Icon, Content, Accessory> に拡張しました。ただし、デフォルトでは Accessory == EmptyView となっています。

Accessory == EmptyView の場合のみ .accessory が使えるようになっています。二重に Accessory を設定するのを禁止したいのと、CellLayout の定義と Accessory の定義が離れるとコードが読みづらくなるためです。気にしないのなら適当にアレンジしてください。(アレンジする場合、Accessory を Environment などで伝播させるくらいしか方法を思いつかないです)

CellAccessoryView

private struct CellAccessoryView<BaseAccessory>: View where BaseAccessory: View {

    let base: BaseAccessory
    let alignment: VerticalAlignment

    var body: some View {
        base
            .frame(idealWidth: 0, maxHeight: .infinity, alignment: .init(horizontal: .center, vertical: alignment))
    }
}

Accessory の Wrapper です。alignment の情報を保持していて、垂直方向の任意の位置に Accessory を配置できます。CellLayout 側で .frame(height: cellHeight) しているので、高さが無限に大きくなることはありません。

Gap

CellLayout は水平方向・垂直方向に View を配置します。そのため、水平方向・垂直方向それぞれの spacing が外から指定できると便利です。verticalGap / horizontalGap を実装します。


public struct CellLayout<Icon: View, Content: View, Accessory: View>: View {

    /* ... */

    var horizontalGap: CGFloat
    var verticalGap: CGFloat

    init(horizontalGap: CGFloat = 8, verticalGap: CGFloat = 4, @CellLayoutBuilder builder: () -> Self) where Accessory == EmptyView {
        self = builder()
        self.horizontalGap = horizontalGap
        self.verticalGap = verticalGap
    }

    /* ... */

    var body: some View {
        HStack(alignment: .top, spacing: horizontalGap) {
            iconView
            VStack(alignment: .leading, spacing: verticalGap) {
                contentView
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            accessoryView
                .frame(height: cellHeight)
        }
	/* ... */
    }

適当でいいです。Icon / Body / Accessory の間隔をそれぞれ個別に指定するとか、そういうのは許さないAPIにしていますが、それを指定可能にしたいなら引数を増やすと良いと思います。

まとめ

最終的なコード。

https://gist.github.com/niaeashes/3e004f6f03633aea529953c3851f57e8

Discussion