├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE.md ├── Package.resolved ├── Package.swift ├── README.md └── Sources └── LazyCollection ├── LazyCollection.swift └── Private ├── EnvironmentValues.swift ├── HostingCell.swift ├── HostingCellRootView.swift ├── Previews.swift ├── SwiftUICollectionViewController.swift ├── UICollectionView+StagedChangeset.swift └── View+Modifiers.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2021 Sphere Knowledge Limited. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "DifferenceKit", 6 | "repositoryURL": "https://github.com/ra1028/DifferenceKit", 7 | "state": { 8 | "branch": null, 9 | "revision": "62745d7780deef4a023a792a1f8f763ec7bf9705", 10 | "version": "1.2.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "LazyCollection", 6 | platforms: [.iOS(.v13)], 7 | products: [ 8 | .library(name: "LazyCollection", targets: ["LazyCollection"]) 9 | ], 10 | dependencies: [ 11 | .package(url: "https://github.com/ra1028/DifferenceKit", from: "1.2.0") 12 | ], 13 | targets: [ 14 | .target(name: "LazyCollection", dependencies: ["DifferenceKit"]) 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LazyCollection 2 | 3 | A minimal SwiftUI lazy container type, that: 4 | 5 | * uses `UICollectionView` underneath and supports any self-sizing `UICollectionViewLayout` you throwing at it; 6 | * can be used on iOS 13.0+ and substituted the sometimes wonky `LazyVStack`; and 7 | * does not support sections as is, does not support drag-n-drop, and does not support multiple selections. 8 | 9 | There is no commitment to a feature roadmap. Feel free to copy & paste into your own codebase, and evolve/fork it on your own terms. 10 | 11 | Sources/Private/Previews.swift contains a SwiftUI Preview showcasing `LazyCollection` in a grid format, powered by `UICollectionViewCompositionalLayout`. One thing worth noting that is also showcased is that `LazyCollection` supports two ways of item selection: 12 | 13 | 1. a `Data.Element.ID?` binding which you point to a `@State` variable; or 14 | 15 | 2. using a `SwiftUI.Button` at the root of your `ItemContent` view, akin to the vanilla usage of `SwiftUI.List`. 16 | 17 | This is made possible by `LazyCollection` always providing an ambient `PrimitiveButtonStyle`, that will trigger the SwiftUI action closure in response to UICollectionView selection, and then subsequently auto-deselect the item. Note that auto-deselection is disabled if you use a binding (1). 18 | 19 | ### Acknowledgements 20 | 21 | * [Samuel Défago's blog post series](https://defagos.github.io/swiftui_collection_part3/) 22 | * [ASCollectionView](https://github.com/apptekstudios/ASCollectionView) — a more fully featured package. 23 | -------------------------------------------------------------------------------- /Sources/LazyCollection/LazyCollection.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | import DifferenceKit 4 | 5 | public struct LazyCollection: View where Data.Element: Identifiable, Data.Element: Equatable { 6 | public let data: Data 7 | public let transform: (Data.Element) -> ItemContent 8 | 9 | let layout: () -> UICollectionViewLayout 10 | var contentInsets: EdgeInsets 11 | var selection: Binding? = nil 12 | 13 | public var body: some View { 14 | Core(data: data, transform: transform, layout: layout, contentInsets: contentInsets, selection: selection) 15 | .ignoreSafeArea() 16 | } 17 | 18 | public init( 19 | _ data: Data, 20 | selection: Binding? = nil, 21 | @ViewBuilder transform: @escaping (Data.Element) -> ItemContent, 22 | layout: @escaping () -> UICollectionViewLayout, 23 | contentInsets: EdgeInsets = EdgeInsets() 24 | ) { 25 | self.data = data 26 | self.selection = selection 27 | self.transform = transform 28 | self.layout = layout 29 | self.contentInsets = contentInsets 30 | } 31 | 32 | public func contentInsets(_ insets: EdgeInsets) -> Self { 33 | var copy = self 34 | copy.contentInsets = insets 35 | return copy 36 | } 37 | 38 | private struct Core: UIViewControllerRepresentable { 39 | typealias Context = UIViewControllerRepresentableContext 40 | typealias UIViewControllerType = SwiftUICollectionViewController 41 | 42 | let data: Data 43 | let transform: (Data.Element) -> ItemContent 44 | let layout: () -> UICollectionViewLayout 45 | var contentInsets: EdgeInsets 46 | var selection: Binding? = nil 47 | 48 | private var uiKitInsets: UIEdgeInsets { 49 | UIEdgeInsets(top: contentInsets.top, left: contentInsets.leading, bottom: contentInsets.bottom, right: contentInsets.trailing) 50 | } 51 | 52 | func makeUIViewController(context: Context) -> UIViewControllerType { 53 | let viewController = UIViewControllerType(layout: layout(), initial: data, transform: transform) 54 | applyExtraAttributes(viewController) 55 | return viewController 56 | } 57 | 58 | func updateUIViewController(_ viewController: UIViewControllerType, context: Context) { 59 | viewController.apply(data, transaction: context.transaction) 60 | applyExtraAttributes(viewController) 61 | } 62 | 63 | private func applyExtraAttributes(_ viewController: UIViewControllerType) { 64 | applyIfChanged(viewController, \.collectionView.contentInset, uiKitInsets) 65 | viewController.selectionBinding = selection 66 | } 67 | } 68 | } 69 | 70 | private func applyIfChanged(_ root: Root, _ keyPath: ReferenceWritableKeyPath, _ value: Value) { 71 | if root[keyPath: keyPath] != value { 72 | root[keyPath: keyPath] = value 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/LazyCollection/Private/EnvironmentValues.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public enum CellState { 4 | case normal 5 | case highlighted 6 | case selected 7 | } 8 | 9 | extension EnvironmentValues { 10 | public var cellState: CellState { 11 | get { self[CellStateKey.self] } 12 | set { self[CellStateKey.self] = newValue } 13 | } 14 | 15 | public var deselectCell: () -> Void { 16 | get { self[DeselectCellKey.self] } 17 | set { self[DeselectCellKey.self] = newValue } 18 | } 19 | } 20 | 21 | private struct DeselectCellKey: EnvironmentKey { 22 | static var defaultValue: () -> Void = {} 23 | } 24 | 25 | private struct CellStateKey: EnvironmentKey { 26 | static var defaultValue: CellState = .normal 27 | } 28 | -------------------------------------------------------------------------------- /Sources/LazyCollection/Private/HostingCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | 4 | internal final class HostingCell: UICollectionViewCell { 5 | let hostingController = UIHostingController(rootView: HostingCellRootView(content: nil)) 6 | 7 | override var safeAreaInsets: UIEdgeInsets { .zero } 8 | 9 | override var isSelected: Bool { 10 | didSet { 11 | hostingController.rootView.isSelected = isSelected 12 | } 13 | } 14 | 15 | override var isHighlighted: Bool { 16 | didSet { 17 | hostingController.rootView.isHighlighted = isHighlighted 18 | } 19 | } 20 | 21 | override init(frame: CGRect) { 22 | super.init(frame: frame) 23 | 24 | hostingController.disableSafeArea() 25 | contentView.addSubview(hostingController.view) 26 | hostingController.view.translatesAutoresizingMaskIntoConstraints = false 27 | 28 | contentView.backgroundColor = .clear 29 | hostingController.view.backgroundColor = .clear 30 | } 31 | 32 | required init?(coder: NSCoder) { fatalError() } 33 | 34 | override func prepareForReuse() { 35 | super.prepareForReuse() 36 | hostingController.rootView.content = nil 37 | } 38 | 39 | func set(_ item: ItemContent) { 40 | hostingController.rootView.content = item 41 | } 42 | 43 | func didDequeue(for viewController: SwiftUICollectionViewControllerProtocol) { 44 | if hostingController.parent !== viewController { 45 | assert(hostingController.parent == nil) 46 | 47 | viewController.addChild(hostingController) 48 | hostingController.didMove(toParent: viewController) 49 | 50 | hostingController.rootView.deselectCell = { [unowned viewController, unowned self] in 51 | viewController.deselect(self) 52 | } 53 | } 54 | } 55 | 56 | override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) { 57 | super.apply(layoutAttributes) 58 | hostingController.view.frame = layoutAttributes.bounds 59 | } 60 | 61 | override func preferredLayoutAttributesFitting( 62 | _ layoutAttributes: UICollectionViewLayoutAttributes 63 | ) -> UICollectionViewLayoutAttributes { 64 | let attributes = super.preferredLayoutAttributesFitting(layoutAttributes) 65 | attributes.frame.size = hostingController.sizeThatFits(in: attributes.frame.size) 66 | return attributes 67 | } 68 | 69 | override func systemLayoutSizeFitting( 70 | _ targetSize: CGSize, 71 | withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, 72 | verticalFittingPriority: UILayoutPriority 73 | ) -> CGSize { 74 | // Skip Auto Layout. 75 | return targetSize 76 | } 77 | 78 | deinit { 79 | hostingController.removeFromParent() 80 | } 81 | } 82 | 83 | extension UIHostingController { 84 | fileprivate func disableSafeArea() { 85 | // https://defagos.github.io/swiftui_collection_part3/ 86 | guard let viewClass = object_getClass(view) else { return } 87 | 88 | let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea") 89 | if let viewSubclass = NSClassFromString(viewSubclassName) { 90 | object_setClass(view, viewSubclass) 91 | } 92 | else { 93 | guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return } 94 | guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return } 95 | 96 | if let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) { 97 | let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in 98 | return .zero 99 | } 100 | class_addMethod(viewSubclass, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method)) 101 | } 102 | 103 | objc_registerClassPair(viewSubclass) 104 | object_setClass(view, viewSubclass) 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/LazyCollection/Private/HostingCellRootView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | internal struct HostingCellRootView: View { 4 | var content: ItemContent? 5 | var isSelected: Bool = false 6 | var isHighlighted: Bool = false 7 | var deselectCell: () -> Void = {} 8 | 9 | var cellState: CellState { 10 | isSelected ? .selected : (isHighlighted ? .highlighted : .normal) 11 | } 12 | 13 | @ViewBuilder 14 | var body: some View { 15 | if let content = content { 16 | content 17 | .buttonStyle(CellButtonStyle()) 18 | .environment(\.cellState, cellState) 19 | .environment(\.deselectCell, deselectCell) 20 | } 21 | } 22 | 23 | private struct CellButtonStyle: PrimitiveButtonStyle { 24 | @Environment(\.cellState) var cellState 25 | @Environment(\.deselectCell) var deselectCell 26 | 27 | func makeBody(configuration: Configuration) -> some View { 28 | configuration.label 29 | .onValueChanged(cellState) { state in 30 | if state == .selected { 31 | configuration.trigger() 32 | deselectCell() 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/LazyCollection/Private/Previews.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | import DifferenceKit 4 | 5 | #if DEBUG 6 | struct Grid_Previews: PreviewProvider { 7 | static var previews: some View { 8 | NavigationView { 9 | TestGrid_SelectionBinding() 10 | } 11 | .previewLayout(.device) 12 | .previewDisplayName("Selection via ID binding") 13 | 14 | NavigationView { 15 | TestGrid_Button() 16 | } 17 | .previewLayout(.device) 18 | .previewDisplayName("Selection via Button") 19 | } 20 | 21 | static let ranges: [Unicode.Scalar] = (0x1F601...0x1F64F).compactMap(Unicode.Scalar.init) 22 | 23 | struct TestGrid_SelectionBinding: View { 24 | @State var items = ranges.map { TestItem(id: $0) } 25 | @State var selection: Unicode.Scalar? = nil 26 | 27 | var body: some View { 28 | VStack(spacing: 0) { 29 | HStack { 30 | Text("Selection") 31 | Spacer() 32 | Text(selection.map(String.init) ?? "nil") 33 | Button { self.selection = nil } label: { 34 | Image(systemName: "xmark.circle.fill") 35 | } 36 | } 37 | .padding() 38 | 39 | Divider() 40 | 41 | Grid(items, selection: $selection) { item in 42 | TestButton(id: item.id) 43 | 44 | if item.showLabel { 45 | Text("Label") 46 | } 47 | } 48 | .navigationBarTitle(Text("Grid")) 49 | .navigationBarItems( 50 | trailing: Button { 51 | self.items = self.items.map { item in 52 | var copy = item 53 | copy.showLabel.toggle() 54 | return copy 55 | } 56 | } label: { Text("Toggle") } 57 | ) 58 | } 59 | } 60 | 61 | public init() {} 62 | } 63 | 64 | struct TestGrid_Button: View { 65 | @State var items = ranges.map { TestItem(id: $0) } 66 | @State var selection: Unicode.Scalar? = nil 67 | 68 | var body: some View { 69 | VStack(spacing: 0) { 70 | HStack { 71 | Text("Selection") 72 | Spacer() 73 | Text(selection.map(String.init) ?? "nil") 74 | } 75 | .padding() 76 | 77 | Divider() 78 | 79 | Grid(items) { item in 80 | Button { 81 | self.selection = item.id 82 | } label: { 83 | TestButton(id: item.id) 84 | 85 | if item.showLabel { 86 | Text("Label") 87 | } 88 | } 89 | } 90 | .navigationBarTitle(Text("Grid")) 91 | .navigationBarItems( 92 | trailing: Button { 93 | self.items = self.items.map { item in 94 | var copy = item 95 | copy.showLabel.toggle() 96 | return copy 97 | } 98 | } label: { Text("Toggle") } 99 | ) 100 | } 101 | } 102 | 103 | public init() {} 104 | } 105 | 106 | struct TestItem: Equatable, Identifiable { 107 | var id: Unicode.Scalar 108 | var showLabel: Bool = false 109 | } 110 | 111 | struct TestButton: View { 112 | let id: Unicode.Scalar 113 | @Environment(\.cellState) var cellState 114 | 115 | var body: some View { 116 | Text("\(String(id))") 117 | .font(Font.system(size: 48)) 118 | .padding(8) 119 | .background( 120 | Group { 121 | switch cellState { 122 | case .selected: 123 | Text("\(String(id))") 124 | .font(Font.system(size: 64)) 125 | .blur(radius: 48.0, opaque: true) 126 | .opacity(0.3) 127 | case .highlighted: 128 | Color.gray 129 | case .normal: 130 | Color.clear 131 | } 132 | } 133 | ) 134 | .clipShape(RoundedRectangle(cornerRadius: 16.0, style: .continuous)) 135 | } 136 | } 137 | 138 | 139 | struct Grid: View { 140 | let items: [Item] 141 | var selection: Binding? = nil 142 | private let content: (Item) -> ItemContent 143 | var contentInsets: EdgeInsets = EdgeInsets() 144 | 145 | init( 146 | _ items: [Item], 147 | selection: Binding? = nil, 148 | @ViewBuilder content: @escaping (Item) -> ItemContent, 149 | contentInsets: EdgeInsets = EdgeInsets() 150 | ) { 151 | self.items = items 152 | self.selection = selection 153 | self.content = content 154 | self.contentInsets = contentInsets 155 | } 156 | 157 | var body: some View { 158 | LazyCollection( 159 | items, 160 | selection: selection, 161 | transform: { item in 162 | content(item) 163 | }, 164 | layout: { [contentInsets] in 165 | let group = NSCollectionLayoutGroup.horizontal( 166 | layoutSize: .init(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)), 167 | subitem: NSCollectionLayoutItem( 168 | layoutSize: .init(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) 169 | ), 170 | count: 3 171 | ) 172 | group.interItemSpacing = .fixed(4) 173 | 174 | let section = NSCollectionLayoutSection(group: group) 175 | section.interGroupSpacing = 16 176 | section.contentInsets = NSDirectionalEdgeInsets( 177 | top: 0, 178 | leading: contentInsets.leading, 179 | bottom: 0, 180 | trailing: contentInsets.trailing 181 | ) 182 | 183 | let layout = UICollectionViewCompositionalLayout(section: section) 184 | return layout 185 | }, 186 | contentInsets: EdgeInsets(top: contentInsets.top, leading: 0, bottom: contentInsets.bottom, trailing: 0) 187 | ) 188 | } 189 | } 190 | } 191 | 192 | #endif 193 | -------------------------------------------------------------------------------- /Sources/LazyCollection/Private/SwiftUICollectionViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | import DifferenceKit 4 | 5 | private let reuseIdentifier = "HostingCell" 6 | 7 | internal protocol SwiftUICollectionViewControllerProtocol: UIViewController { 8 | func deselect(_ cell: UICollectionViewCell) 9 | } 10 | 11 | internal final class SwiftUICollectionViewController< 12 | Data: RandomAccessCollection, 13 | ItemContent: View 14 | >: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, SwiftUICollectionViewControllerProtocol 15 | where Data.Element: Identifiable, Data.Element: Equatable { 16 | let collectionView: UICollectionView 17 | var transform: (Data.Element) -> ItemContent 18 | var current: [SectionWrapper] = [] 19 | var selectionBinding: Binding? = nil { 20 | didSet { updateSelectionIfNecessary() } 21 | } 22 | 23 | override var shouldAutomaticallyForwardAppearanceMethods: Bool { false } 24 | 25 | init( 26 | layout: UICollectionViewLayout, 27 | initial: Data, 28 | transform: @escaping (Data.Element) -> ItemContent 29 | ) { 30 | self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) 31 | self.transform = transform 32 | 33 | super.init(nibName: nil, bundle: nil) 34 | 35 | collectionView.register( 36 | HostingCell.self, 37 | forCellWithReuseIdentifier: reuseIdentifier 38 | ) 39 | collectionView.dataSource = self 40 | collectionView.delegate = self 41 | 42 | apply(initial, transaction: nil) 43 | } 44 | 45 | required init?(coder: NSCoder) { fatalError() } 46 | 47 | override func loadView() { 48 | view = collectionView 49 | collectionView.backgroundColor = .clear 50 | } 51 | 52 | func apply(_ new: Data, transaction: Transaction?) { 53 | let shouldAnimate = (transaction?.disablesAnimations ?? true) == false 54 | let new = [SectionWrapper(new.map(ElementWrapper.init))] 55 | 56 | if shouldAnimate { 57 | let changeset = StagedChangeset(source: current, target: new) 58 | 59 | collectionView.reloadWithSilentCellUpdate( 60 | changeset, 61 | updateCell: { [transform] cell, item in 62 | (cell as! HostingCell).set(transform(item.element)) 63 | }, 64 | setData: { self.current = $0 } 65 | ) 66 | } else { 67 | current = new 68 | collectionView.reloadData() 69 | } 70 | } 71 | 72 | func deselect(_ cell: UICollectionViewCell) { 73 | // If we have a selection binding, automatic deselection should be disabled. 74 | guard selectionBinding == nil else { return } 75 | 76 | guard let indexPath = collectionView.indexPath(for: cell) else { return } 77 | collectionView.deselectItem(at: indexPath, animated: false) 78 | } 79 | 80 | private func updateSelectionIfNecessary() { 81 | guard let binding = selectionBinding else { return } 82 | 83 | let selectedId = binding.wrappedValue 84 | let collectionViewSelection = (collectionView.indexPathsForSelectedItems?.first) 85 | .map { current[0].elements[$0.item].differenceIdentifier } 86 | 87 | // Update the collection view state, and in turn all affected visible cells, immediately without any animation. 88 | // SwiftUI can animate on its own terms in response to this update. 89 | // 90 | // Also note that setting `animated: true` might cause the animations on the SwiftUI root view to be stuck somehow. 91 | 92 | if selectedId != collectionViewSelection { 93 | (collectionView.indexPathsForSelectedItems ?? []) 94 | .forEach { collectionView.deselectItem(at: $0, animated: false) } 95 | 96 | if let newSelectionIndex = current[0].elements.firstIndex(where: { $0.differenceIdentifier == selectedId }) { 97 | collectionView.selectItem( 98 | at: IndexPath(item: newSelectionIndex, section: 0), 99 | animated: false, 100 | scrollPosition: .bottom 101 | ) 102 | } 103 | } 104 | } 105 | 106 | func numberOfSections(in collectionView: UICollectionView) -> Int { 107 | 1 108 | } 109 | 110 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 111 | assert(section == 0) 112 | return current[0].elements.count 113 | } 114 | 115 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 116 | let cell = collectionView.dequeueReusableCell( 117 | withReuseIdentifier: reuseIdentifier, 118 | for: indexPath 119 | ) as! HostingCell 120 | 121 | cell.didDequeue(for: self) 122 | cell.set(transform(current[0].elements[indexPath.item].element)) 123 | 124 | return cell 125 | } 126 | 127 | func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { 128 | let cell = (cell as! HostingCell) 129 | cell.hostingController.beginAppearanceTransition(true, animated: false) 130 | cell.hostingController.endAppearanceTransition() 131 | } 132 | 133 | func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { 134 | let cell = (cell as! HostingCell) 135 | cell.hostingController.beginAppearanceTransition(false, animated: false) 136 | cell.hostingController.endAppearanceTransition() 137 | } 138 | 139 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 140 | selectionBinding?.wrappedValue = current[0].elements[indexPath.item].differenceIdentifier 141 | } 142 | 143 | func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { 144 | selectionBinding?.wrappedValue = nil 145 | } 146 | 147 | struct SectionWrapper: DifferentiableSection { 148 | var elements: [ElementWrapper] 149 | var differenceIdentifier: Int8 { 0 } 150 | 151 | init(_ elements: [ElementWrapper]) { 152 | self.elements = elements 153 | } 154 | 155 | init(source: SectionWrapper, elements: C) where C : Collection, C.Element == ElementWrapper { 156 | self.init(Array(elements)) 157 | } 158 | 159 | func isContentEqual(to source: SectionWrapper) -> Bool { 160 | true 161 | } 162 | } 163 | 164 | struct ElementWrapper: Differentiable { 165 | let element: Data.Element 166 | 167 | var differenceIdentifier: Data.Element.ID { 168 | element.id 169 | } 170 | 171 | func isContentEqual(to source: ElementWrapper) -> Bool { 172 | source.element == element 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /Sources/LazyCollection/Private/UICollectionView+StagedChangeset.swift: -------------------------------------------------------------------------------- 1 | import DifferenceKit 2 | import UIKit 3 | 4 | extension UICollectionView { 5 | internal func reloadWithSilentCellUpdate( 6 | _ changeset: StagedChangeset, 7 | updateCell: (UICollectionViewCell, C.Element.Collection.Element) -> Void, 8 | setData: @escaping (C) -> Void 9 | ) where C.Element: DifferentiableSection { 10 | guard window != nil else { 11 | if let data = changeset.last?.data { 12 | setData(data) 13 | } 14 | 15 | reloadData() 16 | return 17 | } 18 | 19 | for changeset in changeset { 20 | performBatchUpdates({ 21 | setData(changeset.data) 22 | 23 | if !changeset.sectionDeleted.isEmpty { 24 | deleteSections(IndexSet(changeset.sectionDeleted)) 25 | } 26 | 27 | if !changeset.sectionInserted.isEmpty { 28 | insertSections(IndexSet(changeset.sectionInserted)) 29 | } 30 | 31 | if !changeset.sectionUpdated.isEmpty { 32 | reloadSections(IndexSet(changeset.sectionUpdated)) 33 | } 34 | 35 | for (source, target) in changeset.sectionMoved { 36 | moveSection(source, toSection: target) 37 | } 38 | 39 | if !changeset.elementDeleted.isEmpty { 40 | deleteItems(at: changeset.elementDeleted.map { IndexPath(item: $0.element, section: $0.section) }) 41 | } 42 | 43 | if !changeset.elementInserted.isEmpty { 44 | insertItems(at: changeset.elementInserted.map { IndexPath(item: $0.element, section: $0.section) }) 45 | } 46 | 47 | if !changeset.elementUpdated.isEmpty { 48 | // For all visible cells that have updated model, apply changes to the cells directly using the provided 49 | // `updateCell` closure. 50 | // 51 | // The rest of the invisible index paths can be safety ignored, because their sizes would either: 52 | // 1. be lazily computed as they go on screen (UICollectionViewLayout self-sizing via preferred layout 53 | // attributes); or 54 | // 2. be computed by the handler in `collectionViewLayout(_:sizeForItemAt:)` (if implemented) using the model. 55 | // 56 | // TODO: Supplementary view is not dealt with, as that requires tighter integration of diff application & 57 | // data source. 58 | 59 | let updatedIndexPaths = Set( 60 | changeset.elementUpdated.lazy 61 | .map { IndexPath(item: $0.element, section: $0.section) } 62 | ) 63 | 64 | self.indexPathsForVisibleItems 65 | .filter(updatedIndexPaths.contains) 66 | .map { ($0, cellForItem(at: $0)!) } 67 | .forEach { indexPath, cell in 68 | let sectionIndex = changeset.data.index(changeset.data.startIndex, offsetBy: indexPath.section) 69 | let section = changeset.data[sectionIndex].elements 70 | let elementIndex = section.index(section.startIndex, offsetBy: indexPath.item) 71 | updateCell(cell, section[elementIndex]) 72 | } 73 | 74 | let invalidationContextType = type(of: collectionViewLayout).invalidationContextClass as! UICollectionViewLayoutInvalidationContext.Type 75 | let invalidationContext = invalidationContextType.init() 76 | invalidationContext.invalidateItems(at: Array(updatedIndexPaths)) 77 | collectionViewLayout.invalidateLayout(with: invalidationContext) 78 | } 79 | 80 | for (source, target) in changeset.elementMoved { 81 | // CollectionView in iOS 14.0 is once again not happy with moves being mixed with other changes. 82 | deleteItems(at: [IndexPath(item: source.element, section: source.section)]) 83 | insertItems(at: [IndexPath(item: target.element, section: target.section)]) 84 | } 85 | }) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/LazyCollection/Private/View+Modifiers.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | extension View { 5 | @ViewBuilder 6 | internal func ignoreSafeArea() -> some View { 7 | if #available(iOS 14.0, *) { 8 | ignoresSafeArea(.all, edges: .all) 9 | } else { 10 | edgesIgnoringSafeArea(.all) 11 | } 12 | } 13 | 14 | /// iOS 13 usable version of `onChange(of:perform:)`. 15 | @ViewBuilder 16 | internal func onValueChanged(_ value: Value, perform action: @escaping (Value) -> Void) -> some View { 17 | if #available(iOS 14.0, *) { 18 | onChange(of: value, perform: action) 19 | } else { 20 | modifier(OnChangeModifier(value: value, action: action)) 21 | } 22 | } 23 | } 24 | 25 | private struct OnChangeModifier: ViewModifier { 26 | let value: Value 27 | let action: (Value) -> Void 28 | 29 | @State var lastRecord: Value? = nil 30 | 31 | func body(content: Content) -> some View { 32 | return content 33 | .onAppear() 34 | .onReceive(Just(value)) { value in 35 | if value != lastRecord { 36 | action(value) 37 | self.lastRecord = value 38 | } 39 | } 40 | } 41 | } 42 | --------------------------------------------------------------------------------