Last active
August 11, 2025 04:38
-
-
Save mattadatta/667fe7f8f176dda2a9cf99c635eb4c55 to your computer and use it in GitHub Desktop.
CollectionView.swift - UIKit's UICollectionView implemented in SwiftUI using UIViewRepresentable
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // 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