【SwiftUI】iOS16でまたListのSection上部にスペースが生じた件

はじめに

当ブログで【SwiftUI】iOS15でListのSection上部にスペースが生じる件 という記事を公開してから、約一年が経ちました。

先日iOS16がリリースされ、ヤツが帰ってきました。

再び埋めましょう。

【重要】※追記 (2023/4/7)
iOS 16.3より、下記の4章「解決!と思いきや…」にある『SectionのheaderにEmptyView(height = 0)を使う解決策』を用いると、アプリがクラッシュする現象が確認されました。
そのため、こちらの解決策は用いらないようお願いします。
詳細は4章に載せております。

開発環境 Xcode 14 / Swift 5.7

問題の確認

以下のコードで確認してみます。

struct ContentView: View {

    init() {
        // In iOS 15, we could remove the top padding by this
        if #available(iOS 15, *) {
            UITableView.appearance().sectionHeaderTopPadding = 0
        }
    }

    var body: some View {
        VStack(alignment: .center, spacing: 0) {
            HStack {
                Spacer()
                Text("iOS \(UIDevice.current.systemVersion)")
                Spacer()
            }
            .frame(height: 20)
            .background(Color.red.opacity(0.2))

            List {
                ForEach(0..<3) { section in
                    Section {
                        ForEach(0..<2) { row in
                            HStack {
                                Image(systemName: "globe")
                                    .imageScale(.large)
                                    .foregroundColor(.accentColor)
                                Text("Hello, world!")
                            }
                            .frame(height: 40)
                        }

                    } header: {
                        HStack {
                            Text("Section \(section)")
                            Spacer()
                        }
                        .frame(height: 40)
                        .background(Color.yellow.opacity(0.2))
                    }
                }
                .listRowInsets(EdgeInsets())
                .listRowBackground(Color.mint.opacity(0.2))
            }
            .listStyle(.plain)
        }
    }
}

これを iOS15.x、iOS16でそれぞれ実行すると、以下のようになります↓

解決策

iOS16になり、どうやらListの中身が UITableView から UICollectionView に変更になったようです。
(Tableが追加されたので、それに合わせて変更された?)

なので UITableView.appearance().sectionHeaderTopPadding では効かなくなったみたいです。

UICollectionView の場合、UICollectionViewCompositionalLayout + UICollectionLayoutListConfiguration が使えそうです。

// Add this somewhere
if #available(iOS 16, *) {
    var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
    configuration.headerMode = .supplementary
    configuration.headerTopPadding = 0

    let layout = UICollectionViewCompositionalLayout.list(using: configuration)
    UICollectionView.appearance().collectionViewLayout = layout
}

これを加えて実行してみます↓

上手くいきました 👍

解決!と思いきや…

上記解決策で確かにスペースはなくせたのですが、UICollectionView.appearance() だけにSectionを使わない場合のListに悪影響が出てしまいました。

struct ContentView: View {

    init() {
        if #available(iOS 16, *) {
            var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
            configuration.headerMode = .supplementary
            configuration.headerTopPadding = 0

            let layout = UICollectionViewCompositionalLayout.list(using: configuration)
            UICollectionView.appearance().collectionViewLayout = layout
        }
    }

    var body: some View {
        VStack(alignment: .center, spacing: 0) {
            HStack {
                Spacer()
                Text("iOS \(UIDevice.current.systemVersion)")
                Spacer()
            }
            .frame(height: 20)
            .background(Color.red.opacity(0.2))

            List {
                ForEach(0..<3) { row in
                    HStack {
                        Image(systemName: "globe")
                            .imageScale(.large)
                            .foregroundColor(.accentColor)
                        Text("Hello, world! \(row)")
                    }
                    .frame(height: 40)
                }
                .listRowInsets(EdgeInsets())
                .listRowBackground(Color.mint.opacity(0.2))
            }
            .listStyle(.plain)
        }
    }
}

↑を実行すると、↓のようになります。

ヘッダーを表示固定にしたため、1行目がヘッダーとして表示されてしまいます。

そこで、空のSectionを作ってワークアラウンドします↓
(上記ソースのList部分を以下に置き換えます)

【重要】※追記 (2023/4/7)
iOS 16.3より、↓にある『SectionのheaderにEmptyView(height = 0)を使う解決策』を用いると、アプリがクラッシュする現象が確認されました。(以下のような例外が発生します)

*** Terminating app due to uncaught exception ‘NSInternalInconsistencyException’, reason: ‘Invalid parameter not satisfying: !CGSizeEqualToSize(size, CGSizeZero)’

そのため、こちらの策は参考にしないようお願いいたします。

代替策としては、もともとSectionの必要がなかったListに関しては『List → Section → ForEach』から『List → ForEach』へとシンプルな形に戻し、Sectionが必要なViewに対してだけUICollectionView.appearanceの設定が適用されるよう、5.2章「解決」にあるようにUICollectionView.appearance(whenContainedInInstancesOf:) を用いて対象を絞ってあげると上手くいきます。

List {
    Section {
        ForEach(0..<3) { row in
            HStack {
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundColor(.accentColor)
                Text("Hello, world! \(row)")
            }
            .frame(height: 40)
        }
    } header: {
        EmptyView()
            .frame(height: 0)
    }
    .listRowInsets(EdgeInsets())
    .listRowBackground(Color.mint.opacity(0.2))
}
.listStyle(.plain)
.environment(\.defaultMinListHeaderHeight, 0)

↑これで実行すると、↓のようになりました。

これでひとまず良しとします。(させてください)

さらに問題が… (追記 2022/9/15)

問題

やはり UICollectionView.appearance() を使用したことで、さらに不具合が生じてしまいました。

具体的に言うと、TextFieldやTextEditorなどを設置し、フォーカスが当たっている(キーボードが表示されている)状態でその入力欄をタップするとアプリが落ちます。

上記サンプルコードにTextFieldを足します↓

(以上略)
    @State private var text: String = "" // Added

    var body: some View {
        VStack(alignment: .center, spacing: 0) {

            HStack {
                Spacer()
                Text("iOS \(UIDevice.current.systemVersion)")
                Spacer()
            }
            .frame(height: 20)
            .background(Color.red.opacity(0.2))

            // Added
            TextField("Input Text", text: $text)
                .frame(height: 30)
                .background(Color.gray.opacity(0.2))

            List {
(以下略)

これを実行したのがこちら↓

TextFieldをタップし、入力状態にしたところでさらにTextFieldをタップすると、以下のようなエラーが出てアプリが落ちます。

2022-09-15 14:30:48.293796+0900 SwiftUISample[75648:990334] *** Assertion failure in -[_UIEditMenuCollectionView _createPreparedSupplementaryViewForElementOfKind:atIndexPath:withLayoutAttributes:applyAttributes:], UICollectionView.m:3441
2022-09-15 14:30:48.298783+0900 SwiftUISample[75648:990334] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'the collection view's data source returned nil from -collectionView:viewForSupplementaryElementOfKind:atIndexPath: for element kind 'UICollectionElementKindSectionHeader' at index path <NSIndexPath: 0x9a968dd42cd194c8> {length = 2, path = 0 - 0}'

_UIEditMenuCollectionView なるものが、ヘッダーを作る際に nil を返したことで落ちています。

で、この _UIEditMenuCollectionView が誰かというのがこちら↓

このポップアップ、iOS15.x では UICalloutBar となっていましたが、これも UICollectionView ものに変わったようです。

解決

やはりグローバルに設定を変えてしまう UICollectionView.appearance() は危険すぎた(対応が雑すぎた)ということで、UICollectionView.appearance(whenContainedInInstancesOf:) を使用して適用させる対象を絞ることにします。

// Comment out
// UICollectionView.appearance().collectionViewLayout = layout

// Replace with this
UICollectionView.appearance(whenContainedInInstancesOf: [UIViewController.self]).collectionViewLayout = layout

SwiftUIなので使用できる具体的なクラスがなく、ひとまずここでは UIViewController を指定しています。

これで再度実行したら、アプリが落ちなくなったことを確認できました。

もしこれでもまだ落ちるケースがあるのなら、最後は UIViewRepresentable や UIViewControllerRepresentable を使用して、独自のクラスをかましてそれで appearance を絞り込むとかするしかないかもしれませんね。

参考リンク

UICollectionViewCompositionalLayout – Apple Developer Documentation

UICollectionLayoutListConfiguration – Apple Developer Documentation

UIAppearance – Apple Developer Documentation