【SwiftUI】外観カスタマイズ可能なタグリストの実装

# 概要
フロントエンドやアプリによくあるフレーム幅に合わせて良い感じに改行してくれるタグリストをSwiftUIで実装したかったのですが、既存のライブラリではやれることに限度があったのでより柔軟にUIを実装できるライブラリを作成しました。

https://github.com/chitomo12/SwiftUICustomTagListView

![IMG_A5504AD6EFF9-1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245143/2b3da2d7-c4f2-43e9-4cb6-dfe31dce2da3.jpeg)
※画像はマダミスアプリUZUより。Flutter製なのであくまでイメージです。

## 完成物 ＆ 使い方

#### インストール

##### Cocoapods
```
pod 'SwiftUICustomTagListView'
```

##### SwiftPackageManager
`Xcode` -> `File` -> `Add Packages...` -> `SwiftUICustomTagListView` or `https://github.com/chitomo12/SwiftUICustomTagListView` で検索 -> `Add Package`

##### 実装サンプル

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245143/8654d34e-8fa1-678c-8401-8beb99db4dd8.png" width="220">

```swift
import SwiftUICustomTagListView

struct SampleView: View {

    let data: [SampleTagViewData] = [
        .init(text: "#Technology", color: Color(hex: "#ff4d4d")),
        .init(text: "#News", color: Color(hex: "#b33636")),
        .init(text: "#Politics", color: Color(hex: "#ff944d")),
        .init(text: "#Breaking", color: Color(hex: "#ff4dd3")),
        .init(text: "#Global", color: Color(hex: "#b33693")),
    ]
    
    var views: [SwiftUICustomTagView<SampleTagView>] {
        self.data.map { data in
            SwiftUICustomTagView(content: {
                SampleTagView(data: data)
            })
        }
    }
    
    var body: some View {
        SwiftUICustomTagListView(views, horizontalSpace: 8, verticalSpace: 8)
            .frame(width: 240, height: 220)
    }
}

// MARK: - Define your own component
struct SampleTagView: View {
    
    let data: SampleTagViewData
    
    var body: some View {
        Text(data.text)
            .font(.title2)
            .onTapGesture {
                print("[Pressed] \(data.text)")
            }
            .foregroundColor(.white)
            .padding(.all, 8)
            .background(LinearGradient(
                gradient: Gradient(colors: [data.color, data.color.opacity(0.6)]),
                startPoint: .top,
                endPoint: .bottom))
            .cornerRadius(7)
    }
}

struct SampleTagViewData {
    let text: String
    let color: Color
}
```

## 実装

タグのサイズ、位置を算出し、改行するかなどの判定は`.alignmentGuide`モディファイアを使って実現しています。Reona様の[「タグを自動的に改行させる」](https://reona.dev/posts/20200929)のコードを土台に作成しました。

https://reona.dev/posts/20200929

`alignmentGuide`はViewの位置をそのView自体のサイズに合わせて操作することのできるモディファイアです。詳しい説明はここでは割愛します。

```swift
// ref: https://reona.dev/posts/20200929
private func generateTags(_ geometry: GeometryProxy,
                          views: [SwiftUICustomTagView<Content>]) -> some View {
    var width = CGFloat.zero
    var height = CGFloat.zero
 
    return ZStack(alignment: .topLeading) {
        ForEach(views, id: \.self) { view in
            view
                .padding(.trailing, horizontalSpace)
                .alignmentGuide(.leading, computeValue: { dimension in
                    if abs(width - dimension.width) > geometry.size.width {
                        width = 0
                        height -= dimension.height + verticalSpace
                    }
                    let result = width
                    if view == views.last {
                        width = 0
                    } else {
                        width -= dimension.width
                    }
                    return result
                })
                .alignmentGuide(.top, computeValue: { dimension in
                    let result = height
                    if view == views.last {
                        height = 0
                    }
                    return result
                })
        }
    }
}
```


ライブラリのコアにあたる`SwiftUICustomTagListView`では、ジェネリックなViewを包括した`SwiftUICustomTagView`の配列を受け取り、これを上記`generateTags`で一つのZStackに集約することで先の添付画像のようなタグの並びを実現しています。

```SwiftUICustomTagListView.swift
/// A SwiftUI view that presents a customizable tag list.
public struct SwiftUICustomTagListView<Content: View>: View {
    
    /// An array of Tag View
    private let tagViews: [SwiftUICustomTagView<Content>]
    /// Horizontal space between each tag
    private let horizontalSpace: CGFloat
    /// Vertical space between each tag
    private let verticalSpace: CGFloat
    
    @State private var listHeight: CGFloat = 0
    
    public init(_ views: [SwiftUICustomTagView<Content>],
                horizontalSpace: CGFloat,
                verticalSpace: CGFloat) {
        self.tagViews = views
        self.horizontalSpace = horizontalSpace
        self.verticalSpace = verticalSpace
    }
    
    public var body: some View {
        GeometryReader { geometry in
            generateTags(geometry, views: tagViews)
                .background(GeometryReader { geo in
                    Color(.clear)
                        .onAppear {
                            self.listHeight = geo.size.height
                        }
                })
        }
        .frame(height: listHeight)
    }
    ...
```

## おまけ
そこそこ汎用的なライブラリになっている気がしたので、思い切ってCocoapods、Swift-Package-Indexにも登録してみました。これについては巷にHowTo記事が沢山あるので参考した記事の紹介に留めます（どちらもuhooi様の記事です🙏）

#### Cocoapodsへの公開
https://qiita.com/uhooi/items/5ce382cb0c1c6bc018d9#%E3%83%88%E3%83%A9%E3%83%B3%E3%82%AF%E3%82%92%E3%82%BB%E3%83%83%E3%83%88%E3%82%A2%E3%83%83%E3%83%97%E3%81%99%E3%82%8B

#### Swift Package Index
https://qiita.com/uhooi/items/689801dad2cf2e0a37b4


世界中からアクセスされる共通リソースにコミットすると聞くと高い心理的障壁を感じますが、作業自体は思ったより難しくはないので興味のある方はぜひトライしてみてください。