SwiftUI で幅が不揃いなセルのリストを実装する

この記事は [ドワンゴ Advent Calendar 2021](https://qiita.com/advent-calendar/2021/dwango) 11 日目の記事です。

## 書くこと

複数のタグをタイトルによって異なる長さで配置するような UI はよく見かけます。下記のような UI です。
<img width=300 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64850/b89000dc-9e06-23e3-2162-1d25b9d7d03e.png">
すなわち、配置される各々のセルのサイズが、内包するコンテンツの内容によって動的に定まるような UI です。
UIKit を採用している場合であれば `UICollectionView` にて `UICollectionViewLayout` を利用すれば、割と柔軟にレイアウトを構築することができます。一方、SwiftUI には `UICollectionViewLayout` のような仕組みはありません。そこで、今回は SwiftUI で上述したような UI を構築した話を書いていこうと思います。

## アプローチ

### 課題点

SwiftUI にて今回のようなタグ群を表示するために利用できそうなものとしては、`Stack` や `Grid` が考えられます。`Grid` は規則的なレイアウトを構築するのには向いていますが、セルの大きさが不規則なレイアウトの構築には向いていないため、今回は `Stack` を利用します。
UIKit では `UICollectionViewLayout` のような Layout オブジェクトにセルのサイズ計算を委譲して、Layout オブジェクトは適宜必要な情報を受け取りながらサイズ計算を行わせることができます。が、SwiftUI にはこの Layout オブジェクトに相当するような存在はなく、UI は宣言的に記述する必要があります。

- 各々のセルのサイズ計算をどのように行うか？
- サイズ計算結果をどのようにレイアウトに反映させるか？

あたりが課題となります。

### セルのサイズ計算結果をPreferenceKey経由で伝播する

`VStack` と `HStack` を利用する場合、各行にどれだけセルを配置するか？を計算する必要があり、そのためには、まず配置するセルのサイズ計算が必要です。セルのサイズ計算を行いたい場合、セルのリストを表示する親 View が、セルである子 View からその描画サイズを受け取ることができれば良さそうに思えます。

SwiftUI にて View の描画サイズを取得したい場合、[GeometryReader](https://developer.apple.com/documentation/swiftui/geometryreader) を利用する例がよく見られます。`GeometryReader` は、自身のサイズや座標情報を取得できる特殊な View です。サイズを取得したい View に対して、その `background` などに `GeometryReader` View を配置し、そこで View の frame サイズを取得することができます。
また、View Hierarchy の下流から上流に値を流すための仕組みとして、SwiftUI には [PreferenceKey](https://developer.apple.com/documentation/swiftui/preferencekey) が用意されています。下流では [preference(key:value:)](https://developer.apple.com/documentation/swiftui/list/preference(key:value:)) View Modifier を利用して値を set し、上流では [onPreferenceChange](https://developer.apple.com/documentation/swiftui/view/onpreferencechange(_:perform:)) で値を get できます。

この `GeometryReader` と `PreferenceKey` の仕組みを併用することで、子 View の描画サイズを親 View に伝播することができます。例えば、下記のような実装を用意します。

``` swift
private struct SizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}

public extension View {
    func onChangeFrame(_ block: @escaping (CGSize) -> Void) -> some View {
        background(
            GeometryReader {
                Color.clear
                    .preference(key: SizePreferenceKey.self, value: $0.size)
            }
        )
        .onPreferenceChange(SizePreferenceKey.self, perform: block)
    }
}
```

このアプローチを採用した先行事例があるので、詳しくは下記をご覧ください。

https://www.fivestars.blog/articles/flexible-swiftui/

この方法で、確かに動的にセルのサイズ計算結果を親 View で受け取り、`VStack` と `HStack` を組み合わせて目的のレイアウトを実現することは出来ます。

が、このアプローチだと、親 View に正しいレイアウトが反映できるのは子 View 側でサイズ計算が行われてからになってしまいます。例えば、タグのリストに新規タグを追加した時、「タグを追加した瞬間はタグのセルサイズは 0 あるいは不正な値となり、その後すぐにセルの計算が行われ、正しいセルのサイズで描画され直す」、といった流れとなります。基本的にはこれでも問題ないのですが、これだと **タグの新規追加時のアニメーションがうまく動かない** という問題があります。

SwiftUI でのアニメーションは、[matchedGeometryEffect](https://developer.apple.com/documentation/swiftui/view/matchedgeometryeffect(id:in:properties:anchor:issource:)) あたりを使うと自動でフレーム間の補完をおこなってくれますが、子 View にセルサイズの計算を任せてしまうと、セルの新規追加時の瞬間にはセルサイズは不正なので、うまくアニメーションしてくれないのです。

### 手動でセルのサイズを計算する

View の描画時のサイズは様々な要因によって変わるので、あまり手動で行いたくないものです。しかし、今回はタグの新規追加時のアニメーションも綺麗に行うために、事前にセルのサイズ計算を行うアプローチを考えてみました。SwiftUI の View のサイズをコード上で描画前に行う方法、というのは自分の調べたところなさそうだったので、頑張って計算する必要があります。

これらを事前に計算する場合、考慮に入れる必要があるのは下記です。

- Dynamic Type を考慮できるようにする
- ScaledMetric を考慮できるようにする

これらを考慮した実装をどのようにおこなっていけば良いのか、記述していきます。

## 実装

この記事内では、説明のために大幅に簡略化したコードのみを引用します。最終的な実装は下記にあるので、参考にしてみてください。

https://github.com/tasuwo/TagGrid

### セルの実装

今回実装するセルは、下記のような見た目を目指します。基本的には文字列(+絵文字)で構成され、選択状態を持ちます。最終的な実装では削除ボタンもサポートしていますが、本記事内では割愛します。

<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64850/28f8bed3-b0ce-290c-1b37-b6e3e6492359.png">

上記のようなセルの実装の一部を下記に示します。

``` swift
struct Tag {
    let id: UUID
    let name: String
    let count: Int
}

struct TagCell: View {
    private let tag: Tag
    private let isSelected: Bool

    @ScaledMetric private var padding: CGFloat = 8

    // 省略

    var body: some View {
        HStack(spacing: 0) {
            container
                .padding(.horizontal, padding * 3 / 2)
                .padding(.vertical, padding)
        }
        // 省略. 背景色や角丸の設定などを以後行う
    }

    private var checkmark: some View {
        Image(systemName: "checkmark")
            .font(.body)
            .aspectRatio(contentMode: .fit)
    }

    private var container: some View {
        if isSelected {
            checkmark
                .foregroundColor(.white)
                .padding([.trailing], 2)
        } else {
            // frameサイズを合わせるため、チェックマークをベースにする
            checkmark
                .foregroundColor(.clear)
                .overlay(
                    Text("#")
                        .font(size.font)
                        .padding(.all, 0)
                        .scaleEffect(1.2)
                )
                .padding([.trailing], 2)
        }

        HStack(spacing: 4) {
            Text(tag.name)
                .foregroundColor(isSelected ? .white : .primary)
                .font(.body)
                .lineLimit(1)

            Text("(\(tag.count))")
                .foregroundColor(isSelected ? .white : .secondary)
                .font(.body)
                .lineLimit(1)
        }
    }
}
```

### セルのサイズ計算

SwiftUI には View のサイズを事前に計算するための機能はありません。ので、今回は仕方なく UIKit の力を借りることにしました。まずは、Dynamic Type に対応するために、下記のような拡張を用意しておきます。

``` swift
import SwiftUI
import UIKit

extension Font {
    var uiFont: UIFont {
        return UIFont.preferredFont(forTextStyle: self.uiTextStyle)
    }

    var uiTextStyle: UIFont.TextStyle {
        switch self {
        case .largeTitle:
            return .largeTitle

        case .title:
            return .title1

        // 中略

        case .footnote:
            return .footnote

        case .body:
            fallthrough

        default:
            return .body
        }
    }
}
```

#### ラベルのサイズ計算

`Label` のサイズ計算には、`UILabel` を利用します。この時のポイントとしては、`adjustsFontForContentSizeCategory` を true に設定することです。これによって、その時々のフォントに応じたラベルサイズを計算することができます。

``` swift
extension String {
    func labelSize(withFont font: Font) -> CGSize {
        let label = UILabel()
        label.font = font.uiFont
        label.adjustsFontForContentSizeCategory = true
        label.text = self
        label.sizeToFit()
        return label.frame.size
    }
}
```

#### シンボルのサイズ計算

今回、セルの選択時のアイコンにチェックマークを利用しています。このチェックマークは `Image` ですが、そのサイズにはフォントが反映されるため、フォントに応じたシンボルのサイズ計算を行える必要があります。これは、以下のような手順で行います。

1. フォントサイズを反映したシンボルの UIImage を生成する
2. UIImage を内包した NSAttributedString を生成する
3. UILabel に NSAttributedString を設定し、サイズ計算を行う

シンボルにフォントなどを適用して UIImage を生成したい場合には、[UIImage.SymbolConfiguration](https://developer.apple.com/documentation/uikit/uiimage/symbolconfiguration) が利用できます。これを利用して UIImage を生成し、後は順次上記手順に則って生成を行い、最終的にラベルの時と同様にサイズ計算を行います。

``` swift
extension String {
    static func labelSizeOfSymbol(systemName: String, withFont font: Font) -> CGSize {
        let config = UIImage.SymbolConfiguration(font: font.uiFont)
        let image = UIImage(systemName: systemName, withConfiguration: config)

        let attachment = NSTextAttachment()
        attachment.image = image
        let string = NSMutableAttributedString(attachment: attachment)

        let label = UILabel()
        label.font = font.uiFont
        label.adjustsFontForContentSizeCategory = true
        label.attributedText = string
        label.sizeToFit()

        return label.frame.size
    }
}
```

#### paddingのサイズ計算

セルの上下左右や、アイコン&テキスト間の空白のサイズとして `padding` を利用していますが、その `padding` は今回 ScaledMetric として定義しています。DynamicType をサポートする場合、フォントサイズの大小にともなってスペースの大きさも動的に切り替わった方が良い場合が多いためです。
ScaledMetrics なプロパティの値は DynamicType により適用されているフォントサイズによって動的に決定されるので、セルのサイズ計算のためには、現在のフォントサイズに対する `padding` の値を取得できなくてはなりません。

``` swift
@ScaledMetric private var padding: CGFloat = 8
```

この計算のためには [UIFontMetrics](https://developer.apple.com/documentation/uikit/uifontmetrics) を利用することができます。

``` swift
extension CGFloat {
    func scaledValue(for font: Font) -> CGFloat {
        return UIFontMetrics(forTextStyle: font.uiTextStyle).scaledValue(for: self)
    }
}
```

#### セル全体のサイズ計算

ここまでのサイズ計算のための拡張を用いれば、事前にセルのサイズ計算を行うことが可能です。

``` swift
extension TagCell {
    static func preferredSize(tag: Tag) -> CGSize {
        let font = Font.body
        let padding = 8.scaledValue(for: font)

        let markSize = String.labelSizeOfSymbol(systemName: "checkmark", withFont: font)
        let tagNameSize = tag.name.labelSize(withFont: font)
        let countLabelSize = "(\(tag.count))".labelSize(withFont: font)

        let cellHeight = max(markSize.height, tagNameSize.height, countLabelSize.height) + padding * 2

        let bodyWidth = markSize.width + 2 + tagNameSize.width + 4 + countLabelSize.width
        let horizontalPadding = padding * 3 / 2
        var cellWidth = bodyWidth + horizontalPadding * 2

        return CGSize(width: cellWidth, height: cellHeight)
     }
}
```

### セルリストの実装

各セルのサイズが事前に計算できるようになったので、セルのサイズ計算を行い Stack を構築する親 View の実装を行います。リストの本体は、下記のように `ScrollView` と `Stack` を組み合わせます。

``` swift
ScrollView {
    VStack(alignment: .leading, spacing: self.spacing) {
        ForEach(calcRows(), id: \.self) { tags in
            HStack(spacing: self.spacing) {
                ForEach(tags) {
                    TagCell(tag: $0)
                }
            }
        }
    }
}
```

`calsRows()` では、タグのサイズ計算を行い、各行にどのタグを配置するか？を決定し、結果を配列で返します。

``` swift
func calcRows() -> [[Tag]] {
    var rows: [[Tag]] = [[]]
    var currentRow = 0
    var remainingWidth = self.availableWidth - self.inset * 2

    for tag in self.tags {
        let size = TagCell.preferredSize(tag: tag)
        let width = min(size.width, self.availableWidth - self.inset * 2)
        let cellSize = CGSize(width: width, height: size.height)

        if remainingWidth - (cellSize.width + spacing) >= 0 {
            rows[currentRow].append(tag)
        } else {
            currentRow += 1
            rows.append([tag])
            remainingWidth = self.availableWidth = self.inset * 2
        }

        remainingWidth -= (cellSize.width + self.spacing)
    }

    return rows
}
```

ここで必要とされている `availableWidth` は、タグを敷き詰められる最大幅です。この最大幅の取得には [こちら](#候補1-セルのサイズ計算結果をpreferencekey経由で伝播する) で話題にしていた PreferenceKey によるサイズの伝播を利用することにします。この `availableWidth` はタグリストを表示する View 全体のサイズを指しており、タグの新規追加時にはこのサイズは変動しないため、アニメーションは問題なく動作します。

``` swift
struct TagGrid: View {
    // 省略

    var body: some View {
        ZStack {
            // View 全体のサイズ計算のためだけに透明な View を配置する
            Color.clear
                .frame(height: 0)
                .onChangeFrame {
                    self.availableWidth = $0.width
                }

            // タグリストを配置する
            ScrollView {
                // 省略
            }
        }
    }
}
```

## 完成形

下記のようなタグのリストが実装できます。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">SwiftUI で横幅が不揃いなタグのリスト、やっといい感じにアニメーションするようになった。 <a href="https://t.co/AB9uhkfFGI">pic.twitter.com/AB9uhkfFGI</a></p>&mdash; たすを (@tasuwo_) <a href="https://twitter.com/tasuwo_/status/1447579106619453441?ref_src=twsrc%5Etfw">October 11, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

コードの完成形は下記にあります。

https://github.com/tasuwo/TagGrid