Building A Reorderable UICollectionView

October 12, 2021
The goal is simple: build a reordable grid view based only on stock `UICollectionView` API.
  • The view utilizes the following APIs:
    • UICollectionView's compositional layout API
    • UICollectionViewDiffableDataSource
    • UIContextMenuConfiguration
  • The view is driven by a two-column layout.
  • The layout supports three NSCollectionLayoutGroup`s:
  • The layout must dynamically adjust while the user drags cells around.
  • The user must be able to long-press on cell to present a context menu.
  • 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
    • 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

    Long-press to present a standard iOS 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 NSCollectionLayoutGroups.

    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.Styles, 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.

    Layout Groups

    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

    There's a bug in UIKit that causes the center position of a dragged view to adjust downward when the drag begins from a contextual menu preview view. This bug does not appear to negatively impact the drop behavior.

    Dragging a view before the previous drag completes

    Here's another UIKit bug that allows a new drag operation to begin before the current drag ends. It's fairly easy to reproduce. Simply drag and drop a cell, then immediately long-press on the same cell to initiate another drag operation. The new drag operation begins before the current drag ends. The end result is a drag operation that renders incorrectly (completely transparent and with an "add" behavior).

    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.