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 のドキュメントをちゃんと読んで実装するの大事。

AssociatedValue の有りと無しの case を複数持つ Enum を Decode する

AssociatedValue が無い case が1つ

enum Food {
    case hamburger(topping: String)
    case pizza
}

上記のような Enum がありまして、これを Decodable に準拠すると

extension Food: Decodable {
    enum CodingKeys: String, CodingKey {
        case hamburger
        case pizza
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        if let value = try container.decodeIfPresent(String.self, forKey: .hamburger) {
            self = .hamburger(topping: value)
        } else if try container.decodeNil(forKey: .pizza) {
            self = .pizza
        } else {
            let context = DecodingError.Context(
                codingPath: container.codingPath,
                debugDescription: "Unknown case"
            )
            throw DecodingError.dataCorrupted(context)
        }
    }
}

こんな感じに書けます。 動作を確認するとそれぞれの case で decode できています。

let jsonString = """
{
    "hamburger": "cheeze"
}
"""
let jsonData = jsonString.data(using: .utf8)!
let food = try JSONDecoder().decode(Food.self, from: jsonData)
// => hamburger(topping: "cheeze")
let jsonString = """
{
    "pizza": null
}
"""
let jsonData = jsonString.data(using: .utf8)!
let food = try JSONDecoder().decode(Food.self, from: jsonData)
// => pizza

AssociatedValue が無い case が2つ

ただ、以下のように AssociatedValue の無い case がもう一つ追加されるとどうでしょうか。

enum Food {
    case hamburger(topping: String)
    case pizza
    case kebabu // New!!!
}

decode メソッドは先ほどと同様に記述すると以下のようなコードをイメージするかと思います。

extension Food: Decodable {
    enum CodingKeys: String, CodingKey {
        case hamburger
        case pizza
        case kebabu
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        if let value = try container.decodeIfPresent(String.self, forKey: .hamburger) {
            self = .hamburger(topping: value)
        } else if try container.decodeNil(forKey: .pizza) {
            self = .pizza
        } else if try container.decodeNil(forKey: .kebabu) { // New!!!
            self = .kebabu
        } else {
            let context = DecodingError.Context(
                codingPath: container.codingPath,
                debugDescription: "Unknown case"
            )
            throw DecodingError.dataCorrupted(context)
        }
    }
}

しかし、これで kebabu を decode しようとすると DecodingError.keyNotFound が throw されてしまいます。

let jsonString = """
{
    "kebabu": null
}
"""
let jsonData = jsonString.data(using: .utf8)!
let food = try JSONDecoder().decode(Food.self, from: jsonData)
//  ▿ keyNotFound : 2 elements
//    - .0 : CodingKeys(stringValue: "pizza", intValue: nil)
//    ▿ .1 : Context
//      - codingPath : 0 elements
//      - debugDescription : "No value associated with key CodingKeys(stringValue: \"pizza\", intValue: nil) (\"pizza\")."
//      - underlyingError : nil

これは、AssociatedValue の無い case を decode しているメソッド decodeNil(forKey:) は、 Key が存在しないと DecodingError.keyNotFound を throw するため、 pizza の時点で DecodingError.keyNotFound が throw されてしまっているからです。

解決案1

try? を使って DecodingError.keyNotFound が throw されないようにしてみましょう。

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        if let value = try container.decodeIfPresent(String.self, forKey: .hamburger) {
            self = .hamburger(topping: value)
        } else if (try? container.decodeNil(forKey: .pizza)) ?? false { // try? で Error を throw しないように
            self = .pizza
        } else if try container.decodeNil(forKey: .kebabu) {
            self = .kebabu
        } else {
            let context = DecodingError.Context(
                codingPath: container.codingPath,
                debugDescription: "Unknown case"
            )
            throw DecodingError.dataCorrupted(context)
        }
    }

こうすることで、 kebabu を decode することができます。

let jsonString = """
{
    "kebabu": null
}
"""
let jsonData = jsonString.data(using: .utf8)!
let food = try JSONDecoder().decode(Food.self, from: jsonData)
// => kebabu

しかし、 if 分の順序を変えると壊れてしまったり、DecodingError.keyNotFound 以外の DecodingError も throw されなくなってしまうので、あまり好ましくなさそうです。

解決案2

throw された DecodingError.keyNotFound を catch するようにしてみます。

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        if let value = try container.decodeIfPresent(String.self, forKey: .hamburger) {
            self = .hamburger(topping: value)
        } else {
            do {
                if try container.decodeNil(forKey: .pizza) {
                    self = .pizza
                } else {
                    let context = DecodingError.Context(
                        codingPath: container.codingPath,
                        debugDescription: "Unknown case"
                    )
                    throw DecodingError.dataCorrupted(context)
                }
            } catch DecodingError.keyNotFound {
                if try container.decodeNil(forKey: .kebabu) {
                    self = .kebabu
                } else {
                    let context = DecodingError.Context(
                        codingPath: container.codingPath,
                        debugDescription: "Unknown case"
                    )
                    throw DecodingError.dataCorrupted(context)
                }
            } catch {
                throw error
            }
        }
    }

この方法であれば案1で述べたデメリットが解消されそうですが、記述量が多く、 case が増える度に do catch がネストしていってしまいます。

解決案3

これは案2の改良版で、DecodingError.keyNotFound を握りつぶすメソッドを生やします。

extension KeyedDecodingContainer where K : CodingKey {
    func decodeNilIfPresent(forKey key: K) throws -> Bool {
        do {
            return try decodeNil(forKey: key)
        } catch DecodingError.keyNotFound {
            return false
        } catch {
            throw error
        }
    }
}
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        if let value = try container.decodeIfPresent(String.self, forKey: .hamburger) {
            self = .hamburger(topping: value)
        } else if try container.decodeNilIfPresent(forKey: .pizza) {
            self = .pizza
        } else if try container.decodeNilIfPresent(forKey: .kebabu) {
            self = .kebabu
        } else {
            let context = DecodingError.Context(
                codingPath: container.codingPath,
                debugDescription: "Unknown case"
            )
            throw DecodingError.dataCorrupted(context)
        }
    }

これであれば案1、案2のデメリットがカバーされていそうです。

解決案 番外編

そもそも data に null を入れない。   例えば Int とかを入れておいて、 decodeNil(forKey:) ではなく decodeIfPresent(_:,forKey:) を使って値の有無を見るという感じです。

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        if let value = try container.decodeIfPresent(String.self, forKey: .hamburger) {
            self = .hamburger(topping: value)
        } else if try container.decodeIfPresent(Int.self, forKey: .pizza) != nil {
            self = .pizza
        } else if try container.decodeIfPresent(Int.self, forKey: .kebabu) != nil {
            self = .kebabu
        } else {
            let context = DecodingError.Context(
                codingPath: container.codingPath,
                debugDescription: "Unknown case"
            )
            throw DecodingError.dataCorrupted(context)
        }
    }
let jsonString = """
{
    "kebabu": 1
}
"""
let jsonData = jsonString.data(using: .utf8)!
let food = try JSONDecoder().decode(Food.self, from: jsonData)
// => kebabu

この方法であれば、 Int 以外の期待しない型が入っている場合に DecodingError.typeMismatch を throw してくれるようになります。