SwiftUI でアイコン付き VStack を作る
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")
}
このような 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 の対応
CellLayout
で CellLayoutBuilder
を使ったときに 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?) -> String
を buildIf(_: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
をそのまま使うことにします。
_ConditionalContent
の init
は 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 の頃にもありましたね。
黄色い部分です。
- 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 です。例えば HStack
の alignment
などの型です。
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にしていますが、それを指定可能にしたいなら引数を増やすと良いと思います。
まとめ
最終的なコード。
Discussion