SwiftUI で透明な View に Gesture を設定する

下記のように、Color.clear のような透明のビューにジェスチャーを追加したかったけど反応しなかった。

import SwiftUI

struct ExampleView: View {
    @State var text: String = "ケバブ"

    var body: some View {
        ZStack {
            Color.clear
                .onTapGesture {
                    text = "ハンバーガー"
                }

            Text(text)
        }
    }
}

下記のように contentShape を設定してあげるとジェスチャーが反応するようになる。

import SwiftUI

struct ExampleView: View {
    @State var text: String = "ケバブ"

    var body: some View {
        ZStack {
            Color.clear
                .contentShape(Rectangle()) // 追加
                .onTapGesture {
                    text = "ハンバーガー"
                }

            Text(text)
        }
    }
}

Color.clear だけでなく、 VStack や HStack といった、コンテナ的な View も同様の方法で設定できるとのこと。

参考

stackoverflow.com

www.hackingwithswift.com

Compositional Layouts Self-Sizing のはまりどころと複雑なレイアウト例

github.com

紹介するコードは上記リポジトリにあげてあります。

はまりどころ

1. itemSize と groupSize を .estimated にする必要がある

例えば、height が可変の場合、グループの heightDimension を .estimated にして、レイアウトアイテムの heightDimension は .fractionalHeight にすれば良いかと思ったけど、それだとダメだった。

func makeLayoutSection() -> NSCollectionLayoutSection {
    let itemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
//        heightDimension: .fractionalHeight(1.0) // 🙅<200d>♀️
        heightDimension: .estimated(100.0) // 🙆<200d>♀️
    )
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    
    let itemGroupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let itemGroup = NSCollectionLayoutGroup.horizontal(
        layoutSize: itemGroupSize,
        subitem: item,
        count: 1
    )
    
    return .init(group: itemGroup)
}

2. グループを UICollectionView のスクロール方向と同じ方向で count を指定して作ると Self-Sizing されない

例えば、UICollectionView のスクロールが縦方向の場合、 グループを vertical で構築するとグループ直下のアイテムの height が Self-Sizing されず、 .estimated に指定した値がそのまま height に反映されてしまう。

func makeLayoutSection() -> NSCollectionLayoutSection {
    let itemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    
    let itemGroupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
//    let itemGroup = NSCollectionLayoutGroup.vertical( // 🙅<200d>♀️
    let itemGroup = NSCollectionLayoutGroup.horizontal( // 🙆<200d>♀️その1
        layoutSize: itemGroupSize,
        subitem: item,
        count: 1
    )
    let itemGroup = NSCollectionLayoutGroup.vertical( // 🙆<200d>♀️その2
        layoutSize: itemGroupSize,
        subitems: [item],
    )
    
    return .init(group: itemGroup)
}

3. レイアウトアイテムの Self-Sizing 方向の contentInsets が効かず、垂直方向の contentInsets が設定通りに反映されない

.absolute の時には問題なく反映される contentInsets が、 .estimated を利用している時には意図した通りに反映されない。 これは 公式ドキュメントにも書いてあった。

Note The value of this property is ignored for any axis that uses an estimated value for its dimension.

ただ、Self-Sizing 方向と垂直方向の挙動は謎で、縦方向スクロールの UICollectionView でレイアウトアイテムの height を Self-Sizing している場合において、 leading に設定した値は leading, trailing の両方に反映され、 trailing に設定した値は2倍されて trailing に反映されてしまった。

func makeLayoutSection() -> NSCollectionLayoutSection {
    let itemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    item.contentInsets = .init(
        top: 16.0, // 設定しても効かない
        leading: 16.0, // 設定すると leading, trailing の両方に反映される
        bottom: 16.0, // 設定しても効かない
        trailing: 16.0 // 設定すると 値 * 2 が trailing に反映される
    )
    
    let itemGroupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let itemGroup = NSCollectionLayoutGroup.horizontal(
        layoutSize: itemGroupSize,
        subitem: item,
        count: 1
    )
    
    return .init(group: itemGroup)
}

4. グループの Self-Sizing 方向の contentInsets が効かない

NSCollectionLayoutGroup は NSCollectionLayoutItem のサブクラスなので、はまりどころの3同様に Self-Sizing 方向の contentInsets は効かない。 ただ、 Self-Sizing と垂直方向の挙動は異なり、こちらは設定した通りの値がレイアウトに反映される。

func makeLayoutSection() -> NSCollectionLayoutSection {
    let itemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    
    let itemGroupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let itemGroup = NSCollectionLayoutGroup.horizontal(
        layoutSize: itemGroupSize,
        subitem: item,
        count: 1
    )
    itemGroup.contentInsets = .init(
        top: 16.0, // 設定しても効かない
        leading: 16.0, // 設定通りに反映される
        bottom: 16.0, // 設定しても効かない
        trailing: 16.0 // 設定通りに反映される
    )
    
    return .init(group: itemGroup)
}

レイアウト例

1. 縦方向にスクロールする UICollectionView で同じレイアウトアイテムを縦に複数並べて Self-Sizing させる

レイアウト例1
レイアウト例1

func makeLayoutSection() -> NSCollectionLayoutSection {
    let itemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    
    let itemGroupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let itemGroup = NSCollectionLayoutGroup.horizontal(
        layoutSize: itemGroupSize,
        subitem: item,
        count: 1
    )
    itemGroup.contentInsets = .init(
        top: .zero,
        leading: 16.0,
        bottom: .zero,
        trailing: 16.0
    )

    let section = NSCollectionLayoutSection(group: itemGroup)
    section.contentInsets = .init(
        top: 16.0,
        leading: .zero,
        bottom: 16.0,
        trailing: .zero
    )
    section.interGroupSpacing = 8.0
    return section
}

注意点

例えば、縦に並べるレイアウトアイテム数が 3 つ固定の場合、下記のように group を vertical にして count を 3 とかにしたくなるけど、はまりどころの 2 で述べた通り Self-Sizing されなくなってしまう。

// 🙅<200d>♀️
let itemGroup = NSCollectionLayoutGroup.vertical(
    layoutSize: itemGroupSize,
    subitem: item,
    count: 3
)

// 🙆<200d>♀️その1
let itemGroup = NSCollectionLayoutGroup.horizontal(
    layoutSize: itemGroupSize,
    subitem: item,
    count: 1
)

// 🙆<200d>♀️その2
// この場合は、itemGroup が3回繰り返されるのではなく、3つの item を持つ単一のグループになるので、レイアウトアイテム間のスペースは section.interGroupSpacing ではなくて group.interItemSpacing で設定する
let itemGroup = NSCollectionLayoutGroup.vertical( 
    layoutSize: itemGroupSize,
    subitems: [item, item, item],
)

2. 縦方向にスクロールする UICollectionView で異なるレイアウトアイテムを縦に複数並べて Self-Sizing させる

例えば、大きいレイアウトアイテムを3つ、小さいレイアウトアイテムを2つ表示する場合。

レイアウト例2
レイアウト例2

方法1

それぞれのレイアウトアイテム種類毎にグループを作って、さらにそれらをまとめるグループを作るやり方。 それぞれのグループに同じ interItemSpacing を設定しないといけないけど、反対にグループ毎に変えたい場合はこの方法が良い。

func makeLayoutSection() -> NSCollectionLayoutSection {
    let interItemSpacing: NSCollectionLayoutSpacing = .fixed(8.0)

    // MARK: - Large Item
    let largeItemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let largeItem = NSCollectionLayoutItem(layoutSize: largeItemSize)
    
    let largeItemGroupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(300.0)
    )
    let largeItemGroup = NSCollectionLayoutGroup.vertical(
        layoutSize: largeItemGroupSize,
        subitems: [largeItem, largeItem, largeItem]
    )
    largeItemGroup.contentInsets = .init(
        top: .zero,
        leading: 16.0,
        bottom: .zero,
        trailing: 16.0
    )
    largeItemGroup.interItemSpacing = interItemSpacing
    
    // MARK: - Small Item
    let smallItemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let smallItem = NSCollectionLayoutItem(layoutSize: smallItemSize)
    
    let smallItemGroupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let smallItemGroup = NSCollectionLayoutGroup.vertical(
        layoutSize: smallItemGroupSize,
        subitems: [smallItem, smallItem]
    )
    smallItemGroup.contentInsets = .init(
        top: .zero,
        leading: 48.0,
        bottom: .zero,
        trailing: 48.0
    )
    smallItemGroup.interItemSpacing = interItemSpacing
    
    // MARK: - All
    let groupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(500.0)
    )
    let group = NSCollectionLayoutGroup.vertical(
        layoutSize: groupSize,
        subitems: [
            largeItemGroup,
            smallItemGroup
        ]
    )
    group.interItemSpacing = interItemSpacing
    
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = .init(
        top: 16.0,
        leading: .zero,
        bottom: 16.0,
        trailing: .zero
    )
    return section
}

方法2

全レイアウトアイテム毎にグループを作って、それらをまとめるグループを作るやり方。 レイアウトアイテム間のスペースの指定は、最上位のグループの interItemSpacing を指定するだけで良い。

func makeLayoutSection() -> NSCollectionLayoutSection {
    // MARK: - Large Item
    let largeItemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let largeItem = NSCollectionLayoutItem(layoutSize: largeItemSize)
    
    let largeItemGroupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let largeItemGroup = NSCollectionLayoutGroup.horizontal(
        layoutSize: largeItemGroupSize,
        subitem: largeItem,
        count: 1
    )
    largeItemGroup.contentInsets = .init(
        top: .zero,
        leading: 16.0,
        bottom: .zero,
        trailing: 16.0
    )
    
    // MARK: - Small Item
    let smallItemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let smallItem = NSCollectionLayoutItem(layoutSize: smallItemSize)
    
    let smallItemGroupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100.0)
    )
    let smallItemGroup = NSCollectionLayoutGroup.horizontal(
        layoutSize: smallItemGroupSize,
        subitem: smallItem,
        count: 1
    )
    smallItemGroup.contentInsets = .init(
        top: .zero,
        leading: 48.0,
        bottom: .zero,
        trailing: 48.0
    )
    
    // MARK: - All
    let groupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(500.0)
    )
    let group = NSCollectionLayoutGroup.vertical(
        layoutSize: groupSize,
        subitems: [
            largeItemGroup, largeItemGroup, largeItemGroup,
            smallItemGroup, smallItemGroup
        ]
    )
    group.interItemSpacing = .fixed(8.0)
    
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = .init(
        top: 16.0,
        leading: .zero,
        bottom: 16.0,
        trailing: .zero
    )
    return section
}

注意点

subitems: [
    largeItemGroup, largeItemGroup, largeItemGroup,
    smallItemGroup, smallItemGroup
]

largeItemGroup, smallItemGroup はそれぞれ一つのレイアウトアイテムしか保持していないので、グループを作らずに group の subitem に直接それぞれのレイアウトアイテム(largeItem, smallItem)を設定すれば良いかと思ったけど、はまりどころの3で述べたように、レイアウトアイテムの contentInsets は意図した通りに反映されないので、グループでラップしてそれらの contentInsets を設定している。


group.interItemSpacing = .fixed(8.0)

それぞれのアイテム間のスペースの設定をするための上記コードだけど、最初は、 グループ間のスペースを設定するのだからと思って下記のように section.interGroupSpacing に設定してしまったけど、それだと効かなかった。

section.interGroupSpacing = 8.0 🙅<200d>♀️

というのも、 largeItemGroup や smallItemGroup は確かにグループだけど、それらはネストしたグループであって、最上位にはそれらをまとめたグループがいて、 section.interGroupSpacing はその最上位のグループが繰り返される場合に最上位グループ間のスペースを確保するというものなのであった。

その他

NSCollectionLayoutGroup に visualDescription() なるものがあって試してみたけど、うーん?という感じだった。

Instance Method visualDescription() Returns a string with an ASCII representation of the group. Apple Developer Documentation


NSCollectionLayoutItem に edgeSpacing というのがあった。 より複雑なレイアウト作るときには使えそう?

edgeSpacing The amount of space added around the boundaries of the item between other items and this item's container. Apple Developer Documentation

関連

komaji504.hateblo.jp

Compositional Layout におけるレイアウト構築のおすすめ

f:id:komaji504:20200914155330p:plain:w414
Compositional Layout の2列表示

Compositional Layout を使って、上記のような一方向のみスクロールできるグリッドレイアウトを構築する場合の実装方法についてのおすすめ。

基本

制約との二重管理になってしまうので可能な限り .absolute の使用は避け、 Self-Sizing されるようにする。

スクロール方向のサイズ指定

.estimated を用いて Self-Sizing されるようにする。
例えば縦スクロールの場合は、セルの width に応じて height が変わって欲しいので item, group 両方の height に対して .estimated() を指定する。

let itemSize = NSCollectionLayoutSize(
    widthDimension: .fractionalWidth(1.0),
    heightDimension: .estimated(estimatedItemHeight) // スクロール方向なので .estimated()
)

let groupLayoutSize = NSCollectionLayoutSize(
    widthDimension: .fractionalWidth(1.0),
    heightDimension: .estimated(estimatedItemHeight) // スクロール方向なので .estimated()
)

スクロールと垂直方向のサイズ指定

UICollectionView のサイズによって決まるようにしたいので、.fractional(1.0) を指定する。
例えば縦スクロールの場合は、セルの width は UICollectionView の width によって決まるようにしたいので、 item, group 両方の width に対して .fractional(1.0) を指定する。

let itemSize = NSCollectionLayoutSize(
    widthDimension: .fractionalWidth(1.0), // スクロールと垂直方向なので .fractionalWidth(1.0)
    heightDimension: .estimated(estimatedItemHeight)
)

let groupLayoutSize = NSCollectionLayoutSize(
    widthDimension: .fractionalWidth(1.0), // スクロールと垂直方向なので .fractionalWidth(1.0)
    heightDimension: .estimated(estimatedItemHeight)
)

カラム数の指定

画像の2列表示のように複数カラムある場合は、 group の count にカラム数を指定する。
これにより、interItemSpacing を考慮してカラムサイズが自動計算されるので、 .fractional(1.0) はそのままで良くなる。

let group = NSCollectionLayoutGroup.horizontal(
    layoutSize: groupLayoutSize,
    subitem: item,
    count: 2 // 2行表示 
)
group.interItemSpacing = .fixed(8.0)

上記に沿った実装例

上記の内容をまとめると、画像のようなレイアウトを構築するには下記のような実装になる。

func makeGridSection() -> UICollectionViewLayout {
    let estimatedItemHeight: CGFloat = 100.0
    let itemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0), // スクロールと垂直方向なので .fractionalWidth()
        heightDimension: .estimated(estimatedItemHeight) // スクロール方向なので .estimated()
    )
    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    let groupLayoutSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0), // スクロールと垂直方向なので .fractionalWidth()
        heightDimension: .estimated(estimatedItemHeight) // スクロール方向なので .estimated()
    )
    let group = NSCollectionLayoutGroup.horizontal(
        layoutSize: groupLayoutSize,
        subitem: item,
        count: 2 // 2行表示 
    )
    group.interItemSpacing = .fixed(8.0)

    let section = NSCollectionLayoutSection(group: group)
    section.interGroupSpacing = 8.0
    let sideInset: CGFloat = 16.0
    section.contentInsets = .init(
        top: .zero,
        leading: sideInset,
        bottom: .zero,
        trailing: sideInset
    )

    return UICollectionViewCompositionalLayout(section: section)
}

DiffableDataSource で apply 後の UICollectionView の contentSize を取得する

問題

UICollectionViewDataSource を使っている場合では、 下記のように reloadData 直後には collectionView の contentSize が更新されている。

collectionView.reloadData()
collectionView.layoutIfNeeded()
// collectionView.contentSize が更新されている

しかし DiffalbleDataSource を使っている場合には、 apply 直後では、collectionView の contentSize は更新されていない(レイアウトの組み方によってはその限りじゃないかも)。

dataSource.apply(snapshot)
collectionView.layoutIfNeeded()
//  collectionView.contentSize は更新前の値のまま

reloadData はサイズ計算が同期的に行われているけど、 apply の場合はサイズ計算が非同期で行われているぽい。

対応

DiffalbleDataSource の apply メソッドは下記のようなインターフェースになっており、 アニメーションの有無と、 apply 後に実行するクロージャを渡すことができる。

func apply(_ snapshot: NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>, animatingDifferences: Bool = true, completion: (() -> Void)? = nil)

Apple Developer Documentation

そのため、 apply 後の contentSize を取得したければ completion 内で取得すると良さそう。
アニメーションが不要な場合には animatingDifferences を false にするのでも大丈夫だった。

ちなみに apply の場合には layoutIfNeeded は不要だった。

dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
    // collectionView.contentSize は更新後の値
}

dataSource.apply(snapshot, animatingDifferences: false)
//  collectionView.contentSize は更新後の値

Appearance の設定方法

iOS 13 から Appearance の設定方法が追加されていた。
以前の方法もまだ非推奨になっておらず引き続き利用できるみたいだけど、 Legacy Customizations と表記されていて新しい設定方法が推奨されている。

新しい設定方法は standardAppearance プロパティによるもので、 UITabBar の場合は以下のようなコードになる。

let tabBarController = UITabBarController()
let tintColor: UIColor = .green
        
if #available(iOS 13.0, *) {
    let tabBarItemAppearance = UITabBarItemAppearance()
    tabBarItemAppearance.selected.iconColor = tintColor
    tabBarItemAppearance.selected.titleTextAttributes = [.foregroundColor: tintColor]
    
    let tabBarAppearance = UITabBarAppearance()
    tabBarAppearance.inlineLayoutAppearance = tabBarItemAppearance
    tabBarAppearance.stackedLayoutAppearance = tabBarItemAppearance
    tabBarAppearance.compactInlineLayoutAppearance = tabBarItemAppearance
            
    tabBarController.tabBar.standardAppearance = tabBarAppearance
} else {
    UITabBar.appearance().tintColor = tintColor
    UITabBarItem.appearance().setTitleTextAttributes(
        [.foregroundColor: tintColor],
        for: .selected
    )
}
  • inlineLayoutAppearance
  • stackedLayoutAppearance
  • compactInlineLayoutAppearance

は、端末のスクリーンサイズや portrait/landscape によっていずれかが適用されるみたいなので、例えば portrait の iPhone のみの対応とかであれば3つ全てにセットする必要はなさそうかも。

LaunchScreen の変更が反映されないとき

LaunchScreen の storyboard に加えた変更が全く反映されなくなった。

Target の設定で LaunchScreen File のファイルを変更してみたところ、ファイル自体の変更は効くようだった。

変更前の内容がキャッシュされているのかと思って、 Xcode で Clean Build Folder をしてから Xcode を再起動して再ビルドしてみたけど変わらず。

今度は、アプリをアンインストールして端末を再起動してから再ビルドしたところ無事反映された。

アンインストールが効いたのか再起動が効いたのか分からなくなってしまったけど、やっぱり以前の LaunchScreen の内容がキャッシュされているだけだったみたい。

以前、アプリアイコンも同様にキャッシュされて変更が反映されないということもあったので、変更が反映されない場合はキャッシュを疑うのは良さそう。

DarkMode 対応したらアプリ起動時にクラッシュするようになってしまった

事象

DarkMode 対応したら、アプリ起動時にクラッシュするようになってしまった。

クラッシュ内容は、アプリ起動時に表示する VC である TopViewController の viewDidLoad() 内での EXC_BREAKPOINT で、 iOS 13 系の端末のみで発生する模様。

スタックトレースを一部抜粋するとこんな感じ。

Crashed: com.apple.main-thread
0  MyApp                 0x10022293c TopViewController.viewDidLoad() + 112 (TopViewController.swift:112)
1  MyApp                 0x100222fb8 @objc TopViewController.viewDidLoad() (<compiler-generated>)
2  UIKitCore                      0x1ba9b00e4 -[UIViewController _sendViewDidLoadWithAppearanceProxyObjectTaggingEnabled] + 104
3  UIKitCore                      0x1ba9b4d18 -[UIViewController loadViewIfRequired] + 952
4  UIKitCore                      0x1ba9b5104 -[UIViewController view] + 32

調査 & 対応

このクラッシュ、特定のユーザは 100% 起こるようなのにも関わらず、自分の手元では全く再現できないので調査に大苦戦した。ちなみに、クラッシュが生じるようになったバージョンでは viewDidLoad() 内のコードは一切変えていなかった。

再現できてしまえば直したも同然なので、端末の設定をいろいろ変えて再現できるか確認した。

の各項目をいろいろ変えてみたけど再現できず...

再現できなければ、いろいろ試してリリースして様子を見るしかないので、いろいろコードを修正してリリースしてみた。 しかし、一向に直らず。

コードというよりもプロジェクトの設定が悪いんじゃないかと思っていろいろ確認していたら、アプリの Main Target の General の Main Interface に、アプリ起動時に表示する VC である TopViewController が設定してあった。

この項目は Info.plist の UIMainStoryboardFile として設定されるようで、Apple のドキュメントを見てみると

When this key is present, the main storyboard file is loaded automatically at launch time and its initial view controller installed in the app’s window.
iOS Keys

と書いてあった。

自分のアプリでは、以下のように AppDelegate の application(_:,didFinishLaunchingWithOptions:) 内で TopViewController を初期化して window.rootViewController にセットしていたので、 Main Interface の指定と合わせると二重に画面の初期化処理が走ってしまっているみたいだった。

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    window?.rootViewController = TopViewController()
    window?.makeKeyAndVisible()

これはこれで問題なので、 Main Interface での指定は削除(何も設定しないように)して、以下のように初期化処理をコードに集約させた。

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    window = UIWindow(frame: UIScreen.main.bounds)
    window?.rootViewController = TopViewController()
    window?.makeKeyAndVisible()

このバージョンをリリースしてみたところ、なんと、無事クラッシュが治まっていた。

UIKit のコードは読めないので詳細な理由はわからないけど、DarkMode 対応することで View の初期化処理がいろいろ変わるとかそんな感じだと思う。

とにかく、めちゃくちゃ安心した。

まとめ

今までは問題なかった実装や設定も、 iOSSDK のアップデート等で動かなくなってしまうことがあるので、Apple のドキュメントをちゃんと読んで実装するの大事。