├── .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 |
--------------------------------------------------------------------------------