Skip to content

Instantly share code, notes, and snippets.

@mattadatta
Last active August 11, 2025 04:38
Show Gist options
  • Select an option

  • Save mattadatta/667fe7f8f176dda2a9cf99c635eb4c55 to your computer and use it in GitHub Desktop.

Select an option

Save mattadatta/667fe7f8f176dda2a9cf99c635eb4c55 to your computer and use it in GitHub Desktop.
CollectionView.swift - UIKit's UICollectionView implemented in SwiftUI using UIViewRepresentable
//
// SwiftUI `CollectionView` type implemented with UIKit's `UICollectionView` under the hood.
// Implemented using `UIViewRepresentable`, assigning a custom `UIHostingConfiguration` to
// the `contentConfiguration` property of custom `UICollectionViewCell`s.
//
// See below implementation for usage.
//
import SwiftUI
import UIKit
public struct CollectionView
<Collections, CellContent, CellBackground>
: UIViewRepresentable
where
Collections : RandomAccessCollection,
Collections.Index == Int,
Collections.Element : RandomAccessCollection,
Collections.Element.Index == Int,
Collections.Element.Element : Identifiable,
CellContent : View,
CellBackground : View
{
public typealias Section = Collections.Element
public typealias Item = Section.Element
public typealias ContentForItem = (Item) -> CellContent
public typealias ScrollDirection = UICollectionView.ScrollDirection
public typealias SizeForItem = (UICollectionView, Item) -> CGSize
public typealias CollectionViewConfigure = (UICollectionView) -> Void
public typealias CollectionViewUpdate = (UICollectionView) -> Bool
public typealias CollectionViewDiff = (Self, Self, UICollectionView) -> Void
public typealias HostingConfiguration = UIHostingConfiguration<HostedContentCell, CellBackground>
public typealias HostingConfigurationForItem = (UIHostingConfiguration<HostedContentCell, EmptyView>, Item) -> HostingConfiguration
public typealias GenerateDefaultLayoutAttributes = () -> UICollectionViewLayoutAttributes
public typealias LayoutAttributesForCell = (
UICollectionView,
UICollectionViewCell,
UICollectionViewLayoutAttributes,
Item,
GenerateDefaultLayoutAttributes
) -> UICollectionViewLayoutAttributes
fileprivate typealias Constants = CollectionViewConstants
public enum ItemSize: Equatable {
case fixed(CGSize)
case crossAxisFilled(mainAxisLength: CGFloat)
case custom(SizeForItem)
case automatic(LayoutAttributesForCell?)
fileprivate var isAutomatic: Bool {
return switch self {
case .automatic:
true
default:
false
}
}
public static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case (.fixed(let lhs), .fixed(let rhs)):
return lhs == rhs
case (.crossAxisFilled(let lhs), .crossAxisFilled(let rhs)):
return lhs == rhs
case (.custom, .custom):
return true
case (.automatic, .automatic):
return true
default:
return false
}
}
}
public struct ItemSpacing: Equatable {
public var minLineSpacing: CGFloat
public var minInteritemSpacing: CGFloat
public init(
minLineSpacing: CGFloat = 10,
minInteritemSpacing: CGFloat = 0
) {
self.minLineSpacing = minLineSpacing
self.minInteritemSpacing = minInteritemSpacing
}
}
fileprivate var collections: Collections
fileprivate var contentForItem: ContentForItem
fileprivate var hostingConfigForItem: HostingConfigurationForItem
fileprivate var scrollDirection: ScrollDirection
fileprivate var itemSize: ItemSize
fileprivate var itemSpacing: ItemSpacing
fileprivate var onViewCreate: CollectionViewConfigure?
fileprivate var onViewUpdate: CollectionViewUpdate?
fileprivate var onViewDiff: CollectionViewDiff
public init(
collections: Collections,
scrollDirection: ScrollDirection = .vertical,
itemSize: ItemSize,
itemSpacing: ItemSpacing = .init(),
onViewCreate: CollectionViewConfigure? = nil,
onViewUpdate: CollectionViewUpdate? = nil,
onViewDiff: CollectionViewDiff? = nil,
hostingConfigForItem: @escaping HostingConfigurationForItem,
@ViewBuilder contentForItem: @escaping ContentForItem
) {
self.collections = collections
self.scrollDirection = scrollDirection
self.itemSize = itemSize
self.itemSpacing = itemSpacing
self.onViewCreate = onViewCreate
self.onViewUpdate = onViewUpdate
self.onViewDiff = onViewDiff ?? Self.defaultReloader
self.hostingConfigForItem = hostingConfigForItem
self.contentForItem = contentForItem
}
public func makeCoordinator() -> Coordinator {
return Coordinator(view: self)
}
public func makeUIView(context: Context) -> UICollectionView {
let coordinator = context.coordinator
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = self.scrollDirection
if self.itemSize.isAutomatic {
layout.estimatedItemSize = Constants.automaticItemSize
}
if case let .fixed(size) = self.itemSize {
layout.itemSize = size
}
layout.minimumLineSpacing = self.itemSpacing.minLineSpacing
layout.minimumInteritemSpacing = self.itemSpacing.minInteritemSpacing
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.backgroundColor = nil
collectionView.dataSource = coordinator
collectionView.delegate = coordinator
self.onViewCreate?(collectionView)
coordinator.uiView = collectionView
coordinator.layout = layout
return collectionView
}
public func updateUIView(_ uiView: UICollectionView, context: Context) {
let oldView = context.coordinator.view
context.coordinator.view = self
guard let layout = context.coordinator.layout else { return }
let updateInvalidatedLayout = self.onViewUpdate?(uiView) ?? false
let hasNewScrollDirection = oldView.scrollDirection != self.scrollDirection
let hasNewSize = oldView.itemSize != self.itemSize
let hasNewSpacing = oldView.itemSpacing != self.itemSpacing
if hasNewScrollDirection {
layout.scrollDirection = self.scrollDirection
}
if hasNewSize {
let estimatedItemSize: CGSize = self.itemSize.isAutomatic ? Constants.automaticItemSize : .zero
layout.estimatedItemSize = estimatedItemSize
let itemSize: CGSize = if case let .fixed(size) = self.itemSize { size } else { Constants.defaultItemSize }
layout.itemSize = itemSize
}
if hasNewSpacing {
layout.minimumLineSpacing = self.itemSpacing.minLineSpacing
layout.minimumInteritemSpacing = self.itemSpacing.minInteritemSpacing
}
let hasLayoutUpdates = updateInvalidatedLayout || hasNewScrollDirection || hasNewSize || hasNewSpacing
if hasLayoutUpdates {
layout.invalidateLayout()
}
self.onViewDiff(oldView, self, uiView)
}
private static func defaultReloader(_ oldView: Self, newView: Self, collectionView: UICollectionView) {
let collectionsEqual = oldView.collections.elementsEqual(newView.collections, by: { lhs, rhs in
return lhs.elementsEqual(rhs, by: { lhs, rhs in lhs.id == rhs.id })
})
if !collectionsEqual {
collectionView.reloadData()
}
}
}
@MainActor
private struct CollectionViewConstants {
static let defaultItemSize: CGSize = .init(width: 50, height: 50)
static let automaticItemSize: CGSize = UICollectionViewFlowLayout.automaticSize
}
extension CollectionView {
public init(
collections: Collections,
scrollDirection: ScrollDirection = .vertical,
itemSize: ItemSize,
itemSpacing: ItemSpacing = .init(),
onViewCreate: CollectionViewConfigure? = nil,
onViewUpdate: CollectionViewUpdate? = nil,
onViewDiff: CollectionViewDiff? = nil,
@ViewBuilder contentForItem: @escaping ContentForItem
) where CellBackground == EmptyView {
self.init(
collections: collections,
scrollDirection: scrollDirection,
itemSize: itemSize,
itemSpacing: itemSpacing,
onViewCreate: onViewCreate,
onViewUpdate: onViewUpdate,
onViewDiff: onViewDiff,
hostingConfigForItem: { config, _ in config },
contentForItem: contentForItem)
}
public init<Collection>(
items: Collection,
scrollDirection: ScrollDirection = .vertical,
itemSize: ItemSize,
itemSpacing: ItemSpacing = .init(),
onViewCreate: CollectionViewConfigure? = nil,
onViewUpdate: CollectionViewUpdate? = nil,
onViewDiff: CollectionViewDiff? = nil,
hostingConfigForItem: @escaping HostingConfigurationForItem,
@ViewBuilder contentForItem: @escaping ContentForItem
) where Collections == [Collection] {
self.init(
collections: [items],
scrollDirection: scrollDirection,
itemSize: itemSize,
itemSpacing: itemSpacing,
onViewCreate: onViewCreate,
onViewUpdate: onViewUpdate,
onViewDiff: onViewDiff,
hostingConfigForItem: hostingConfigForItem,
contentForItem: contentForItem)
}
public init<Collection>(
items: Collection,
scrollDirection: ScrollDirection = .vertical,
itemSize: ItemSize,
itemSpacing: ItemSpacing = .init(),
onViewCreate: CollectionViewConfigure? = nil,
onViewUpdate: CollectionViewUpdate? = nil,
onViewDiff: CollectionViewDiff? = nil,
@ViewBuilder contentForItem: @escaping ContentForItem
) where Collections == [Collection], CellBackground == EmptyView {
self.init(
collections: [items],
scrollDirection: scrollDirection,
itemSize: itemSize,
itemSpacing: itemSpacing,
onViewCreate: onViewCreate,
onViewUpdate: onViewUpdate,
onViewDiff: onViewDiff,
contentForItem: contentForItem)
}
}
extension CollectionView {
public final class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
private typealias CellRegistration = UICollectionView.CellRegistration<HostingCollectionViewCell, Item>
fileprivate var view: CollectionView
fileprivate var uiView: UICollectionView?
fileprivate var layout: UICollectionViewFlowLayout?
private var cellRegistration: CellRegistration?
fileprivate init(view: CollectionView) {
self.view = view
super.init()
self.cellRegistration = CellRegistration { [unowned self] cell, indexPath, item in
cell.host(item, at: indexPath, from: self)
}
}
public func numberOfSections(
in collectionView: UICollectionView
) -> Int {
return self.view.collections.count
}
public func collectionView(
_ collectionView: UICollectionView,
numberOfItemsInSection section: Int
) -> Int {
return self.view.collections[section].count
}
public func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let item = self.view.collections[indexPath.section][indexPath.item]
let cell = collectionView.dequeueConfiguredReusableCell(
using: self.cellRegistration!,
for: indexPath,
item: item)
return cell
}
public func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
switch self.view.itemSize {
case .fixed(let size):
return size
case .crossAxisFilled(let mainAxisLength):
switch self.view.scrollDirection {
case .horizontal:
return CGSize(
width: mainAxisLength,
height: collectionView.bounds.height)
case .vertical:
fallthrough
@unknown default:
return CGSize(
width: collectionView.bounds.width,
height: mainAxisLength)
}
case .custom(let sizeForData):
let data = self.view.collections[indexPath.section][indexPath.item]
return sizeForData(collectionView, data)
case .automatic(_):
return Constants.defaultItemSize
}
}
}
}
private extension CollectionView {
final class HostingCollectionViewCell: UICollectionViewCell {
private var indexPath: IndexPath?
private weak var coordinator: Coordinator?
override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
let generateDefaultAttributes: GenerateDefaultLayoutAttributes = { super.preferredLayoutAttributesFitting(layoutAttributes) }
guard let indexPath, let swiftUIView = coordinator?.view, let collectionView = coordinator?.uiView else { return generateDefaultAttributes() }
let item = swiftUIView.collections[indexPath.section][indexPath.item]
let layoutAttrsForCell: CollectionView.LayoutAttributesForCell = switch swiftUIView.itemSize {
case .automatic(let attrsForCell):
attrsForCell ?? Self.computeDefaultAutomaticLayoutAttributes
default:
Self.computeDefaultAutomaticLayoutAttributes
}
let modifiedAttrs = layoutAttrsForCell(
collectionView,
self,
layoutAttributes,
item,
generateDefaultAttributes)
return modifiedAttrs
}
func host(
_ item: Item,
at indexPath: IndexPath,
from coordinator: Coordinator
) {
self.indexPath = indexPath
self.coordinator = coordinator
let contentForItem = coordinator.view.contentForItem
let hostingConfigForItem = coordinator.view.hostingConfigForItem
let contentConfiguration = UIHostingConfiguration {
HostedContentCell(
item: item,
layoutHandle: .init(
invalidateIntrinsicContentSize: { [weak self] in
guard let self else { return }
self.invalidateIntrinsicContentSize()
},
invalidateLayout: { [weak self] in
guard let self else { return }
self.onCellLayoutInvalidated()
},
layoutIfNeeded: { [weak self] in
guard let self else { return }
self.onCellLayoutIfNeeded()
}
)
) {
contentForItem(item)
}
}
self.contentConfiguration = hostingConfigForItem(contentConfiguration, item)
}
private func onCellLayoutInvalidated() {
guard let collectionView = coordinator?.uiView else { return }
collectionView.collectionViewLayout.invalidateLayout()
}
private func onCellLayoutIfNeeded() {
guard let collectionView = coordinator?.uiView else { return }
collectionView.layoutIfNeeded()
}
static func computeDefaultAutomaticLayoutAttributes(
in collectionView: UICollectionView,
for cell: UICollectionViewCell,
attributes: UICollectionViewLayoutAttributes,
with item: Item,
_ generateDefaultAttrs: () -> UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
return generateDefaultAttrs()
}
static func computeContentSizeAutomaticLayoutAttributes(
in collectionView: UICollectionView,
for cell: UICollectionViewCell,
attributes: UICollectionViewLayoutAttributes,
with item: Item,
_ generateDefaultAttrs: () -> UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
let contentSize = cell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
attributes.size = contentSize
return attributes
}
}
}
extension CollectionView {
public struct HostedContentCell: View {
public typealias Handle = EnvironmentValues.CollectionViewCellLayoutHandle
var item: Item
var layoutHandle: Handle
var content: () -> CellContent
init(
item: Item,
layoutHandle: Handle,
@ViewBuilder content: @escaping () -> CellContent
) {
self.item = item
self.layoutHandle = layoutHandle
self.content = content
}
public var body: some View {
content()
.id(item.id)
.environment(\.cellLayoutHandle, layoutHandle)
}
}
}
extension EnvironmentValues {
public struct CollectionViewCellLayoutHandle {
public typealias Callback = () -> Void
public var invalidateIntrinsicContentSize: Callback
public var invalidateLayout: Callback
public var layoutIfNeeded: Callback
fileprivate init(
invalidateIntrinsicContentSize: @escaping Callback,
invalidateLayout: @escaping Callback,
layoutIfNeeded: @escaping Callback
) {
self.invalidateIntrinsicContentSize = invalidateIntrinsicContentSize
self.invalidateLayout = invalidateLayout
self.layoutIfNeeded = layoutIfNeeded
}
}
@Entry public var cellLayoutHandle: CollectionViewCellLayoutHandle = .init(
invalidateIntrinsicContentSize: { },
invalidateLayout: { },
layoutIfNeeded: { })
}
// Usage (just copy paste with above CollectionView and enable previews):
struct MyCustomItem : Identifiable {
var index: Int
var text: String
var id: String { "\(index)_\(text)" } // Be mindful, just for demo simplicity
}
struct MyCustomCell : View {
@State var isDisplaying: Bool = false
var item: MyCustomItem
var body: some View {
HStack(spacing: 0) {
Button(action: { }) {
Text(item.text)
.font(.system(size: 16)) // Font should match in sizeForItem(_:_:) below
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
.buttonStyle(MyCustomButtonStyle())
.cornerRadius(8)
}
.scaleEffect(isDisplaying ? 1 : 2)
.opacity(isDisplaying ? 1 : 0)
.onAppear {
withAnimation(.spring()) {
isDisplaying = true
}
}
}
// Example if your view sizes itself based on its content
static func sizeForItem(
_ collectionView: UICollectionView,
_ item: MyCustomItem
) -> CGSize {
let height = collectionView.frame.height
let uiFont = UIFont.systemFont(ofSize: 16)
let attrs: [NSAttributedString.Key: Any] = [.font: uiFont]
let size = (item.text as NSString).size(withAttributes: attrs)
let horizontalPadding: CGFloat = 32
let widthWithPadding = size.width.rounded(.up) + horizontalPadding
let availableHeight = height - (collectionView.contentInset.top + collectionView.contentInset.bottom)
return .init(
width: widthWithPadding,
height: availableHeight)
}
}
struct MyCustomButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
let isPressed = configuration.isPressed
return configuration.label
.foregroundColor(isPressed ? .white : .black)
.background(Material.ultraThinMaterial.opacity(isPressed ? 0.0 : 0.5))
.background(isPressed ? .black : .clear)
}
}
struct MyCustomView : View {
@State var items = [
"sky", "marble", "quantum", "leaf", "synchronize", "river",
"glyph", "banana", "cipher", "wander", "echo", "lullaby",
"pneumatic", "twig", "horizon", "brisk", "plasma", "orbit",
"cascade", "nebula", "fjord", "quartz", "lantern", "flux",
"zenith", "stellar", "pixel", "ember", "whistle", "dynamo",
"serendipity", "vault", "amber", "velocity", "foxtrot",
"gossamer", "myriad", "nocturne", "puzzle", "ripple",
"saffron", "tangent", "ultraviolet", "voyage", "waffle",
"yonder", "zephyr", "alpine", "blossom", "cobweb",
"dragonfly", "ephemeral", "fable", "galaxy", "harvest",
"inkling", "jigsaw", "krypton", "lighthouse", "mosaic",
"nimbus", "overture", "paradox", "quiver", "rumble",
"sonnet", "thistle", "undertow", "violet", "wanderlust",
"xylophone", "yearn", "zeppelin", "aurora", "blink",
"crimson", "daybreak", "emberline", "firefly", "gust",
"halcyon", "isotope", "jubilant", "kaleidoscope",
"labyrinth", "monsoon", "nectar", "onyx", "petal",
"quench", "rendezvous", "silhouette", "timber", "umbra",
"vortex", "whimsy", "xenon", "yodel", "zen"
].shuffled().enumerated().map({ MyCustomItem(index: $0, text: "\($1)") })
var body: some View {
CollectionView(
items: items,
scrollDirection: .horizontal,
itemSize: .custom(MyCustomCell.sizeForItem),
itemSpacing: .init(minInteritemSpacing: 8),
onViewCreate: { collectionView in
collectionView.contentInset = .init(top: 4, left: 4, bottom: 4, right: 4)
collectionView.showsHorizontalScrollIndicator = false
collectionView.alwaysBounceHorizontal = true
collectionView.delaysContentTouches = false
},
hostingConfigForItem: { config, _ in
config.margins(.all, 0)
}
) { item in
MyCustomCell(item: item)
}
.frame(height: 48)
}
}
#Preview {
MyCustomView()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment