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