はじめに
当ブログで【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