The Composable Architecture(TCA)メモ

ViewStore

binding

public func binding<LocalState>(
    get: @escaping (State) -> LocalState,
    send localStateToViewAction: @escaping (LocalState) -> Action
) -> Binding<LocalState>

https://github.com/pointfreeco/swift-composable-architecture/blob/main/Sources/ComposableArchitecture/ViewStore.swift#L125-L141

ViewStore の State, Action から Binding を作る。

双方方向バインディングをしたい時に使う。

Binding の getter では state の値を返し、setter では渡されたアクションを send する。

// 例
struct State { var name = "" }
enum Action { case nameChanged(String) }

TextField(
    "名前を入力してください",
    text: viewStore.binding(
        get: { $0.name },
        send: { Action.nameChanged($0) }
    )
)

Reducer

pullback

public func pullback<GlobalState, GlobalAction, GlobalEnvironment>(
    state toLocalState: WritableKeyPath<GlobalState, State>,
    action toLocalAction: CasePath<GlobalAction, Action>,
    environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment
) -> Reducer<GlobalState, GlobalAction, GlobalEnvironment>

https://github.com/pointfreeco/swift-composable-architecture/blob/main/Sources/ComposableArchitecture/Reducer.swift#L250-L264

子の Reducer を親の State, Action に紐づいた Reducer へ変換する。

これにより、単一の Reducer が大きくなりすぎてしまわないように適切に子 Reducer へと分割し、それらをマージして親 Reducer を作ることができる。

// 例
// 親の State
struct AppState { var settings: SettingsState, ... }
// 親の Action
enum AppAction { case settings(SettingsAction), ... }

// 子の Reducer
let settingsReducer = Reducer<SettingsState, SettingsAction, SettingsEnvironment> { ... }

// 親の Reducer
// combine で複数の子 Reducer を結合する
let appReducer = Reducer<AppState, AppAction, AppEnviroment> = .combine(
    settingsReducer.pullback(
        state: \.settings,
        action: /AppAction.settings,
        environment: { $0.settings }
    ),
    // 他の子 Reducer ...
)

combine

public static func combine(_ reducers: [Reducer]) -> Reducer

https://github.com/pointfreeco/swift-composable-architecture/blob/main/Sources/ComposableArchitecture/Reducer.swift#L155-L159

複数の Reducer を順番に実行する単一の Reducer を作る。

順番に実行するため配列の記述順序に影響し、親・子関係の Reducer が同一の State, Action を使用する際に問題を生じる場合がある。

特に Optional な Reducer を用いる場合に起こりやすく、子 Reducer の前に 親 Reducer を実行すると assertion failure となる可能性があるため、子から親という順序にするのが一般的である。

// 例
let parentReducer = Reducer<ParentState, ParentAction, ParentEnvironment>.combine(
    // 後続の Reducer で state を nil にしているが先に実行されるので state を処理できる
    childReducer.optional().pullback(
        state. \.child,
        action: /ParentAction.child,
        ennvironment: { $0.child }
    ),
    // childReducer の後に実行されるので state を nil にしても childReducer には影響がない
    Reducer { state, action, environment in
        switch action {
        case .child(.dismiss):
            state.child = nil
            return .none
        ...
        }
    }
)

SwiftUI の ScrollView を CustomShape で clip すると内部の View まで clip される

下記のように ScrollView に対して clippedShae(_:) で CustomShape を適用して clip すると、 ScrollView 内の View まで clip されてしまって View が途中で切れてしまう。 Rectangle 等の組み込みの Shape や、List に対してであれば問題は生じない。 Stack Overflow にも同様の投稿があって、自分は確認していないけど iOS 13 では再現しないとのこと。

stackoverflow.com

CustomShape で clip clippedShae(_:) 無し(正常パターン)
f:id:komaji504:20210216181507g:plain
CustomShape で clip
f:id:komaji504:20210216181442g:plain
clippedShae(_:)` 無し
struct ExampleView: View {
    struct CustomShape: Shape {
        func path(in rect: CGRect) -> Path {
            let bezierPath = UIBezierPath(
                roundedRect: rect,
                byRoundingCorners: .allCorners,
                cornerRadii: CGSize(width: 20.0, height: 20.0)
            )
            let cgPath = bezierPath.cgPath
            return Path(cgPath)
        }
    }

    var body: some View {
        ScrollView {
            ForEach(0..<100) { element in
                Text("\(element)")
            }
            .frame(maxWidth: .infinity)
            .background(Color.blue)
            .padding()
        }
        .background(Color.green)
        .clipShape(CustomShape()) // ScrollView に対して CustomShape で clip
    }
}

これは、CustomShape 内の Path の init によっても再現の有無が異なっていて、各 init における今回の現象の再現有無は下記の通りだった。 もしかしたら callback や CGPath を引数にとる init は CGPath の作り方によっても挙動が異なるかも。

  • OK
    • init(_ rect: CGRect),
    • init(roundedRect rect: CGRect, cornerSize: CGSize, style: RoundedCornerStyle = .circular)
    • init(roundedRect rect: CGRect, cornerRadius: CGFloat, style: RoundedCornerStyle = .circular)
  • NG
    • init(_ callback: (inout Path) -> ())
    • init(_ path: CGPath)
    • init(_ path: CGMutablePath)
    • init(ellipseIn rect: CGRect)

解決法

ZStack 等を使って ScrollView の背面の View をおいて、ScrollView は透明にして背面の View に CustomShape を適用することで解決した。 ただ、この方法は View のレイアウト次第では使えないかも。

    var body: some View {
        ZStack {
            Color.green
                .clipShape(CustomShape()) // 背面の View に CustomShape を適用

            ScrollView {
                ForEach(0..<100) { element in
                    Text("\(element)")
                }
                .frame(maxWidth: .infinity)
                .background(Color.blue)
                .padding()
            }
        }
    }

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つ全てにセットする必要はなさそうかも。