【SwiftUI】iOS 15でListのSection上部に余白が生じないようプリミティブに対処する

# 問題

SwiftUIでListを表示させると、iOS 15では最初のSectionの上部に余白が生じてしまう。

### スクリーンショット

| iOS 16.0（正常）| iOS 15.5（異常）|
|-|-|
|![13pro_16_0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929361/7c8bedc0-06ea-ff12-fffd-b614d9108cd7.png)|![13pro_15_5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929361/f06175b1-2b31-4a7a-5c03-e578c70dafe1.png)|

<details><summary>【参考】上記画面のコード</summary>

```swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                Section("セクション1") {
                    Text("メニュー1")
                    Text("メニュー2")
                    Text("メニュー3")
                    Text("メニュー4")
                    Text("メニュー5")
                }
                Section("セクション2") {
                    Text("メニュー1")
                    Text("メニュー2")
                    Text("メニュー3")
                    Text("メニュー4")
                    Text("メニュー5")
                }
            }
            .navigationBarTitle("タイトル")
        }
    }
}

#Preview {
    ContentView()
}

```

</details>

上述の余白は、`.listStyle(.plain)`を適用したListであれば、`UITableView.appearance().sectionHeaderTopPadding`を0に設定することで解消される可能性が高い[^1]。しかし、デフォルトの`.listStyle(.insetGrouped)`が適用されたListではこの設定が効かなかった。さらに、iOS 16以降では仕様が変わり、この解決策は機能しないようである[^2]。他に、[SwiftUIIntrospect](https://github.com/siteline/swiftui-introspect)を用いてUIKitの側をカスタマイズするなど、Webで公開されている一通りの方法を試したものの、解消には至らなかった。

そこで、スクラッチで新たにListを定義してみたい。プリミティブであり、エレガントな解決法とは言えないが、OSバージョン間の表示の差異を無くすためには確実で分かりやすいと思われたからである。

# 方針

- ViewBuilderを用いて、Listに相当する要素（以下、カスタムListと称する）、Sectionに相当する要素、及びSection内の項目用の要素を新たに定義する
- [ViewExtractor](https://github.com/GeorgeElsham/ViewExtractor?tab=readme-ov-file) を用いて、カスタムList内のコンテンツを順に読み込み、表示させる
  - その際、最初と最後の要素のみ部分的に角丸を適用する
      - 最初の要素は上辺、最後の要素は下辺のみ角丸に
  - 上記以外の要素の場合、あるいはコンテンツの数が一つの場合は、全ての角を丸くする
- ヘッダーをコンテンツの上に表示させる
  - ヘッダーの中身はカスタムListのオプションで指定できるようにする

# 解決

### コード

※ 部分的な角丸の実装は、「『一部だけ角丸にする』をSwiftUIで実現する」(https://qiita.com/chocoyama/items/1cb7040f0e717406a6f4) を参照されたい。

```swift
import SwiftUI
import ViewExtractor

struct ListItemResetModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .listRowInsets(EdgeInsets())
            .listRowBackground(Color.clear)
    }
}

struct CustomList<T: View>: View {
    @ViewBuilder var content: () -> T
    var body: some View {
        List {
            content()
        }
        .environment(\.defaultMinListRowHeight, 0)
    }
}

struct CustomSection<T: View>: View {
    var header: String?
    var cornerRadius: CGFloat = 8
    @ViewBuilder let content: T
    var body: some View {
        Group {
            ListHeader(header)
            ExtractMulti(content) { views in
                ForEach(views) { view in
                    if views.count > 1 {
                        if view.id == views.first?.id {
                            view.cornerRadius(cornerRadius, maskedCorners: [.layerMaxXMinYCorner, .layerMinXMinYCorner])
                                .modifier(ListItemResetModifier())
                        } else if view.id == views.last?.id {
                            view.cornerRadius(cornerRadius, maskedCorners: [.layerMaxXMaxYCorner, .layerMinXMaxYCorner])
                                .modifier(ListItemResetModifier())
                                .listRowSeparator(.hidden, edges: .bottom)
                        } else {
                            view.modifier(ListItemResetModifier())
                        }
                    } else {
                        view.modifier(ListItemResetModifier())
                            .cornerRadius(cornerRadius)
                            .listRowSeparator(.hidden)
                    }
                }
            }
        }
        VStack {}
            .frame(height: 32)
            .modifier(ListItemResetModifier())
    }
}

struct ListHeader: View {
    var text: String?
    init(_ text: String?) {
        self.text = text
    }
    var body: some View {
        if let text = text {
            Text(text)
                .font(.custom("HiraginoSans-W3", fixedSize: 12))
                .foregroundStyle(Color.secondary)
                .padding(.horizontal, 20)
                .padding(.vertical, 4)
                .frame(maxWidth: .infinity, alignment: .leading)
                .listRowInsets(EdgeInsets())
                .listRowBackground(Color.clear)
                .listRowSeparator(.hidden)
        }
    }
}

struct ListItem<T: View>: View {
    @ViewBuilder var content: () -> T
    var body: some View {
        HStack {
            content()
        }
        .padding(.vertical, 0)
        .padding(.horizontal, 16)
        .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
        .background(Color.white)
        .listRowSeparator(.hidden)
    }
}

struct ContentView: View {
    var body: some View {
        NavigationView {
            CustomList {
                CustomSection(header: "セクション1") {
                    ListItem {
                        Text("メニュー1")
                    }
                    ListItem {
                        Text("メニュー2")
                    }
                    ListItem {
                        Text("メニュー3")
                    }
                    ListItem {
                        Text("メニュー4")
                    }
                    ListItem {
                        Text("メニュー5")
                    }
                }
                CustomSection(header: "セクション2") {
                    ListItem {
                        Text("メニュー1")
                    }
                    ListItem {
                        Text("メニュー2")
                    }
                    ListItem {
                        Text("メニュー3")
                    }
                    ListItem {
                        Text("メニュー4")
                    }
                    ListItem {
                        Text("メニュー5")
                    }
                }
            }
            .environment(\.defaultMinListRowHeight, 0)
            .navigationBarTitle("タイトル")
        }
    }
}


#Preview {
    ContentView()
}

```

### スクリーンショット

| iOS 16.0 | iOS 15.5 |
|-|-|
|![after_16_0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929361/0e01b418-898b-f77d-f4e5-fbc15e6b537a.png)|![after_15_5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929361/26f39fe0-8cb5-88da-18c8-d18b8fad089d.png)|

上記のコードによって、バージョン間の表示の差が解消されたことが分かる。

[^1]: 詳しくは 「【SwiftUI】iOS15でListのSection上部にスペースが生じる件」(https://www.2nd-walker.com/2021/09/29/swiftui-remove-top-padding-of-list-in-ios15/) を参照されたい。
[^2]: 詳しくは「【SwiftUI】iOS16でまたListのSection上部にスペースが生じた件」(https://www.2nd-walker.com/2022/09/14/swiftui-list-has-extra-top-padding-again-in-ios16/) を参照されたい。