- The view utilizes the following APIs:
UICollectionView
's compositional layout APIUICollectionViewDiffableDataSource
UIContextMenuConfiguration
- The view is driven by a two-column layout.
- The layout supports three
NSCollectionLayoutGroup
`s:
The source code for this project is available on GitHub.
Let's Start With The Model
struct Model: Hashable {
enum Style {
case compact
case regular
}
let value: Int
let style: Style
let allowsContextMenu: Bool
}
This demo's view model includes three properties:
- an integer
value
representing the value displayed on the cell. - a
style
representing the visual layout:- the
compact
style displays a square that is roughly half the view's width - the
regular
style displays a rectangle that takes up the view's width
- the
- an
allowsContextMenu
flag indicating whether or not a cell can present a context menu
Next, let's look at some example layouts based on different initial Model
configurations.
Example layouts
All compact
let models = [ Model(value: 0, style: .compact), Model(value: 1, style: .compact), Model(value: 2, style: .compact), Model(value: 3, style: .compact), Model(value: 4, style: .compact), Model(value: 5, style: .compact), Model(value: 6, style: .compact), ]
All regular
let models = [ Model(value: 0, style: .regular), Model(value: 1, style: .regular), Model(value: 2, style: .regular), Model(value: 3, style: .regular) ]
Mix-n-match
let models = [ Model(value: 0, style: .regular), Model(value: 1, style: .compact), Model(value: 2, style: .compact), Model(value: 3, style: .compact), Model(value: 4, style: .compact), Model(value: 5, style: .regular) ]
Context menu
Building The Collection View Controller
The entire project is about 500 lines of standard looking UIKit code. The source includes quite a few comments sprinkled throughout. So instead of pulling in lots of code snippets here, let's just focus on how the UICollectionViewCompositionalLayout
builds its NSCollectionLayoutGroup
s.
The bulk of the compositional layout code delegates to a custom strategy named DynamicLayoutGroupProvider
. This provider is called by the UICollectionViewCompositionalLayout
to build a NSCollectionLayoutGroup
based on the arrangement of Model.Styles
. The UICollectionViewCompositionalLayout
code iterates the array of Model.Style
s, and passes the previous style, current style, and next style to the DynamicLayoutGroupProvider
.
protocol DynamicLayoutGroupProvider {
func deriveLayoutGroup(
basedOnPreviousStyle previousStyle: Model.Style?,
currentStyle: Model.Style,
nextStyle: Model.Style?
) -> NSCollectionLayoutGroup?
}
As a reminder, the groups are arranged in three different group styles.
Here's the full source for the default DynamicLayoutGroupProvider
implementation which returns one of the three group styles, or nil
.
final class DefaultDynamicLayoutGroupProvider: DynamicLayoutGroupProvider {
private(set) lazy var compactGroup = lazyCompactGroup()
private(set) lazy var compactOrphanGroup = lazyCompactOrphanGroup()
private(set) lazy var regularGroup = lazyRegularGroup()
private(set) lazy var fullWidthItem = lazyFullWidthItem()
}
extension DefaultDynamicLayoutGroupProvider {
func deriveLayoutGroup(
basedOnPreviousStyle previousStyle: Model.Style?,
currentStyle: Model.Style,
nextStyle: Model.Style?
) -> NSCollectionLayoutGroup? {
// Special case if we are at the end.
guard let nextStyle = nextStyle else {
switch currentStyle {
case .compact:
return compactOrphanGroup
case .regular:
return regularGroup
}
}
switch (previousStyle, currentStyle, nextStyle) {
case (.none, .compact, .compact):
return compactGroup
case (.none, .compact, .regular):
return compactOrphanGroup
case (.compact, .compact, .compact):
return nil
case (.compact, .compact, .regular):
return nil
case (.regular, .compact, .compact):
return compactGroup
case (.regular, .compact, .regular):
return compactOrphanGroup
case (_, .regular, _):
return regularGroup
}
}
}
extension DefaultDynamicLayoutGroupProvider {
private func lazyFullWidthItem() -> NSCollectionLayoutItem {
let layoutSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0)
)
return NSCollectionLayoutItem(layoutSize: layoutSize)
}
private func lazyCompactGroup() -> NSCollectionLayoutGroup {
let compactGroupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalWidth(0.5)
)
let compactGroup = NSCollectionLayoutGroup.horizontal(
layoutSize: compactGroupSize,
subitem: fullWidthItem,
count: 2
)
compactGroup.interItemSpacing = .fixed(16)
return compactGroup
}
private func lazyCompactOrphanGroup() -> NSCollectionLayoutGroup {
let compactOrphanGroupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(0.5),
heightDimension: .fractionalWidth(0.5)
)
return NSCollectionLayoutGroup.horizontal(
layoutSize: compactOrphanGroupSize,
subitem: fullWidthItem,
count: 1
)
}
private func lazyRegularGroup() -> NSCollectionLayoutGroup {
let regularGroupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalWidth(0.5)
)
return NSCollectionLayoutGroup.horizontal(
layoutSize: regularGroupSize,
subitem: fullWidthItem,
count: 1
)
}
}
A Few Wonky Bugs
Contextual menu drag bug
Dragging a view before the previous drag completes
Wrapping Up
There's a lot more code to dig through than can fit into this short article. Overall, the implementation is fairly straightforward, but it's not without its warts. In fact, there are quite a few things that confused me. My confusion is mostly centered around the fact that there are so many different collection view APIs, that when combined together, start a riveting game of whack-a-mole.
Despite the points of confusion, I was able to achieve a nice solution that can be easily extended to support custom cell content views, custom context menu actions, and cell selection navigation.
Another really cool thing is that UICollectionView
provides automatic haptic feedback when reordering the cells.
I hope this post, along with the source code, helps show how to build a moderately complex reordable collection view. Please open a pull request if you hit a bug or have a better way to solve this challenge.