🎞️ Super Simple abstraction layer for building UICollectionView-based UIs with minimal boilerplate.
Implementing UICollectionView across various screens often involves repetitive, error-prone tasks — registering cells, configuring data sources and delegates, or adapting raw server responses to data models. As these tasks pile up screen after screen, the codebase becomes tedious to maintain, especially when each screen handles things a little differently.
The core issue is a lack of separation between rendering logic and interaction logic. Each screen ends up owning too much — it knows how to display data, how to respond to events, and how to talk to the rest of the app.
The design of this library was heavily inspired by pkh0225/CollectionViewAdapter, which solved exactly these problems in a way that felt immediately practical. Introducing a ViewModel as the single source of truth eliminated the data synchronization issues that tend to creep in when view controllers manage collection state directly. Having the adapter take full ownership of UICollectionViewDataSource and UICollectionViewDelegate meant that individual screens no longer needed to reimplement the same boilerplate — they simply bind to a ViewModel and react. And seeing real-world e-commerce features baked into the library made it clear just how much repetitive work a well-designed abstraction can eliminate in production codebases.
SSCollectionViewPresenter follows that same philosophy, while integrating SendingState as its backbone. The presenter drives the UI through type-safe ViewModel binding, and events emitted by cells flow upward through a shared event channel — keeping UI code focused on rendering and interaction logic easy to trace.
SSCollectionViewPresenter is built on a pragmatic take on Apple's MVC architecture:
- Lightweight business logic can remain in the
UIViewController. - For more complex interactions, an
Interactorcan be introduced to separate concerns. - UI components like
UICollectionViewCellcan forward user interactions (buttons, gestures, toggles) to anInteractororUIViewController.
You provide a ViewModel containing:
- A list of
SectionInfo - Each section has a list of
CellInfo(and optional header/footer viaReusableViewInfo)
Then, simply bind the ViewModel to the presenter. The presenter handles:
- Drawing the correct section/cell
- Registering cells and reusable views
- Managing layout & display logic
There's no need to implement UICollectionViewDataSource manually.
Boilerplate-free UICollectionView setup
No need to write custom data sources and delegates repeatedly. The presenter takes full ownership of UICollectionViewDataSource and UICollectionViewDelegate — screens simply bind to a ViewModel and react.
Automatic cell/header/footer registration
Cells and reusable views are registered automatically using type-safe identifiers. NIB files are detected and loaded without any extra configuration.
Section layout control
Each section can carry its own insets, line spacing, interitem spacing, and column count — configurable either through the builder API or via direct assignment on SectionInfo.
Built-in RESTful API pagination
Tracks page and hasNext out of the box. Supports both append-only pagination via extendViewModel and structured page management via loadPage — including per-page replacement and removal.
Server-driven UI composition
Conforms to ServerStateSectionRepresentable and ServerStateUnitRepresentable to compose the UI based on a shared server-client contract. Section and item ordering is determined by the server response — no hardcoded layout decisions on the client side.
Infinite scrolling & auto-rolling
Smooth circular scroll behavior for banner carousels, with optional center-snapping and configurable auto-roll intervals.
Page lifecycle callbacks
Observe and respond to page-level events: onPageWillAppear, onPageDidAppear, onPageWillDisappear, onPageDidDisappear.
Drag & Drop reordering
Supports long-press drag reordering within the collection view. On iPad, external drag & drop is also supported — items can be dragged into or out of other apps using NSItemProvider and UTType-based filtering.
Diffable & traditional data source support
Switch between UICollectionViewDiffableDataSource and the traditional data source with a single parameter at setup time.
Flow layout & compositional layout
Full support for both UICollectionViewFlowLayout and UICollectionViewCompositionalLayout.
Re-exported dependency
SendingState is re-exported, so you can use Configurable, EventForwardingProvider, and other types without an extra import.
struct BannerData: Decodable {
let id: String
let title: String
let imgUrl: String
}Conform to SSCollectionViewCellProtocol, which inherits from Configurable (provided by SendingState).
final class BannerCell: UICollectionViewCell, SSCollectionViewCellProtocol {
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var imgView: UIImageView!
static func size(with input: BannerData?, constrainedTo parentSize: CGSize?) -> CGSize? {
CGSize(width: parentSize?.width ?? 100, height: 200)
}
var configurer: (BannerCell, BannerData) -> Void {
{ view, model in
view.titleLabel.text = model.title
view.imgView.loadWebImage(model.imgUrl)
}
}
}final class HomeViewController: UIViewController {
@IBOutlet weak var collectionView: UICollectionView!
override func viewDidLoad() {
super.viewDidLoad()
collectionView.ss.setupPresenter()
let banners = [
BannerData(id: "1", title: "Summer Sale", imgUrl: "https://your.image.url"),
BannerData(id: "2", title: "Winter Deals", imgUrl: "https://your.image.url")
]
// Option A: Manual construction
let sectionInfo = SSCollectionViewModel.SectionInfo()
for banner in banners {
sectionInfo.appendCellInfo(banner, cellType: BannerCell.self)
}
let viewModel = SSCollectionViewModel(sections: [sectionInfo])
collectionView.ss.setViewModel(with: viewModel)
// Option B: Builder pattern
collectionView.ss.buildViewModel { builder in
builder.section {
builder.cells(banners, cellType: BannerCell.self)
}
}
collectionView.reloadData()
}
}Each section can carry its own layout properties — insets, spacing, and column count — either via direct assignment on SectionInfo or through the builder API.
Using the builder (recommended):
collectionView.ss.buildViewModel { builder in
builder.section("productList") {
builder.sectionInset(UIEdgeInsets(top: 20, left: 16, bottom: 20, right: 16))
builder.minimumLineSpacing(10)
builder.minimumInteritemSpacing(16)
builder.gridColumnCount(2)
builder.cells(products, cellType: ProductCell.self)
}
}Using direct assignment:
let section = SSCollectionViewModel.SectionInfo()
section.sectionInset = UIEdgeInsets(top: 20, left: 16, bottom: 20, right: 16)
section.minimumLineSpacing = 10
section.minimumInteritemSpacing = 16
section.gridColumnCount = 2Available layout properties:
| Property | Type | Description |
|---|---|---|
sectionInset |
UIEdgeInsets? |
Padding around the section's items |
minimumLineSpacing |
CGFloat? |
Minimum spacing between successive rows or columns |
minimumInteritemSpacing |
CGFloat? |
Minimum spacing between items in the same row or column |
gridColumnCount |
Int? |
Number of columns. 0 ignores section insets and stretches each item to full width. A positive value distributes items evenly across the row. |
For straightforward interactions — a tap, a toggle — attach an actionClosure directly in the builder. The closure receives an action name and an optional input payload.
// Cell
builder.cells(products, cellType: ProductCell.self) { indexPath, cell, action, input in
switch action {
case "addToCart":
addToCart(at: indexPath)
default:
break
}
}
// Header / Footer
builder.header(headerData, viewType: SectionHeaderView.self) { section, view, action, input in
switch action {
case "more":
showMore(for: section)
default:
break
}
}When a cell or view emits multiple event types, carries typed payloads, or needs to share a single event channel across sections, conform to EventForwardingProvider from SendingState instead.
final class ProductCell: UICollectionViewCell, SSCollectionViewCellProtocol, EventForwardingProvider {
// UI
@IBOutlet weak var cartButton: UIButton!
@IBOutlet weak var clipButton: UIButton!
@IBOutlet weak var lensButton: UIButton!
@IBOutlet weak var productDetailButton: UIButton!
@IBOutlet weak var refreshButton: UIButton!
// ...
var configurer: (ProductCell, ProductModel) -> Void {
{ view, model in
// configuration code
}
}
var eventForwarder: EventForwardable {
SenderGroup {
EventForwarder(cartButton) { sender, ctx in
ctx.control(.touchUpInside) { (state: ProductModel) in
[TestAction.cart(state.productId)]
}
}
EventForwarder(clipButton) { sender, ctx in
ctx.control(.touchUpInside) (state: ProductModel) {
[TestAction.clip(state.productId)]
}
}
EventForwarder(lensButton) { sender, ctx in
ctx.control(.touchUpInside) (state: ProductModel) {
[TestAction.aiSearch(state.productId)]
}
}
EventForwarder(productDetailButton) { sender, ctx in
ctx.control(.touchUpInside) (state: ProductModel) {
[TestAction.goProductDetail(state.productId)]
}
}
EventForwarder(refreshButton) { sender, ctx in
ctx.control(.touchUpInside) { [TestAction.refresh(sender.tag)] }
}
}
}
}Observe events at the view-controller level through the presenter's shared event channel.
For full
EventForwardingProviderusage, refer to theSendingStatedocumentation.
Cells can respond to delegate-level events by implementing optional methods from SSCollectionViewCellProtocol:
final class MyCell: UICollectionViewCell, SSCollectionViewCellProtocol {
// ...
func didSelect(with input: MyModel?) {
// Handle selection
}
func willDisplay(with input: MyModel?) {
// Called just before the cell appears
}
func didEndDisplaying(with input: MyModel?) {
// Called after the cell disappears
}
}Available lifecycle methods:
| Method | Description |
|---|---|
willDisplay(with:) |
Called before the view appears |
didEndDisplaying(with:) |
Called after the view disappears |
didHighlight(with:) |
Called on touch-down |
didUnhighlight(with:) |
Called on touch-up |
didSelect(with:) |
Called on selection |
didDeselect(with:) |
Called on deselection |
willDisplayanddidEndDisplayingare available on both cells and reusable views (headers/footers).
reconfigureItem(_:at:), reconfigureHeader(_:at:), and reconfigureFooter(_:at:) replace the underlying state (model) of a visible view and re-invoke its configurer in place — no full reload needed. Use this whenever only the data of an existing view has changed.
// Update a cell's state
let updated = ProductModel(id: "00000011", title: "Test Product", price: 30)
collectionView.ss.reconfigureItem(updated, at: indexPath)
// Update a header / footer
collectionView.ss.reconfigureHeader(SectionHeaderData(title: "New Event"), at: 0)
collectionView.ss.reconfigureFooter(FooterData(text: "More Events"), at: 0)toggleSection(_:completion:) flips the isCollapsed flag of the given section and triggers a data source update. The completion closure delivers the new collapsed state — use it to push updated state into any header or footer whose appearance depends on it (a chevron, a label, etc.).
collectionView.ss.toggleSection(sectionIndex) { [weak self] collapsed in
guard let self = self else { return }
let updated = SectionHeaderData(title: "Products", isExpanded: !collapsed)
self.collectionView.ss.reconfigureHeader(updated, at: sectionIndex)
}The section parameter passed into a header or footer actionClosure is the section index itself, so it can be forwarded directly to toggleSection:
builder.header(headerData, viewType: SectionHeaderView.self) { [weak self] section, view, action, input in
guard let self = self else { return }
switch action {
case "toggle":
guard let state = input as? SectionHeaderData else { return }
self.collectionView.ss.toggleSection(section) { collapsed in
let updated = SectionHeaderData(title: state.title, isExpanded: !collapsed)
self.collectionView.ss.reconfigureHeader(updated, at: section)
}
default:
break
}
}When using EventForwardingProvider, the handler receives no index by default. The view must embed its own position in the forwarded payload so the handler can pass it to toggleSection.
- Headers / footers — include
sectionIndex - Cells — include
indexPath
// Header view — embed sectionIndex in the payload
final class SectionHeaderView: UICollectionReusableView, EventForwardingProvider {
@IBOutlet weak var filterButton: UIButton!
@IBOutlet weak var sortButton: UIButton!
@IBOutlet weak var searchButton: UIButton!
@IBOutlet weak var closeButton: UIButton!
@IBOutlet weak var collapseButton: UIButton!
var configurer: (SectionHeaderView, SectionHeaderModel) -> Void {
{ view, model in
view.titleLabel.text = model.title
view.collapseButton.setImage(UIImage(named: "chevron_down"), for: .normal)
view.collapseButton.setImage(UIImage(named: "chevron_right"), for: .selected)
view.collapseButton.isSelected = model.isExpanded ? false : true
}
}
var eventForwarder: EventForwardable {
SenderGroup {
EventForwarder(filterButton) { sender, ctx in
ctx.control(.touchUpInside) {
[TestAction.filter]
}
}
EventForwarder(sortButton) { sender, ctx in
ctx.control(.touchUpInside) {
[TestAction.sort]
}
}
EventForwarder(searchButton) { sender, ctx in
ctx.control(.touchUpInside) {
[TestAction.search]
}
}
EventForwarder(closeButton) { sender, ctx in
ctx.control(.touchUpInside) {
[TestAction.close]
}
}
EventForwarder(collapseButton) { sender, ctx in
ctx.control(.touchUpInside) { [weak self] in
guard let self, let state = self.ss.state() else { return [TestAction]() }
return [TestAction.toggle(self.sectionIndex, state)]
}
}
}
}
}
// Action handler (view controller or interactor)
final class TestStoreViewController: UIViewController, ActionHandlingProvider {
@IBOutlet weak var collectionView: UICollectionView!
// ...
func handle(action: TestAction) {
switch action {
case .toggle(let sectionIndex):
collectionView.ss.toggleSection(sectionIndex, state) { [weak self] collapsed in
guard let self = self else { return }
let newState = SectionHeaderModel(title: state.title, isExpanded: !collapsed)
self.collectionView.ss.reconfigureHeader(newState, at: sectionIndex)
}
default:
break
}
}
}extendViewModel is useful for simple append-only pagination. For more structured control — such as replacing or removing individual pages — use loadPage instead.
If your collection view should load more data when the user scrolls near the end, use onNextRequest:
collectionView.ss.onNextRequest { [weak self] viewModel in
guard let self = self else { return }
NetworkingManager.fetchNextPage(current: viewModel.page) { result in
guard case .success(let response) = result else { return }
self.collectionView.ss.extendViewModel(
page: response.page,
hasNext: response.hasNext
) { builder in
builder.section("productList") {
builder.cells(response.products, cellType: ProductCell.self)
}
}
self.collectionView.reloadData()
}
}extendViewModel merges by section identifier — if a section with the same ID exists, new items are appended to it. Otherwise, a new section is added.
For typical RESTful APIs that return paginated responses, loadPage lets you store each page's sections independently. The presenter merges all stored pages into a single flat list internally — sections with the same identifier across pages are concatenated, while unnamed sections are simply appended.
loadPage accepts either an array of SectionInfo or a builder closure:
// Initial load
collectionView.ss.loadPage(0, hasNext: true) { builder in
builder.section("banner") {
builder.cells(banners, cellType: BannerCell.self)
}
builder.section("productList") {
builder.cells(products, cellType: ProductCell.self)
}
}
collectionView.reloadData()Combine it with onNextRequest to handle pagination seamlessly:
collectionView.ss.onNextRequest { [weak self] viewModel in
guard let self = self else { return }
NetworkingManager.fetchNextPage(current: viewModel.page + 1) { result in
guard case .success(let response) = result else { return }
self.collectionView.ss.loadPage(response.page, hasNext: response.hasNext) { builder in
builder.section("productList") {
builder.cells(response.productList, cellType: ProductCell.self)
}
}
self.collectionView.reloadData()
}
}Because each page is stored separately, you can replace or remove any individual page without affecting the rest:
// Replace page 2 with fresh data (e.g. after an item edit)
collectionView.ss.loadPage(2, hasNext: true) { builder in
builder.section("productList") {
builder.cells(updatedProducts, cellType: ProductCell.self)
}
}
// Remove a specific page
collectionView.ss.removePage(2)
// Pull-to-refresh: clear everything and start over
var viewModel = collectionView.ss.getViewModel()
viewModel?.removeAllPages()
collectionView.ss.setViewModel(with: viewModel ?? SSCollectionViewModel())You can also query page state directly on the view model:
| Property / Method | Description |
|---|---|
page |
The most recently loaded page number |
hasNext |
Whether more pages are available |
pageCount |
Number of stored pages |
hasPageData |
true if at least one page is stored |
sections(forPage:) |
Returns the sections for a specific page |
findPage(forSectionIdentifier:) |
Finds the latest page containing a given section ID |
Merge rules: When multiple pages contain sections with the same
identifier, their items are merged into one section in page order. Headers and footers from later pages take precedence. Sections without an identifier are never merged — they're always appended as separate sections.
This pattern was born out of hands-on experience developing module units for Template stores at SSG.COM. In that system, each page is composed of a server-defined list of sections — called templates — and each template contains an ordered set of UI modules called units. The server owns both the structure and the ordering of the page; the client simply renders whatever it receives, without hardcoding any layout decisions into the view controller. Working within that contract at production scale made the value of a clean, protocol-driven abstraction immediately obvious — and that experience shaped the design of this feature directly.
SendingState provides the server-driven UI contracts through two protocols:
ServerStateSectionRepresentable— represents a single section returned by the server, carrying an optionalsectionIdand an ordered list of units.ServerStateUnitRepresentable— represents a single UI module within a section, identified by aunitTypestring and an associatedunitDatapayload.
SSCollectionViewPresenter can use these contracts to render server-provided sections and units.
collectionView.ss.buildViewModel { builder in
builder.sections(
result.sectionList,
configureSection: { section, builder in
guard let sectionId = section.sectionId else { return }
switch sectionId {
case "ProductList":
builder.sectionInset(.init(top: 20, left: 15, bottom: 20, right: 15))
builder.minimumLineSpacing(15)
case "TripleItems":
builder.sectionInset(.init(top: 20, left: 10, bottom: 20, right: 10))
builder.minimumLineSpacing(10)
builder.minimumInteritemSpacing(1)
default:
builder.sectionInset(.zero)
builder.minimumLineSpacing(0)
builder.minimumInteritemSpacing(0)
}
},
configureUnit: { unit, builder in
switch unit.unitType {
case "SS_TOP_BANNER":
guard let bannerList = unit.unitData as? [BannerModel] else { return }
builder.cell(bannerList, cellType: TopBannerCell.self)
case "SS_PRODUCT_LIST":
guard let productList = unit.unitData as? [ProductModel] else { return }
builder.cells(productList, cellType: ProductCell.self)
case "SS_MY_FAVORITES":
guard let myFavorites = unit.unitData as? MyFavoritesModel else { return }
if let titleInfo = myFavorites.titleInfo {
builder.header(titleInfo, viewType: MyFavoriteHeaderView.self)
}
builder.cells(myFavorites.productList, cellType: ProductCell.self)
default:
break
}
}
)
}
collectionView.reloadData()configureSection runs first for each section — allowing layout properties to be applied before configureUnit adds the cells. Both closures receive the builder, so the full layout API remains available at every stage.
Tip — conforming to the protocols: Define one conforming type per screen or API context, since each endpoint typically follows its own data contract. If two screens share the same structure but differ in layout rules, prefer subclassing over duplication. If
configureUnitclosures start to look repetitive across screens, extract the shared logic into a factory.If the server doesn't provide section identifiers and instead returns a nested array of units, decode the response as
[[any ServerStateUnitRepresentable]]and initialize a conforming type per inner array — settingsectionIdtonilor a derived index value.
Enable infinite scrolling or auto-rolling banners with a single call:
// Center-aligned paging with infinite scroll and auto-rolling
collectionView.ss.setPagingEnabled(.init(
isAlignCenter: true,
isInfinitePage: true,
isAutoRolling: true,
autoRollingTimeInterval: 4.0
))PagingConfiguration parameters:
| Parameter | Default | Description |
|---|---|---|
isEnabled |
true |
Enables custom paging (replaces UIScrollView.isPagingEnabled) |
isAlignCenter |
false |
Snaps the current page to the center of the viewport |
isLooping |
false |
Wraps around when reaching either end |
isInfinitePage |
false |
Enables infinite scrolling by duplicating content |
isAutoRolling |
false |
Automatically scrolls at a fixed interval |
autoRollingTimeInterval |
3.0 |
Seconds between auto-scroll transitions |
isInfinitePagevsisLooping: Both create a wrap-around effect, but they differ in feel.isInfinitePageduplicates content to produce seamless, continuous scrolling — ideal for banners where the transition should feel uninterrupted.isLoopingsnaps back to the first page explicitly, which can feel cleaner when the jump is intentional. Choose based on the UX you're after.
Requirements (flow layout): This feature requires a single section with uniformly-sized items. For best results, avoid headers/footers and disable
isPagingEnabledon the scroll view.
You can also control paging programmatically:
collectionView.ss.moveToNextPage()
collectionView.ss.moveToPreviousPage()Track which page a user is viewing — useful for analytics, journey maps, or triggering animations:
collectionView.ss.onPageWillAppear { collectionView, pageIndex in
print("Page \(pageIndex) is about to appear")
}
collectionView.ss.onPageDidAppear { collectionView, pageIndex in
print("Page \(pageIndex) appeared")
}
collectionView.ss.onPageWillDisappear { collectionView, pageIndex in
print("Page \(pageIndex) is about to disappear")
}
collectionView.ss.onPageDidDisappear { collectionView, pageIndex in
print("Page \(pageIndex) disappeared")
}If you need to observe scroll events from outside the presenter:
collectionView.ss.setScrollViewDelegateProxy(self)The presenter will forward UIScrollViewDelegate calls to the proxy.
Enable reordering with a single call:
collectionView.ss.setReorderEnabled(true)To restrict which items can be dragged:
collectionView.ss.onCanDragItem { cellInfo in
// Return false to prevent dragging that item
return cellInfo.identifier != "pinned"
}Observe reorder events before and after they apply:
collectionView.ss.onWillReorder { items in
print("About to move: \(items.map { $0.indexPath })")
}
collectionView.ss.onDidReorder { items, destination in
print("Moved to: \(destination)")
}To customize the drag preview:
// Custom view
collectionView.ss.setDragPreviewProvider { cellInfo in
let view = MyPreviewView()
view.configure(with: cellInfo)
return view
}
// Custom parameters (e.g. corner radius, shadow)
collectionView.ss.onDragPreviewParameters { indexPath in
let params = UIDragPreviewParameters()
params.visiblePath = UIBezierPath(roundedRect: .init(x: 0, y: 0, width: 120, height: 120), cornerRadius: 8)
return params
}On iPad, items can be dragged into or out of other apps. Supply an NSItemProvider for outgoing drags and register a handler for incoming drops:
// Outgoing — provide a payload for external drops
collectionView.ss.setDragItemProvider { cell, cellInfo in
guard let text = cellInfo.data as? String else { return nil }
return NSItemProvider(object: text as NSString)
}
// Incoming — specify accepted types and handle the drop
collectionView.ss.setAcceptedExternalDropTypeIdentifiers(
[UTType.plainText.identifier]
)
collectionView.ss.onExternalDrop { value, indexPath in
guard let text = value as? String else { return nil }
return SSCollectionViewModel.CellInfo(data: text, cellType: MyCell.self)
}To use the modern diffable data source (iOS 13+), pass .diffable when setting up:
collectionView.ss.setupPresenter(dataSourceMode: .diffable)
collectionView.ss.buildViewModel { builder in
builder.section("main") {
builder.cells(items, cellType: ItemCell.self)
}
}
// Use applySnapshot instead of reloadData
collectionView.ss.applySnapshot(animated: true)When using diffable mode, call
applySnapshot(animated:)instead ofreloadData()to apply changes with optional animations.
For more advanced layouts, use .compositional with SSCompositionalLayoutSection (iOS 13+):
let sections = [
SSCompositionalLayoutSection(
direction: .horizontal,
columns: 1,
height: 200,
scrolling: .paging
),
SSCompositionalLayoutSection(
direction: .vertical,
columns: 2,
height: 150
)
]
let config = SSCollectionViewPresenter.CompositionalLayoutConfig(sections: sections)
collectionView.ss.setupPresenter(layoutKind: .compositional(config))SSCompositionalLayoutSection parameters:
| Parameter | Type | Description |
|---|---|---|
direction |
UICollectionView.ScrollDirection |
.horizontal or .vertical |
columns |
Int |
Number of columns (default: 1) |
itemWidth |
CGFloat? |
Fixed item width; if nil, auto-calculated from columns |
height |
CGFloat |
Item height |
scrolling |
ScrollingBehavior? |
Orthogonal scrolling behavior (none, continuous, paging, etc.) |
SSCollectionViewPresenter is available via Swift Package Manager.
- Open your project in Xcode
- Go to File > Add Packages...
- Enter the URL:
https://github.com/dSunny90/SSCollectionViewPresenter
- Select the version and finish
dependencies: [
.package(url: "https://github.com/dSunny90/SSCollectionViewPresenter", from: "1.0.0")
]