├── .swift-version
├── Examples
├── Assets.xcassets
│ ├── Contents.json
│ ├── checkmark.imageset
│ │ ├── group3.pdf
│ │ └── Contents.json
│ ├── hazard.imageset
│ │ ├── images-2.jpeg
│ │ └── Contents.json
│ ├── mount.imageset
│ │ ├── Unknown-3.png
│ │ └── Contents.json
│ ├── jorghinho.imageset
│ │ ├── images.jpeg
│ │ └── Contents.json
│ ├── kovacic.imageset
│ │ ├── Unknown-2.png
│ │ └── Contents.json
│ ├── select_off.imageset
│ │ ├── Contents.json
│ │ └── select_off.pdf
│ ├── select_on.imageset
│ │ ├── Contents.json
│ │ └── select_on.pdf
│ └── AppIcon.appiconset
│ │ └── Contents.json
├── SocialNetworkProfile
│ ├── Model
│ │ └── User.swift
│ ├── Cells
│ │ ├── ButtonFooter.swift
│ │ ├── ProfileInfoCell.swift
│ │ ├── FriendCell.swift
│ │ ├── MultilineTextCell.swift
│ │ └── AvatarCell.swift
│ └── ProfileViewController.swift
├── PhotoGrid
│ ├── PhotoGridViewController.swift
│ ├── PhotoGridSection.swift
│ └── PhotoCell.swift
├── ImageCell.swift
├── TextCell.swift
├── AppDelegate.swift
├── Pagination
│ ├── LoadingCell.swift
│ └── PaginationViewController.swift
├── Base
│ ├── CollectionViewController.swift
│ └── CollectionHeader.swift
├── Base.lproj
│ ├── LaunchScreen.storyboard
│ └── Main.storyboard
├── Info.plist
├── SceneDelegate.swift
├── ManyCells
│ └── ManyCellsViewController.swift
├── Filter
│ ├── Popups
│ │ ├── ManualInputFilterPopup.swift
│ │ ├── FilterPopup.swift
│ │ ├── StringFilterPopup.swift
│ │ └── NumberPickerPopup.swift
│ ├── Cells
│ │ ├── SelectFilterCell.swift
│ │ ├── RadioButtonCell.swift
│ │ └── FilterTextValueCell.swift
│ ├── Model.swift
│ ├── Core
│ │ ├── FloatingLabelTextField.swift
│ │ └── PopupController.swift
│ └── FilterViewController.swift
└── Menu
│ └── MenuViewController.swift
├── IVCollectionKit.xcodeproj
├── project.xcworkspace
│ └── contents.xcworkspacedata
└── xcshareddata
│ └── xcschemes
│ └── IVCollectionKit.xcscheme
├── IVCollectionKit
├── Source
│ ├── CollectionDirectorDelegate.swift
│ ├── DeepDiff
│ │ ├── Shared
│ │ │ ├── Array+Extensions.swift
│ │ │ ├── DiffAware.swift
│ │ │ ├── DeepDiff.swift
│ │ │ ├── Change.swift
│ │ │ ├── MoveReducer.swift
│ │ │ └── Algorithms
│ │ │ │ ├── WagnerFischer.swift
│ │ │ │ └── Heckel.swift
│ │ └── iOS
│ │ │ ├── UICollectionView+Extensions.swift
│ │ │ ├── IndexPathConverter.swift
│ │ │ └── UITableView+Extensions.swift
│ ├── Helpers.swift
│ ├── IVCollectionView.swift
│ ├── Operators.swift
│ ├── Reusable.swift
│ ├── CollectionHeaderFooterView.swift
│ ├── CollectionReusableViewsRegisterer.swift
│ ├── Protocols.swift
│ ├── AbstractCollectionSection.swift
│ ├── CollectionSection.swift
│ ├── CollectionUpdater.swift
│ └── CollectionItem.swift
├── IVCollectionKit.h
└── Info.plist
├── Package.swift
├── IVCollectionKitTests
├── Helpers
│ └── StringCell.swift
├── Info.plist
├── DataSourceTests.swift
├── AdjustWidthHeightTests.swift
├── IVCollectionKitTests.swift
├── AnimatedUpdatesTests.swift
└── CollectionUpdaterTests.swift
├── LICENSE
├── IVCollectionKit.podspec
├── README.md
└── .gitignore
/.swift-version:
--------------------------------------------------------------------------------
1 | 5.0
2 |
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/checkmark.imageset/group3.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ivedeneev/CollectionKit/HEAD/Examples/Assets.xcassets/checkmark.imageset/group3.pdf
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/hazard.imageset/images-2.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ivedeneev/CollectionKit/HEAD/Examples/Assets.xcassets/hazard.imageset/images-2.jpeg
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/mount.imageset/Unknown-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ivedeneev/CollectionKit/HEAD/Examples/Assets.xcassets/mount.imageset/Unknown-3.png
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/jorghinho.imageset/images.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ivedeneev/CollectionKit/HEAD/Examples/Assets.xcassets/jorghinho.imageset/images.jpeg
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/kovacic.imageset/Unknown-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ivedeneev/CollectionKit/HEAD/Examples/Assets.xcassets/kovacic.imageset/Unknown-2.png
--------------------------------------------------------------------------------
/IVCollectionKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/select_off.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "select_off.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | }
12 | }
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/select_on.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "select_on.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | }
12 | }
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/checkmark.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "group3.pdf"
6 | }
7 | ],
8 | "info" : {
9 | "version" : 1,
10 | "author" : "xcode"
11 | },
12 | "properties" : {
13 | "preserves-vector-representation" : true
14 | }
15 | }
--------------------------------------------------------------------------------
/IVCollectionKit/Source/CollectionDirectorDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CollectionDirectorDelegate.swift
3 | // IVCollectionKit
4 | //
5 | // Created by Igor Vedeneev on 25.11.2023.
6 | // Copyright © 2023 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public protocol CollectionDirectorDelegate: AnyObject {
12 | func didScrollToBottom(director: CollectionDirector)
13 | }
14 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/DeepDiff/Shared/Array+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Array+Extensions.swift
3 | // DeepDiff
4 | //
5 | // Created by Khoa Pham.
6 | // Copyright © 2018 Khoa Pham. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Array {
12 | func executeIfPresent(_ closure: ([Element]) -> Void) {
13 | if !isEmpty {
14 | closure(self)
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/hazard.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "images-2.jpeg",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "version" : 1,
19 | "author" : "xcode"
20 | }
21 | }
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/jorghinho.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "images.jpeg",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "version" : 1,
19 | "author" : "xcode"
20 | }
21 | }
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/kovacic.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "Unknown-2.png",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "version" : 1,
19 | "author" : "xcode"
20 | }
21 | }
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/mount.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "Unknown-3.png",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "version" : 1,
19 | "author" : "xcode"
20 | }
21 | }
--------------------------------------------------------------------------------
/IVCollectionKit/Source/Helpers.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Helpers.swift
3 | // CollectionKit
4 | //
5 | // Created by Igor Vedeneev on 03.10.17.
6 | // Copyright © 2017 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | //MARK:- Convinience
12 | extension Array {
13 | mutating func remove(at indexes: [Int]) {
14 | for index in indexes.sorted(by: >) {
15 | guard indices.contains(index) else { return }
16 | remove(at: index)
17 | }
18 | }
19 | }
20 |
21 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.0
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "IVCollectionKit",
7 | platforms: [.iOS(.v9)],
8 | products: [
9 | .library(
10 | name: "IVCollectionKit",
11 | targets: ["IVCollectionKit"]),
12 | ],
13 |
14 | targets: [
15 | .target(
16 | name: "IVCollectionKit",
17 | path: "IVCollectionKit")
18 | ]
19 | )
20 |
--------------------------------------------------------------------------------
/IVCollectionKit/IVCollectionKit.h:
--------------------------------------------------------------------------------
1 | //
2 | // IVCollectionKit.h
3 | // IVCollectionKit
4 | //
5 | // Created by Igor Vedeneev on 1/29/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | #import
10 |
11 | //! Project version number for IVCollectionKit.
12 | FOUNDATION_EXPORT double IVCollectionKitVersionNumber;
13 |
14 | //! Project version string for IVCollectionKit.
15 | FOUNDATION_EXPORT const unsigned char IVCollectionKitVersionString[];
16 |
17 | // In this header, you should import all the public headers of your framework using statements like #import
18 |
19 |
20 |
--------------------------------------------------------------------------------
/IVCollectionKitTests/Helpers/StringCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StringCell.swift
3 | // IVCollectionKitTests
4 | //
5 | // Created by Igor Vedeneev on 4/25/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import IVCollectionKit
11 |
12 | class StringCell: UICollectionViewCell, ConfigurableCollectionItem {
13 | let titleLabel = UILabel()
14 |
15 | static func estimatedSize(item: String, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
16 | return CGSize(width: 5, height: 5)
17 | }
18 |
19 | func configure(item: String) {
20 | titleLabel.text = item
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Examples/SocialNetworkProfile/Model/User.swift:
--------------------------------------------------------------------------------
1 | //
2 | // User.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 2/18/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct User: Hashable {
12 | let id: String
13 | let firstName: String
14 | let lastName: String
15 | let imageUrl: URL
16 | let city: String
17 | var info: [Info]
18 | let description: String?
19 |
20 | struct Info: Hashable {
21 | let id: String
22 | let icon: String
23 | let value: String
24 | }
25 |
26 | func hash(into hasher: inout Hasher) {
27 | hasher.combine(id)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/IVCollectionKitTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/IVCollectionKit/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.1.0
19 | CFBundleVersion
20 | $(CURRENT_PROJECT_VERSION)
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Examples/PhotoGrid/PhotoGridViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PhotoGridViewController.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 2/16/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 | import Photos
12 |
13 | final class PhotoGridViewController: CollectionViewController {
14 |
15 |
16 | override func viewDidLoad() {
17 | super.viewDidLoad()
18 |
19 | let options = PHFetchOptions()
20 | options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
21 | let results = PHAsset.fetchAssets(with: options)
22 | let section = PhotoGridSection(results: results)
23 | director += section
24 | director.reload()
25 | collectionView.contentInsetAdjustmentBehavior = .always
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/IVCollectionView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IVCollectionView.swift
3 | // IVCollectionKit
4 | //
5 | // Created by Igor Vedeneev on 10.10.2020.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | /// **EXPEREMENTAL**
12 | /// `UICollectionView` subclass designed for more safe batch updates
13 | /// I know, this is bad :(, but sometimes espesially during multiple updates something goes wrong and it crashes
14 | open class IVCollectionView: UICollectionView {
15 | open override func deleteItems(at indexPaths: [IndexPath]) {
16 | let safeIndexPaths = indexPaths.filter { collectionViewLayout.layoutAttributesForItem(at: $0) != nil }
17 | super.deleteItems(at: safeIndexPaths)
18 | }
19 |
20 | open override func insertItems(at indexPaths: [IndexPath]) {
21 | let safeIndexPaths = indexPaths.filter { collectionViewLayout.layoutAttributesForItem(at: $0) != nil }
22 | super.insertItems(at: safeIndexPaths)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/Operators.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Operators.swift
3 | // CollectionKit
4 | //
5 | // Created by Igor Vedeneev on 27/08/2018.
6 | // Copyright © 2018 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public func +=(left: CollectionDirector, right: AbstractCollectionSection) {
12 | left.append(section: right)
13 | }
14 |
15 | public func +=(left: AbstractCollectionSection, right: AbstractCollectionItem) {
16 | left.append(item: right)
17 | }
18 |
19 | public func +=(left: CollectionDirector, right: [AbstractCollectionSection]) {
20 | left.append(sections: right)
21 | }
22 |
23 | public func +=(left: AbstractCollectionSection, right: [AbstractCollectionItem]) {
24 | left.append(items: right)
25 | }
26 |
27 | public func ==(left: AbstractCollectionItem, right: AbstractCollectionItem) -> Bool {
28 | return left.identifier == right.identifier
29 | }
30 |
31 | public func ==(lhs: AbstractCollectionSection, rhs: AbstractCollectionSection) -> Bool {
32 | return lhs.identifier == rhs.identifier
33 | }
34 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/DeepDiff/Shared/DiffAware.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DiffAware.swift
3 | // DeepDiff
4 | //
5 | // Created by khoa on 22/02/2019.
6 | // Copyright © 2019 Khoa Pham. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// Model must conform to DiffAware for diffing to work properly
12 | /// diffId: Each object must be uniquely identified by id. This is to tell if there is deletion or insertion
13 | /// compareContent: An object can change some properties but having its id intact. This is to tell if there is replacement
14 | public protocol DiffAware {
15 | associatedtype DiffId: Hashable
16 |
17 | var diffId: DiffId { get }
18 | static func compareContent(_ a: Self, _ b: Self) -> Bool
19 | }
20 |
21 | public extension DiffAware where Self: Hashable {
22 | var diffId: Self {
23 | return self
24 | }
25 |
26 | static func compareContent(_ a: Self, _ b: Self) -> Bool {
27 | return a == b
28 | }
29 | }
30 |
31 | //extension Int: DiffAware {}
32 | extension String: DiffAware {}
33 | //extension Character: DiffAware {}
34 | //extension UUID: DiffAware {}
35 |
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Copyright (c) 2017 Igor Vedeneev
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy
5 | of this software and associated documentation files (the "Software"), to deal
6 | in the Software without restriction, including without limitation the rights
7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | copies of the Software, and to permit persons to whom the Software is
9 | furnished to do so, subject to the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included in
12 | all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | THE SOFTWARE.
--------------------------------------------------------------------------------
/IVCollectionKit/Source/DeepDiff/Shared/DeepDiff.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeepDiff.swift
3 | // DeepDiff
4 | //
5 | // Created by Khoa Pham.
6 | // Copyright © 2018 Khoa Pham. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// Perform diff between old and new collections
12 | ///
13 | /// - Parameters:
14 | /// - old: Old collection
15 | /// - new: New collection
16 | /// - Returns: A set of changes
17 |
18 | public func diff(old: [T], new: [T]) -> [Change] {
19 | let heckel = Heckel()
20 | return heckel.diff(old: old, new: new)
21 | }
22 |
23 | public func preprocess(old: [T], new: [T]) -> [Change]? {
24 | switch (old.isEmpty, new.isEmpty) {
25 | case (true, true):
26 | // empty
27 | return []
28 | case (true, false):
29 | // all .insert
30 | return new.enumerated().map { index, item in
31 | return .insert(Insert(item: item, index: index))
32 | }
33 | case (false, true):
34 | // all .delete
35 | return old.enumerated().map { index, item in
36 | return .delete(Delete(item: item, index: index))
37 | }
38 | default:
39 | return nil
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Examples/ImageCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ImageCell.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 1/29/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | final class ImageCell: UICollectionViewCell {
13 | private let imageView = UIImageView()
14 |
15 | override init(frame: CGRect) {
16 | super.init(frame: frame)
17 | addSubview(imageView)
18 | imageView.clipsToBounds = true
19 | imageView.contentMode = .scaleAspectFill
20 | }
21 |
22 | required init?(coder: NSCoder) {
23 | fatalError("init(coder:) has not been implemented")
24 | }
25 |
26 | override func layoutSubviews() {
27 | super.layoutSubviews()
28 | imageView.frame = bounds
29 | }
30 | }
31 |
32 | extension ImageCell: ConfigurableCollectionItem {
33 | static func estimatedSize(item: UIImage, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
34 | let width: CGFloat = ((boundingSize.width - 6) / 2).rounded(.down)
35 | return CGSize(width: width, height: width * 0.8)
36 | }
37 |
38 | func configure(item: UIImage) {
39 | imageView.image = item
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Examples/TextCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TextCell.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 1/29/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | final class TextCell: UICollectionViewCell {
13 | private let label = UILabel()
14 |
15 | override var isHighlighted: Bool {
16 | didSet {
17 | backgroundColor = isHighlighted ? .separator : .secondarySystemGroupedBackground
18 | }
19 | }
20 |
21 | override init(frame: CGRect) {
22 | super.init(frame: frame)
23 | addSubview(label)
24 | backgroundColor = .secondarySystemGroupedBackground
25 | }
26 |
27 | required init?(coder: NSCoder) {
28 | fatalError("init(coder:) has not been implemented")
29 | }
30 |
31 | override func layoutSubviews() {
32 | super.layoutSubviews()
33 | label.frame = CGRect(x: 16, y: 0, width: bounds.width - 16 * 2, height: bounds.height)
34 | }
35 | }
36 |
37 | extension TextCell: ConfigurableCollectionItem {
38 | static func estimatedSize(item: String, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
39 | return CGSize(width: boundingSize.width - 40, height: 44)
40 | }
41 |
42 | func configure(item: String) {
43 | label.text = item
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/Reusable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Reusable.swift
3 | // CollectionKit
4 | //
5 | // Created by Igor Vedeneev on 13.09.17.
6 | // Copyright © 2017 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import UIKit.UINib
11 |
12 | /// Convinience methods to work with cell registering
13 | public protocol Reusable {
14 | static var nib: UINib { get }
15 | static var reuseIdentifier: String { get }
16 | }
17 |
18 | public extension Reusable {
19 | /// Assume that nib file name matches class name
20 | static var nib: UINib {
21 | return UINib(nibName: String(describing: self), bundle: nil)
22 | }
23 |
24 | static var reuseIdentifier: String {
25 | return String(describing: self)
26 | }
27 | }
28 |
29 | public extension UICollectionView {
30 | func dequeue(indexPath: IndexPath) -> T {
31 | guard let cell = dequeueReusableCell(withReuseIdentifier: T.reuseIdentifier, for: indexPath) as? T else {
32 | fatalError("cell type \(T.self) is not registered")
33 | }
34 | return cell
35 | }
36 |
37 | func registerNib(_ type: T.Type) {
38 | register(T.nib, forCellWithReuseIdentifier: T.reuseIdentifier)
39 | }
40 |
41 | func registerClass(_ type: T.Type) where T: UICollectionViewCell {
42 | register(T.self, forCellWithReuseIdentifier: T.reuseIdentifier)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Examples/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 1/29/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | @UIApplicationMain
12 | class AppDelegate: UIResponder, UIApplicationDelegate {
13 |
14 |
15 |
16 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
17 | // Override point for customization after application launch.
18 | return true
19 | }
20 |
21 | // MARK: UISceneSession Lifecycle
22 |
23 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
24 | // Called when a new scene session is being created.
25 | // Use this method to select a configuration to create the new scene with.
26 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
27 | }
28 |
29 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {
30 | // Called when the user discards a scene session.
31 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
32 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
33 | }
34 |
35 |
36 | }
37 |
38 |
--------------------------------------------------------------------------------
/Examples/Pagination/LoadingCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoadingCell.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 25.11.2023.
6 | // Copyright © 2023 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | class PaginationLoadingCell: UICollectionViewCell {
13 |
14 | private let activityIndicatior = UIActivityIndicatorView(style: .large)
15 |
16 | override init(frame: CGRect) {
17 | super.init(frame: frame)
18 | backgroundColor = .secondarySystemBackground
19 |
20 | activityIndicatior.hidesWhenStopped = true
21 | contentView.addSubview(activityIndicatior)
22 | activityIndicatior.translatesAutoresizingMaskIntoConstraints = false
23 | NSLayoutConstraint.activate([
24 | activityIndicatior.centerXAnchor.constraint(equalTo: centerXAnchor),
25 | activityIndicatior.centerYAnchor.constraint(equalTo: centerYAnchor)
26 | ])
27 | }
28 |
29 | required init?(coder: NSCoder) {
30 | fatalError("init(coder:) has not been implemented")
31 | }
32 | }
33 |
34 | extension PaginationLoadingCell: ConfigurableCollectionItem {
35 |
36 | func configure(item: Void) {
37 | activityIndicatior.startAnimating()
38 | }
39 |
40 | static func estimatedSize(
41 | item: Void,
42 | boundingSize: CGSize,
43 | in section: AbstractCollectionSection
44 | ) -> CGSize {
45 | let width = boundingSize.width
46 | return CGSize(width: width, height: 50)
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/CollectionHeaderFooterView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CollectionHeaderFooterView.swift
3 | // CollectionKit
4 | //
5 | // Created by Igor Vedeneev on 25.10.17.
6 | // Copyright © 2017 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | open class CollectionHeaderFooterView: AbstractCollectionHeaderFooterItem where ViewType: UICollectionReusableView {
12 |
13 |
14 | public var viewType: AnyClass { return ViewType.self }
15 | public var indexPath: String?
16 | open var item: ViewType.T
17 | open var onDisplay: (() -> Void)?
18 | open var onEndDisplay: (() -> Void)?
19 | open var reuseIdentifier: String { return ViewType.reuseIdentifier }
20 | public let identifier: String = UUID().uuidString
21 |
22 | public init(item: ViewType.T) {
23 | self.item = item
24 | }
25 |
26 | public func configure(_ view: UICollectionReusableView) {
27 | (view as? ViewType)?.configure(item: item)
28 | }
29 |
30 | public func estimatedSize(boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
31 | return ViewType.estimatedSize(item: item, boundingSize: boundingSize, in: section)
32 | }
33 |
34 | @discardableResult
35 | public func onDisplay(_ block:@escaping () -> Void) -> Self {
36 | self.onDisplay = block
37 | return self
38 | }
39 |
40 | @discardableResult
41 | public func onEndDisplay(_ block:@escaping () -> Void) -> Self {
42 | self.onEndDisplay = block
43 | return self
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Examples/Base/CollectionViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CollectionViewController.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 2/16/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | class CollectionViewController: UIViewController {
13 | let layout = UICollectionViewFlowLayout()
14 | lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
15 | lazy var director: CollectionDirector = CollectionDirector(collectionView: collectionView)
16 |
17 | var topConstraint: NSLayoutConstraint!
18 | var bottomConstraint: NSLayoutConstraint!
19 |
20 | override func viewDidLoad() {
21 | super.viewDidLoad()
22 | setupCollectionView()
23 | }
24 |
25 | private func setupCollectionView() {
26 | view.addSubview(collectionView)
27 | collectionView.translatesAutoresizingMaskIntoConstraints = false
28 | collectionView.backgroundColor = .systemGroupedBackground
29 | collectionView.alwaysBounceVertical = true
30 |
31 | topConstraint = collectionView.topAnchor.constraint(equalTo: view.topAnchor)
32 | bottomConstraint = collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
33 |
34 | NSLayoutConstraint.activate([
35 | topConstraint,
36 | bottomConstraint,
37 | collectionView.leftAnchor.constraint(equalTo: view.leftAnchor),
38 | collectionView.rightAnchor.constraint(equalTo: view.rightAnchor)
39 | ])
40 | }
41 | }
42 |
43 |
--------------------------------------------------------------------------------
/Examples/PhotoGrid/PhotoGridSection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PhotoGridSection.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 2/16/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 | import Photos
12 |
13 | final class PhotoGridSection: AbstractCollectionSection {
14 |
15 | var identifier: String {
16 | return "PhotoGridSection"
17 | }
18 |
19 | var headerItem: AbstractCollectionHeaderFooterItem?
20 | var footerItem: AbstractCollectionHeaderFooterItem?
21 | var insetForSection: UIEdgeInsets = .zero
22 | var minimumInterItemSpacing: CGFloat = 2
23 | var lineSpacing: CGFloat = 2
24 |
25 | private var results: PHFetchResult
26 |
27 | init(results: PHFetchResult) {
28 | self.results = results
29 | }
30 |
31 | func numberOfItems() -> Int {
32 | return results.count
33 | }
34 |
35 | func didSelectItem(at indexPath: IndexPath) {
36 |
37 | }
38 |
39 | func sizeForItem(at indexPath: IndexPath, boundingSize: CGSize) -> CGSize {
40 | return PhotoCell.estimatedSize(item: results[indexPath.row], boundingSize: boundingSize, in: self)
41 | }
42 |
43 | func currentItemIds() -> [String] {
44 | return []
45 | }
46 |
47 | func cell(for director: CollectionDirector, indexPath: IndexPath) -> UICollectionViewCell {
48 | let cell:PhotoCell = director.dequeueReusableCell(indexPath: indexPath)
49 | cell.configure(item: results[indexPath.row])
50 | return cell
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/DeepDiff/Shared/Change.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Change.swift
3 | // DeepDiff
4 | //
5 | // Created by Khoa Pham.
6 | // Copyright © 2018 Khoa Pham. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Insert {
12 | public let item: T
13 | public let index: Int
14 | }
15 |
16 | public struct Delete {
17 | public let item: T
18 | public let index: Int
19 | }
20 |
21 | public struct Replace {
22 | public let oldItem: T
23 | public let newItem: T
24 | public let index: Int
25 | }
26 |
27 | public struct Move {
28 | public let item: T
29 | public let fromIndex: Int
30 | public let toIndex: Int
31 | }
32 |
33 | /// The computed changes from diff
34 | ///
35 | /// - insert: Insert an item at index
36 | /// - delete: Delete an item from index
37 | /// - replace: Replace an item at index with another item
38 | /// - move: Move the same item from this index to another index
39 | public enum Change {
40 | case insert(Insert)
41 | case delete(Delete)
42 | case replace(Replace)
43 | case move(Move)
44 |
45 | public var insert: Insert? {
46 | if case .insert(let insert) = self {
47 | return insert
48 | }
49 |
50 | return nil
51 | }
52 |
53 | public var delete: Delete? {
54 | if case .delete(let delete) = self {
55 | return delete
56 | }
57 |
58 | return nil
59 | }
60 |
61 | public var replace: Replace? {
62 | if case .replace(let replace) = self {
63 | return replace
64 | }
65 |
66 | return nil
67 | }
68 |
69 | public var move: Move? {
70 | if case .move(let move) = self {
71 | return move
72 | }
73 |
74 | return nil
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/DeepDiff/Shared/MoveReducer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MoveReducer.swift
3 | // DeepDiff
4 | //
5 | // Created by Khoa Pham.
6 | // Copyright © 2018 Khoa Pham. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct MoveReducer {
12 | func reduce(changes: [Change]) -> [Change] {
13 | let compareContentWithOptional: (T?, T) -> Bool = { a, b in
14 | guard let a = a else {
15 | return false
16 | }
17 |
18 | return T.compareContent(a, b)
19 | }
20 |
21 | // Find pairs of .insert and .delete with same item
22 | let inserts = changes.compactMap({ $0.insert })
23 |
24 | if inserts.isEmpty {
25 | return changes
26 | }
27 |
28 | var changes = changes
29 | inserts.forEach { insert in
30 | if let insertIndex = changes.firstIndex(where: { return compareContentWithOptional($0.insert?.item, insert.item) }),
31 | let deleteIndex = changes.firstIndex(where: { return compareContentWithOptional($0.delete?.item, insert.item) }) {
32 |
33 | let insertChange = changes[insertIndex].insert!
34 | let deleteChange = changes[deleteIndex].delete!
35 |
36 | let move = Move(item: insert.item, fromIndex: deleteChange.index, toIndex: insertChange.index)
37 |
38 | // .insert can be before or after .delete
39 | let minIndex = min(insertIndex, deleteIndex)
40 | let maxIndex = max(insertIndex, deleteIndex)
41 |
42 | // remove both .insert and .delete, and replace by .move
43 | changes.remove(at: minIndex)
44 | changes.remove(at: maxIndex.advanced(by: -1))
45 | changes.insert(.move(move), at: minIndex)
46 | }
47 | }
48 |
49 | return changes
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/Examples/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/IVCollectionKitTests/DataSourceTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DataSourceTests.swift
3 | // IVCollectionKitTests
4 | //
5 | // Created by Igor Vedeneev on 2/15/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import IVCollectionKit
11 |
12 | class DataSourceTests: IVTestCase {
13 |
14 | func test_delegateAndDataSource_wasSet() {
15 | director.reload() // since director is lazy we need to call if first to initialize director instance
16 | XCTAssert(collectionView.delegate === director)
17 | XCTAssert(collectionView.dataSource === director)
18 | }
19 |
20 | func testDataSource_numberOfSectionsAndRows() {
21 |
22 | let section1 = CollectionSection()
23 | section1 += CollectionItem(item: ())
24 | section1 += CollectionItem(item: ())
25 | director += section1
26 |
27 | let section2 = CollectionSection()
28 | section2 += CollectionItem(item: ())
29 | section2 += CollectionItem(item: ())
30 | director += section2
31 |
32 | director.reload()
33 |
34 | let numberOfSections = collectionView.numberOfSections == 2
35 | let numberOfItemsInSection0 = collectionView.numberOfItems(inSection: 0) == 2
36 | let numberOfItemsInSection1 = collectionView.numberOfItems(inSection: 1) == 2
37 |
38 | XCTAssert(numberOfSections && numberOfItemsInSection0 && numberOfItemsInSection1)
39 | }
40 |
41 | func testPerformanceExample() {
42 | // This is an example of a performance test case.
43 | self.measure {
44 | // Put the code you want to measure the time of here.
45 | }
46 | }
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/IVCollectionKit.podspec:
--------------------------------------------------------------------------------
1 | #
2 | # Be sure to run `pod lib lint IVCollectionKit.podspec' to ensure this is a
3 | # valid spec before submitting.
4 | #
5 | # Any lines starting with a # are optional, but their use is encouraged
6 | # To learn more about a Podspec see https://guides.cocoapods.org/syntax/podspec.html
7 | #
8 |
9 | Pod::Spec.new do |s|
10 | s.name = 'IVCollectionKit'
11 | s.version = '1.1.0'
12 | s.summary = 'UICollectionView declarative management'
13 |
14 | # This description is used to generate tags and improve search results.
15 | # * Think: What does it do? Why did you write it? What is the focus?
16 | # * Try to keep it short, snappy and to the point.
17 | # * Write the description between the DESC delimiters below.
18 | # * Finally, don't worry about the indent, CocoaPods strips it!
19 |
20 | s.description = <<-DESC
21 | TODO: Add long description of the pod here.
22 | DESC
23 |
24 | s.homepage = 'https://github.com/ivedeneev/CollectionKit'
25 | # s.screenshots = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2'
26 | s.license = { :type => 'MIT', :file => 'LICENSE' }
27 | s.author = { 'ivedeneev' => 'i.vedeneev@agima.ru' }
28 | s.source = { :git => 'https://github.com/ivedeneev/CollectionKit.git', :tag => s.version.to_s }
29 | # s.social_media_url = 'https://twitter.com/'
30 |
31 | s.ios.deployment_target = '9.0'
32 | s.swift_version = '5.0'
33 | s.source_files = 'IVCollectionKit/Source/**/*'
34 |
35 | # s.resource_bundles = {
36 | # 'IVCollectionKit' => ['IVCollectionKit/Assets/*.png']
37 | # }
38 |
39 | # s.public_header_files = 'Pod/Classes/**/*.h'
40 | # s.frameworks = 'UIKit', 'MapKit'
41 | # s.dependency 'AFNetworking', '~> 2.3'
42 | end
43 |
--------------------------------------------------------------------------------
/Examples/SocialNetworkProfile/Cells/ButtonFooter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ButtonFooter.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 2/18/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | struct ButtonViewModel {
13 | let icon: String?
14 | let title: String
15 | let handler: (() -> Void)
16 | }
17 |
18 | final class ButtonFooter: UICollectionReusableView {
19 | private let button = UIButton.init(type: .custom)
20 |
21 | override init(frame: CGRect) {
22 | super.init(frame: frame)
23 |
24 | addSubview(button)
25 | button.backgroundColor = .systemBackground
26 | button.translatesAutoresizingMaskIntoConstraints = false
27 | button.clipsToBounds = true
28 | button.layer.cornerRadius = 6
29 | button.setTitleColor(.systemBlue, for: .normal)
30 | button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
31 |
32 | NSLayoutConstraint.activate([
33 | button.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
34 | button.topAnchor.constraint(equalTo: topAnchor, constant: 4),
35 | button.leftAnchor.constraint(equalTo: leftAnchor, constant: 16),
36 | button.rightAnchor.constraint(equalTo: rightAnchor, constant: -16)
37 | ])
38 | }
39 |
40 | required init?(coder: NSCoder) {
41 | fatalError("init(coder:) has not been implemented")
42 | }
43 | }
44 |
45 | extension ButtonFooter: ConfigurableCollectionItem {
46 | static func estimatedSize(item: ButtonViewModel, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
47 | return CGSize(width: boundingSize.width, height: 40)
48 | }
49 |
50 | func configure(item: ButtonViewModel) {
51 | button.setTitle(item.title, for: .normal)
52 | }
53 | }
54 |
55 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/DeepDiff/iOS/UICollectionView+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UICollectionView+Extensions.swift
3 | // DeepDiff
4 | //
5 | // Created by Khoa Pham.
6 | // Copyright © 2018 Khoa Pham. All rights reserved.
7 | //
8 |
9 | #if os(iOS) || os(tvOS)
10 | import UIKit
11 |
12 | public extension UICollectionView {
13 |
14 | /// Animate reload in a batch update
15 | ///
16 | /// - Parameters:
17 | /// - changes: The changes from diff
18 | /// - section: The section that all calculated IndexPath belong
19 | /// - updateData: Update your data source model
20 | /// - completion: Called when operation completes
21 | func reload(
22 | changes: [Change],
23 | section: Int = 0,
24 | updateData: () -> Void,
25 | completion: ((Bool) -> Void)? = nil) {
26 |
27 | let changesWithIndexPath = IndexPathConverter().convert(changes: changes, section: section)
28 |
29 | performBatchUpdates({
30 | updateData()
31 | insideUpdate(changesWithIndexPath: changesWithIndexPath)
32 | }, completion: { finished in
33 | completion?(finished)
34 | })
35 |
36 | // reloadRows needs to be called outside the batch
37 | outsideUpdate(changesWithIndexPath: changesWithIndexPath)
38 | }
39 |
40 | // MARK: - Helper
41 |
42 | private func insideUpdate(changesWithIndexPath: ChangeWithIndexPath) {
43 | changesWithIndexPath.deletes.executeIfPresent {
44 | deleteItems(at: $0)
45 | }
46 |
47 | changesWithIndexPath.inserts.executeIfPresent {
48 | insertItems(at: $0)
49 | }
50 |
51 | changesWithIndexPath.moves.executeIfPresent {
52 | $0.forEach { move in
53 | moveItem(at: move.from, to: move.to)
54 | }
55 | }
56 | }
57 |
58 | private func outsideUpdate(changesWithIndexPath: ChangeWithIndexPath) {
59 | changesWithIndexPath.replaces.executeIfPresent {
60 | self.reloadItems(at: $0)
61 | }
62 | }
63 | }
64 | #endif
65 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/DeepDiff/iOS/IndexPathConverter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IndexPathConverter.swift
3 | // DeepDiff
4 | //
5 | // Created by Khoa Pham.
6 | // Copyright © 2018 Khoa Pham. All rights reserved.
7 | //
8 |
9 | #if os(iOS) || os(tvOS)
10 | import Foundation
11 |
12 | public struct ChangeWithIndexPath {
13 |
14 | public let inserts: [IndexPath]
15 | public let deletes: [IndexPath]
16 | public let replaces: [IndexPath]
17 | public let moves: [(from: IndexPath, to: IndexPath)]
18 | public let isEmpty: Bool
19 |
20 | public init(
21 | inserts: [IndexPath],
22 | deletes: [IndexPath],
23 | replaces:[IndexPath],
24 | moves: [(from: IndexPath, to: IndexPath)]) {
25 |
26 | self.inserts = inserts
27 | self.deletes = deletes
28 | self.replaces = replaces
29 | self.moves = moves
30 | self.isEmpty = inserts.isEmpty && deletes.isEmpty && replaces.isEmpty && moves.isEmpty
31 | }
32 | }
33 |
34 | public class IndexPathConverter {
35 |
36 | public init() {}
37 |
38 | public func convert(changes: [Change], section: Int) -> ChangeWithIndexPath {
39 | let inserts = changes.compactMap({ $0.insert }).map({ $0.index.toIndexPath(section: section) })
40 | let deletes = changes.compactMap({ $0.delete }).map({ $0.index.toIndexPath(section: section) })
41 | let replaces = changes.compactMap({ $0.replace }).map({ $0.index.toIndexPath(section: section) })
42 | let moves = changes.compactMap({ $0.move }).map({
43 | (
44 | from: $0.fromIndex.toIndexPath(section: section),
45 | to: $0.toIndex.toIndexPath(section: section)
46 | )
47 | })
48 |
49 | return ChangeWithIndexPath(
50 | inserts: inserts,
51 | deletes: deletes,
52 | replaces: replaces,
53 | moves: moves
54 | )
55 | }
56 | }
57 |
58 | extension Int {
59 |
60 | fileprivate func toIndexPath(section: Int) -> IndexPath {
61 | return IndexPath(item: self, section: section)
62 | }
63 | }
64 | #endif
65 |
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "size" : "20x20",
6 | "scale" : "2x"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "size" : "20x20",
11 | "scale" : "3x"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "size" : "29x29",
16 | "scale" : "2x"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "size" : "29x29",
21 | "scale" : "3x"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "size" : "40x40",
26 | "scale" : "2x"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "size" : "40x40",
31 | "scale" : "3x"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "size" : "60x60",
36 | "scale" : "2x"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "size" : "60x60",
41 | "scale" : "3x"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "size" : "20x20",
46 | "scale" : "1x"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "size" : "20x20",
51 | "scale" : "2x"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "size" : "29x29",
56 | "scale" : "1x"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "size" : "29x29",
61 | "scale" : "2x"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "size" : "40x40",
66 | "scale" : "1x"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "size" : "40x40",
71 | "scale" : "2x"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "size" : "76x76",
76 | "scale" : "1x"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "size" : "76x76",
81 | "scale" : "2x"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "size" : "83.5x83.5",
86 | "scale" : "2x"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "size" : "1024x1024",
91 | "scale" : "1x"
92 | }
93 | ],
94 | "info" : {
95 | "version" : 1,
96 | "author" : "xcode"
97 | }
98 | }
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/select_off.imageset/select_off.pdf:
--------------------------------------------------------------------------------
1 | %PDF-1.7
2 |
3 | 1 0 obj
4 | << >>
5 | endobj
6 |
7 | 2 0 obj
8 | << /Length 3 0 R >>
9 | stream
10 | /DeviceRGB CS
11 | /DeviceRGB cs
12 | q
13 | 18.000000 9.000000 m
14 | 18.000000 4.029437 13.970563 0.000000 9.000000 0.000000 c
15 | 4.029437 0.000000 0.000000 4.029437 0.000000 9.000000 c
16 | 0.000000 13.970563 4.029437 18.000000 9.000000 18.000000 c
17 | 13.970563 18.000000 18.000000 13.970563 18.000000 9.000000 c
18 | h
19 | W*
20 | n
21 | q
22 | 1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
23 | 0.494118 0.534792 0.643137 scn
24 | 16.000000 9.000000 m
25 | 16.000000 5.134007 12.865993 2.000000 9.000000 2.000000 c
26 | 9.000000 -2.000000 l
27 | 15.075132 -2.000000 20.000000 2.924868 20.000000 9.000000 c
28 | 16.000000 9.000000 l
29 | h
30 | 9.000000 2.000000 m
31 | 5.134007 2.000000 2.000000 5.134007 2.000000 9.000000 c
32 | -2.000000 9.000000 l
33 | -2.000000 2.924868 2.924868 -2.000000 9.000000 -2.000000 c
34 | 9.000000 2.000000 l
35 | h
36 | 2.000000 9.000000 m
37 | 2.000000 12.865993 5.134007 16.000000 9.000000 16.000000 c
38 | 9.000000 20.000000 l
39 | 2.924868 20.000000 -2.000000 15.075132 -2.000000 9.000000 c
40 | 2.000000 9.000000 l
41 | h
42 | 9.000000 16.000000 m
43 | 12.865993 16.000000 16.000000 12.865993 16.000000 9.000000 c
44 | 20.000000 9.000000 l
45 | 20.000000 15.075132 15.075132 20.000000 9.000000 20.000000 c
46 | 9.000000 16.000000 l
47 | h
48 | f
49 | n
50 | Q
51 | Q
52 |
53 | endstream
54 | endobj
55 |
56 | 3 0 obj
57 | 1121
58 | endobj
59 |
60 | 4 0 obj
61 | << /Annots []
62 | /Type /Page
63 | /MediaBox [ 0.000000 0.000000 18.000000 18.000000 ]
64 | /Resources 1 0 R
65 | /Contents 2 0 R
66 | /Parent 5 0 R
67 | >>
68 | endobj
69 |
70 | 5 0 obj
71 | << /Kids [ 4 0 R ]
72 | /Count 1
73 | /Type /Pages
74 | >>
75 | endobj
76 |
77 | 6 0 obj
78 | << /Type /Catalog
79 | /Pages 5 0 R
80 | >>
81 | endobj
82 |
83 | xref
84 | 0 7
85 | 0000000000 65535 f
86 | 0000000010 00000 n
87 | 0000000034 00000 n
88 | 0000001211 00000 n
89 | 0000001234 00000 n
90 | 0000001407 00000 n
91 | 0000001481 00000 n
92 | trailer
93 | << /ID [ (some) (id) ]
94 | /Root 6 0 R
95 | /Size 7
96 | >>
97 | startxref
98 | 1540
99 | %%EOF
--------------------------------------------------------------------------------
/Examples/SocialNetworkProfile/Cells/ProfileInfoCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileInfoCell.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 2/18/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | final class ProfileInfoCell: UICollectionViewCell {
13 |
14 | private let imageView = UIImageView()
15 | private let titleLabel = UILabel()
16 |
17 |
18 | override init(frame: CGRect) {
19 | super.init(frame: frame)
20 |
21 | backgroundColor = .secondarySystemGroupedBackground
22 | setupImageView()
23 | setupTitleLabel()
24 | }
25 |
26 | required init?(coder: NSCoder) {
27 | fatalError("init(coder:) has not been implemented")
28 | }
29 |
30 | private func setupImageView() {
31 | addSubview(imageView)
32 | imageView.translatesAutoresizingMaskIntoConstraints = false
33 | imageView.backgroundColor = .tertiarySystemGroupedBackground
34 |
35 | NSLayoutConstraint.activate([
36 | imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
37 | imageView.leftAnchor.constraint(equalTo: leftAnchor, constant: 32),
38 | imageView.heightAnchor.constraint(equalToConstant: 16),
39 | imageView.widthAnchor.constraint(equalToConstant: 16)
40 | ])
41 | }
42 |
43 | private func setupTitleLabel() {
44 | addSubview(titleLabel)
45 | titleLabel.translatesAutoresizingMaskIntoConstraints = false
46 | titleLabel.font = UIFont.systemFont(ofSize: 15, weight: .regular)
47 |
48 | NSLayoutConstraint.activate([
49 | titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
50 | titleLabel.leftAnchor.constraint(equalTo: imageView.rightAnchor, constant: 36),
51 | titleLabel.rightAnchor.constraint(equalTo: rightAnchor, constant: -16)
52 | ])
53 | }
54 | }
55 |
56 | extension ProfileInfoCell: ConfigurableCollectionItem {
57 | static func estimatedSize(item: User.Info, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
58 | return CGSize(width: boundingSize.width, height: 30)
59 | }
60 |
61 | func configure(item: User.Info) {
62 | titleLabel.text = item.value
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/Examples/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSPhotoLibraryUsageDescription
6 | For demo purposes
7 | CFBundleDevelopmentRegion
8 | $(DEVELOPMENT_LANGUAGE)
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
19 | CFBundleShortVersionString
20 | 1.1.0
21 | CFBundleVersion
22 | 1
23 | LSRequiresIPhoneOS
24 |
25 | UIApplicationSceneManifest
26 |
27 | UIApplicationSupportsMultipleScenes
28 |
29 | UISceneConfigurations
30 |
31 | UIWindowSceneSessionRoleApplication
32 |
33 |
34 | UISceneConfigurationName
35 | Default Configuration
36 | UISceneDelegateClassName
37 | $(PRODUCT_MODULE_NAME).SceneDelegate
38 | UISceneStoryboardFile
39 | Main
40 |
41 |
42 |
43 |
44 | UILaunchStoryboardName
45 | LaunchScreen
46 | UIMainStoryboardFile
47 | Main
48 | UIRequiredDeviceCapabilities
49 |
50 | armv7
51 |
52 | UISupportedInterfaceOrientations
53 |
54 | UIInterfaceOrientationPortrait
55 | UIInterfaceOrientationLandscapeLeft
56 | UIInterfaceOrientationLandscapeRight
57 |
58 | UISupportedInterfaceOrientations~ipad
59 |
60 | UIInterfaceOrientationPortrait
61 | UIInterfaceOrientationPortraitUpsideDown
62 | UIInterfaceOrientationLandscapeLeft
63 | UIInterfaceOrientationLandscapeRight
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/CollectionReusableViewsRegisterer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CollectionReusableViewsRegisterer.swift
3 | // CollectionKit
4 | //
5 | // Created by Igor Vedeneev on 06.02.18.
6 | // Copyright © 2018 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 |
12 | /// Class responsible for registering classes and xibs in `UICollectionView`
13 | final class CollectionReusableViewsRegisterer {
14 | weak var collectionView: UICollectionView?
15 | private var cellReuseIdentifiers: Set = []
16 | private var headersReuseIdentifiers: Set = []
17 | private var footersReuseIdentifiers: Set = []
18 |
19 | init(collectionView: UICollectionView) {
20 | self.collectionView = collectionView
21 | }
22 |
23 | func registerCellIfNeeded(reuseIdentifier: String, cellClass: AnyClass) {
24 | guard !cellReuseIdentifiers.contains(reuseIdentifier) else { return }
25 | cellReuseIdentifiers.insert(reuseIdentifier)
26 | let bundle = Bundle(for: cellClass)
27 | if let _ = bundle.path(forResource: reuseIdentifier, ofType: "nib") {
28 | collectionView?.register(UINib(nibName: reuseIdentifier, bundle: bundle), forCellWithReuseIdentifier: reuseIdentifier)
29 | } else {
30 | collectionView?.register(cellClass, forCellWithReuseIdentifier: reuseIdentifier)
31 | }
32 | }
33 |
34 | func registerHeaderFooterViewIfNeeded(reuseIdentifier: String, viewClass: AnyClass, kind: String) {
35 | if kind == UICollectionView.elementKindSectionHeader && !headersReuseIdentifiers.contains(reuseIdentifier) {
36 | headersReuseIdentifiers.insert(reuseIdentifier)
37 | } else if kind == UICollectionView.elementKindSectionFooter && !footersReuseIdentifiers.contains(reuseIdentifier) {
38 | footersReuseIdentifiers.insert(reuseIdentifier)
39 | }
40 |
41 | let bundle = Bundle(for: viewClass)
42 | if let _ = bundle.path(forResource: reuseIdentifier, ofType: "nib") {
43 | collectionView?.register(UINib(nibName: reuseIdentifier, bundle: bundle), forSupplementaryViewOfKind: kind, withReuseIdentifier: reuseIdentifier)
44 | } else {
45 | collectionView?.register(viewClass, forSupplementaryViewOfKind: kind, withReuseIdentifier: reuseIdentifier)
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Examples/Pagination/PaginationViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PaginationViewController.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 25.11.2023.
6 | // Copyright © 2023 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | final class PaginationViewController: CollectionViewController {
13 | var posts = [String]()
14 | let postsSection = CollectionSection()
15 | var isLoading = false
16 |
17 | override func viewDidLoad() {
18 | super.viewDidLoad()
19 |
20 | title = "Pagination"
21 |
22 | director.delegate = self
23 | postsSection.insetForSection = UIEdgeInsets(top: 0, left: 8, bottom: 16, right: 8)
24 | postsSection.lineSpacing = 8
25 | director += postsSection
26 |
27 | appendPosts()
28 | configure()
29 | }
30 |
31 | func appendPosts() {
32 | let limit = 20
33 | let newPosts = (posts.count..(item: vm)
46 | .onSelect { [vm, director] _ in
47 | vm.isExpanded = !vm.isExpanded
48 | director.performUpdates()
49 | }
50 | }
51 |
52 | if isLoading {
53 | postsSection += CollectionItem(item: Void()).adjustsWidth(true)
54 | }
55 |
56 | director.performUpdates()
57 | }
58 | }
59 |
60 | extension PaginationViewController: CollectionDirectorDelegate {
61 | func didScrollToBottom(director: CollectionDirector) {
62 | print(#function)
63 | guard !isLoading else { return }
64 | print("DID SCROLL TO BOTTOM")
65 | isLoading = true
66 | configure()
67 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
68 | self.appendPosts()
69 | self.isLoading = false
70 | self.configure()
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/Protocols.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Protocols.swift
3 | // CollectionKit
4 | //
5 | // Created by Igor Vedeneev on 27/08/2018.
6 | // Copyright © 2018 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | //MARK:- ConfigurableCollectionItem
12 | public protocol ConfigurableCollectionItem : Reusable {
13 | associatedtype T
14 | static func estimatedSize(item: T, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize
15 | func configure(item: T)
16 | }
17 |
18 |
19 | //MARK:- AbstractCollectionItem
20 | public protocol AbstractCollectionItem : AbstractCollectionReusableView {
21 | var reuseIdentifier: String { get }
22 | var identifier: String { get }
23 | var cellType: AnyClass { get }
24 | var adjustsWidth: Bool { get set }
25 | var adjustsHeight: Bool { get set }
26 |
27 | var onSelect: ((_ indexPath: IndexPath) -> Void)? { get set }
28 | var onDeselect: ((_ indexPath: IndexPath) -> Void)? { get set }
29 | var onDisplay: ((_ indexPath: IndexPath, _ cell: UICollectionViewCell) -> Void)? { get set }
30 | var onEndDisplay: ((_ indexPath: IndexPath, _ cell: UICollectionViewCell) -> Void)? { get set }
31 | var onHighlight: ((_ indexPath: IndexPath) -> Void)? { get set }
32 | var onUnighlight: ((_ indexPath: IndexPath) -> Void)? { get set }
33 | var shouldHighlight: Bool { get set }
34 | var shouldSelect: Bool { get set }
35 | var shouldDeselect: Bool { get set }
36 |
37 | func configure(_: UICollectionReusableView)
38 | func estimatedSize(boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize
39 | }
40 |
41 |
42 | //MARK:- AbstractCollectionReusableView
43 | public protocol AbstractCollectionReusableView {
44 | var reuseIdentifier: String { get }
45 | var identifier: String { get }
46 | func configure(_: UICollectionReusableView)
47 | func estimatedSize(boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize
48 | }
49 |
50 |
51 | //MARK:- AbstractCollectionHeaderFooterItem
52 | public protocol AbstractCollectionHeaderFooterItem : AbstractCollectionReusableView {
53 | var reuseIdentifier: String { get }
54 | var identifier: String { get }
55 | var viewType: AnyClass { get }
56 | var onDisplay: (() -> Void)? { get set }
57 | var onEndDisplay: (() -> Void)? { get set }
58 | func configure(_: UICollectionReusableView)
59 | func estimatedSize(boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize
60 | }
61 |
--------------------------------------------------------------------------------
/Examples/SceneDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SceneDelegate.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 1/29/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class SceneDelegate: UIResponder, UIWindowSceneDelegate {
12 |
13 | var window: UIWindow?
14 |
15 |
16 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
17 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
18 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
19 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
20 | guard let _ = (scene as? UIWindowScene) else { return }
21 | }
22 |
23 | func sceneDidDisconnect(_ scene: UIScene) {
24 | // Called as the scene is being released by the system.
25 | // This occurs shortly after the scene enters the background, or when its session is discarded.
26 | // Release any resources associated with this scene that can be re-created the next time the scene connects.
27 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
28 | }
29 |
30 | func sceneDidBecomeActive(_ scene: UIScene) {
31 | // Called when the scene has moved from an inactive state to an active state.
32 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
33 | }
34 |
35 | func sceneWillResignActive(_ scene: UIScene) {
36 | // Called when the scene will move from an active state to an inactive state.
37 | // This may occur due to temporary interruptions (ex. an incoming phone call).
38 | }
39 |
40 | func sceneWillEnterForeground(_ scene: UIScene) {
41 | // Called as the scene transitions from the background to the foreground.
42 | // Use this method to undo the changes made on entering the background.
43 | }
44 |
45 | func sceneDidEnterBackground(_ scene: UIScene) {
46 | // Called as the scene transitions from the foreground to the background.
47 | // Use this method to save data, release shared resources, and store enough scene-specific state information
48 | // to restore the scene back to its current state.
49 | }
50 |
51 |
52 | }
53 |
54 |
--------------------------------------------------------------------------------
/Examples/SocialNetworkProfile/Cells/FriendCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FriendCell.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 2/18/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | final class FriendCell: UICollectionViewCell {
13 |
14 | private let avatarImageView = UIImageView()
15 | private let nameLabel = UILabel()
16 |
17 | override init(frame: CGRect) {
18 | super.init(frame: frame)
19 | backgroundColor = .secondarySystemGroupedBackground
20 | clipsToBounds = true
21 | layer.cornerRadius = 8
22 | setupAvatarImageView()
23 | setupNameLabel()
24 | }
25 |
26 | required init?(coder: NSCoder) {
27 | fatalError("init(coder:) has not been implemented")
28 | }
29 |
30 | private func setupAvatarImageView() {
31 | addSubview(avatarImageView)
32 | avatarImageView.translatesAutoresizingMaskIntoConstraints = false
33 | avatarImageView.backgroundColor = .systemFill
34 |
35 | NSLayoutConstraint.activate([
36 | avatarImageView.topAnchor.constraint(equalTo: topAnchor),
37 | avatarImageView.leftAnchor.constraint(equalTo: leftAnchor),
38 | avatarImageView.rightAnchor.constraint(equalTo: rightAnchor),
39 | avatarImageView.heightAnchor.constraint(equalTo: heightAnchor, multiplier: 0.5)
40 | ])
41 | }
42 |
43 | private func setupNameLabel() {
44 | addSubview(nameLabel)
45 | nameLabel.translatesAutoresizingMaskIntoConstraints = false
46 |
47 |
48 | nameLabel.numberOfLines = 2
49 | nameLabel.font = .systemFont(ofSize: 13)
50 |
51 | NSLayoutConstraint.activate([
52 | nameLabel.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor),
53 | nameLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: 8),
54 | nameLabel.rightAnchor.constraint(equalTo: rightAnchor, constant: -8),
55 | nameLabel.bottomAnchor.constraint(equalTo: bottomAnchor)
56 | ])
57 | }
58 | }
59 |
60 | extension FriendCell: ConfigurableCollectionItem {
61 | static func estimatedSize(item: User, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
62 | let w = (boundingSize.width - 2 * section.insetForSection.left - 2 * section.minimumInterItemSpacing) / 3
63 | return CGSize(width: w, height: w * 1.3)
64 | }
65 |
66 | func configure(item: User) {
67 | nameLabel.text = "\(item.lastName)\n\(item.firstName)"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Examples/ManyCells/ManyCellsViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewController.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 1/29/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | class ManyCellsViewController: CollectionViewController {
13 |
14 | var section1: CollectionSection!
15 | var imageSection: CollectionSection!
16 | var section2: CollectionSection!
17 |
18 | let pokerCombos = ["High card", "Pair", "Two pairs", "Three of a kind", "Straight", "Flush", "Full house", "Four of a kind", "Straight-flush", "Royal flush"]
19 |
20 | let images: [UIImage] = [UIImage(named: "hazard"), UIImage(named: "jorghinho"), UIImage(named: "kovacic"), UIImage(named: "mount")].compactMap { $0 }
21 |
22 | override func viewDidLoad() {
23 | super.viewDidLoad()
24 |
25 | collectionView.alwaysBounceVertical = true
26 | navigationItem.rightBarButtonItems = [
27 | UIBarButtonItem(title: "Shuffle", style: .plain, target: self, action: #selector(shuffle)),
28 | UIBarButtonItem(title: "Mult. updates",style: .plain, target: self, action: #selector(crazyUpdate))
29 | ]
30 |
31 | let itemsForSection1 = pokerCombos.prefix(5).map { CollectionItem(item: $0).adjustsWidth(true) }
32 | section1 = CollectionSection(items: itemsForSection1)
33 | section1.headerItem = CollectionHeaderFooterView(item: "Poker combos")
34 | director += section1
35 |
36 | imageSection = CollectionSection(items: images.map(CollectionItem.init))
37 | imageSection.insetForSection = UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 2)
38 | imageSection.lineSpacing = 2
39 | imageSection.minimumInterItemSpacing = 2
40 | director += imageSection
41 |
42 | director.performUpdates()
43 | }
44 |
45 | @objc func shuffle() {
46 | imageSection.items.shuffle()
47 | director.performUpdates()
48 | }
49 |
50 | @objc func crazyUpdate() {
51 | section1.items.removeLast()
52 | section1 += CollectionItem(item: Date().description)
53 |
54 | let sectionToInsert = CollectionSection(items: [CollectionItem(item: "INSERTED AT \(Date().description)")])
55 | director.insert(section: sectionToInsert, at: 0)
56 |
57 | section1.insert(item: CollectionItem(item: "insert 1").adjustsWidth(true), at: 1)
58 | section1.items.remove(at: 2)
59 | imageSection.items.shuffle()
60 | director.performUpdates()
61 | }
62 | }
63 |
64 |
--------------------------------------------------------------------------------
/IVCollectionKitTests/AdjustWidthHeightTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AdjustWidthHeightTests.swift
3 | // IVCollectionKitTests
4 | //
5 | // Created by Igor Vedeneev on 2/15/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import IVCollectionKit
11 |
12 | final class AdjustWidthHeightTests: IVTestCase {
13 |
14 | func testAdjustWidth_noInsets() {
15 | let section1 = CollectionSection()
16 | section1 += CollectionItem(item: ()).adjustsWidth(true)
17 | director += section1
18 | director.reload()
19 | collectionView.layoutIfNeeded()
20 | guard let cell = collectionView.cellForItem(at: IndexPath(item: 0, section: 0)) else { XCTFail("missing cell"); return }
21 | let realWidth = cell.bounds.width
22 | let expectedWidth = UIScreen.main.bounds.width
23 | XCTAssert(realWidth == expectedWidth, "real width: \(realWidth), expected width: \(expectedWidth)")
24 | }
25 |
26 | func testAdjustWidth_sectionInsets() {
27 | let section1 = CollectionSection()
28 | let inset: CGFloat = 15
29 | section1.insetForSection = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset)
30 | section1 += CollectionItem(item: ()).adjustsWidth(true)
31 | director += section1
32 | director.reload()
33 | collectionView.layoutIfNeeded()
34 | guard let cell = collectionView.cellForItem(at: IndexPath(item: 0, section: 0)) else { XCTFail("missing cell"); return }
35 | let realWidth = cell.bounds.width
36 | let expectedWidth = UIScreen.main.bounds.width - 2 * inset
37 | XCTAssert(realWidth == expectedWidth, "real width: \(realWidth), expected width: \(expectedWidth)")
38 | }
39 |
40 | func testAdjustWidth_contentInsetsAndSectionInsets() {
41 | let section1 = CollectionSection()
42 | let inset: CGFloat = 15
43 | collectionView.contentInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset)
44 | section1.insetForSection = UIEdgeInsets(top: 0, left: inset, bottom: inset, right: inset)
45 | section1 += CollectionItem(item: ()).adjustsWidth(true)
46 | director += section1
47 | director.reload()
48 | collectionView.layoutIfNeeded()
49 | guard let cell = collectionView.cellForItem(at: IndexPath(item: 0, section: 0)) else { XCTFail("missing cell"); return }
50 | let realWidth = cell.bounds.width
51 | let expectedWidth = UIScreen.main.bounds.width - 4 * inset
52 | XCTAssert(realWidth == expectedWidth, "real width: \(realWidth), expected width: \(expectedWidth)")
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/Examples/Base.lproj/Main.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/Examples/Assets.xcassets/select_on.imageset/select_on.pdf:
--------------------------------------------------------------------------------
1 | %PDF-1.7
2 |
3 | 1 0 obj
4 | << >>
5 | endobj
6 |
7 | 2 0 obj
8 | << /Length 3 0 R >>
9 | stream
10 | /DeviceRGB CS
11 | /DeviceRGB cs
12 | q
13 | 18.000000 9.000000 m
14 | 18.000000 4.029437 13.970563 0.000000 9.000000 0.000000 c
15 | 4.029437 0.000000 0.000000 4.029437 0.000000 9.000000 c
16 | 0.000000 13.970563 4.029437 18.000000 9.000000 18.000000 c
17 | 13.970563 18.000000 18.000000 13.970563 18.000000 9.000000 c
18 | h
19 | W*
20 | n
21 | q
22 | 1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
23 | 0.356863 0.286275 0.866667 scn
24 | 16.000000 9.000000 m
25 | 16.000000 5.134007 12.865993 2.000000 9.000000 2.000000 c
26 | 9.000000 -2.000000 l
27 | 15.075132 -2.000000 20.000000 2.924868 20.000000 9.000000 c
28 | 16.000000 9.000000 l
29 | h
30 | 9.000000 2.000000 m
31 | 5.134007 2.000000 2.000000 5.134007 2.000000 9.000000 c
32 | -2.000000 9.000000 l
33 | -2.000000 2.924868 2.924868 -2.000000 9.000000 -2.000000 c
34 | 9.000000 2.000000 l
35 | h
36 | 2.000000 9.000000 m
37 | 2.000000 12.865993 5.134007 16.000000 9.000000 16.000000 c
38 | 9.000000 20.000000 l
39 | 2.924868 20.000000 -2.000000 15.075132 -2.000000 9.000000 c
40 | 2.000000 9.000000 l
41 | h
42 | 9.000000 16.000000 m
43 | 12.865993 16.000000 16.000000 12.865993 16.000000 9.000000 c
44 | 20.000000 9.000000 l
45 | 20.000000 15.075132 15.075132 20.000000 9.000000 20.000000 c
46 | 9.000000 16.000000 l
47 | h
48 | f
49 | n
50 | Q
51 | Q
52 | q
53 | 1.000000 0.000000 -0.000000 1.000000 4.500000 4.500000 cm
54 | 0.356863 0.286275 0.866667 scn
55 | 9.000000 4.500000 m
56 | 9.000000 2.014719 6.985281 0.000000 4.500000 0.000000 c
57 | 2.014719 0.000000 0.000000 2.014719 0.000000 4.500000 c
58 | 0.000000 6.985281 2.014719 9.000000 4.500000 9.000000 c
59 | 6.985281 9.000000 9.000000 6.985281 9.000000 4.500000 c
60 | h
61 | f
62 | n
63 | Q
64 |
65 | endstream
66 | endobj
67 |
68 | 3 0 obj
69 | 1464
70 | endobj
71 |
72 | 4 0 obj
73 | << /Annots []
74 | /Type /Page
75 | /MediaBox [ 0.000000 0.000000 18.000000 18.000000 ]
76 | /Resources 1 0 R
77 | /Contents 2 0 R
78 | /Parent 5 0 R
79 | >>
80 | endobj
81 |
82 | 5 0 obj
83 | << /Kids [ 4 0 R ]
84 | /Count 1
85 | /Type /Pages
86 | >>
87 | endobj
88 |
89 | 6 0 obj
90 | << /Type /Catalog
91 | /Pages 5 0 R
92 | >>
93 | endobj
94 |
95 | xref
96 | 0 7
97 | 0000000000 65535 f
98 | 0000000010 00000 n
99 | 0000000034 00000 n
100 | 0000001554 00000 n
101 | 0000001577 00000 n
102 | 0000001750 00000 n
103 | 0000001824 00000 n
104 | trailer
105 | << /ID [ (some) (id) ]
106 | /Root 6 0 R
107 | /Size 7
108 | >>
109 | startxref
110 | 1883
111 | %%EOF
--------------------------------------------------------------------------------
/Examples/Filter/Popups/ManualInputFilterPopup.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ManualInputFilterPopup.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 3/22/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | final class ManualInputFilterPopup: CollectionViewController, PopupContentView, FilterPopup {
13 |
14 | var frameInPopup: CGRect {
15 | let safeArea: CGFloat = 34
16 | let height: CGFloat = max(CGFloat(filter.payload.fields.count * 51) + safeArea + 30.0, 250)
17 | return CGRect(x: 0, y: view.bounds.height - height, width: view.bounds.width, height: height)
18 | }
19 |
20 | var scrollView: UIScrollView? {
21 | return collectionView
22 | }
23 |
24 | var filter: ManualInputFilter!
25 | var selectedEntries = Array()
26 |
27 | var onSelect: (([SelectableFilterProtocol]) -> Void)?
28 |
29 | var stackView: UIStackView!
30 |
31 | override func viewDidLoad() {
32 | super.viewDidLoad()
33 |
34 | setupKeyboardObserving()
35 | setupHeaderView(title: filter.title)
36 | roundCorners()
37 |
38 | let fields = filter.payload.fields.map { field -> UITextField in
39 | let tf = FloatingLabelTextField()
40 | tf.kg_placeholder = field.key
41 | tf.keyboardType = .decimalPad
42 | return tf
43 | }
44 |
45 |
46 | stackView = UIStackView(arrangedSubviews: fields)
47 | stackView.alignment = .fill
48 | stackView.axis = .vertical
49 | stackView.distribution = .equalSpacing
50 |
51 | view.addSubview(stackView)
52 | stackView.translatesAutoresizingMaskIntoConstraints = false
53 | NSLayoutConstraint.activate([
54 | stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
55 | stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
56 | stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -60),
57 | stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: 60)
58 | ])
59 | }
60 |
61 | override func viewWillAppear(_ animated: Bool) {
62 | super.viewWillAppear(animated)
63 | (stackView.arrangedSubviews.first as? UITextField)?.becomeFirstResponder()
64 | }
65 |
66 | override func viewDidDisappear(_ animated: Bool) {
67 | super.viewDidDisappear(animated)
68 | }
69 |
70 | @objc func cancel() {
71 | dismiss(animated: true, completion: nil)
72 | }
73 |
74 | @objc func reset() {
75 | print("reset")
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/IVCollectionKitTests/IVCollectionKitTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IVCollectionKitTests.swift
3 | // IVCollectionKitTests
4 | //
5 | // Created by Igor Vedeneev on 1/29/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import IVCollectionKit
11 |
12 | class IVTestCase: XCTestCase {
13 | let collectionView = UICollectionView(frame: UIScreen.main.bounds, collectionViewLayout: UICollectionViewFlowLayout())
14 | lazy var director = CollectionDirector(collectionView: collectionView)
15 |
16 | override func tearDown() {
17 | director.removeAll(clearSections: true)
18 | reload()
19 | super.tearDown()
20 | }
21 |
22 | override func setUp() {
23 | super.setUp()
24 | }
25 |
26 | func reload() {
27 | director.reload()
28 | collectionView.layoutIfNeeded()
29 | }
30 | }
31 |
32 | class IVCollectionKitTests: IVTestCase {
33 |
34 | // let collectionView = UICollectionView(frame: UIScreen.main.bounds, collectionViewLayout: UICollectionViewFlowLayout())
35 | // lazy var director = CollectionDirector(colletionView: collectionView)
36 |
37 | override func setUp() {
38 | super.setUp()
39 | }
40 |
41 | func testPerformanceExample() {
42 | // This is an example of a performance test case.
43 | self.measure {
44 | // Put the code you want to measure the time of here.
45 | }
46 | }
47 |
48 | // func testAppendItem() {
49 | //// self.director.
50 | //
51 | // let collectionView = UICollectionView(frame: UIScreen.main.bounds, collectionViewLayout: UICollectionViewFlowLayout())
52 | // let director = CollectionDirector(colletionView: collectionView)
53 | //
54 | // let section1 = CollectionSection()
55 | // director += section1
56 | // director.reload()
57 | // director.performUpdates(updates: {
58 | // section1 += CollectionItem(item: ())
59 | // }) {
60 | // XCTAssert(collectionView.numberOfItems(inSection: 0) == 1)
61 | // }
62 | // }
63 |
64 | func testInsertDeleteItems() {
65 |
66 | }
67 | }
68 |
69 | final class TestCell : UICollectionViewCell, ConfigurableCollectionItem {
70 | func configure(item: Void) {
71 |
72 | }
73 | static func estimatedSize(item: (), boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
74 | CGSize(width: 50, height: 50)
75 | }
76 | }
77 |
78 |
79 | extension String {
80 | public var diffId: String {
81 | return self
82 | }
83 |
84 | public static func compareContent(_ a: String, _ b: String) -> Bool {
85 | return a == b
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/AbstractCollectionSection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AbstractCollectionSection.swift
3 | // CollectionKit
4 | //
5 | // Created by Igor Vedeneev on 09.12.17.
6 | // Copyright © 2017 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | public protocol AbstractCollectionSection {
12 |
13 | var identifier: String { get }
14 | var isEmpty: Bool { get }
15 |
16 | var headerItem: AbstractCollectionHeaderFooterItem? { get set }
17 | var footerItem: AbstractCollectionHeaderFooterItem? { get set }
18 |
19 | var insetForSection: UIEdgeInsets { get set }
20 | var minimumInterItemSpacing: CGFloat { get set }
21 | var lineSpacing: CGFloat { get set }
22 |
23 | //datasource methods
24 | func numberOfItems() -> Int
25 | func cell(for director: CollectionDirector, indexPath: IndexPath) -> UICollectionViewCell
26 |
27 | //delegate methods
28 | func willDisplayItem(at indexPath: IndexPath, cell: UICollectionViewCell)
29 | func didEndDisplayingItem(at indexPath: IndexPath, cell: UICollectionViewCell)
30 | func didSelectItem(at indexPath: IndexPath)
31 | func didDeselectItem(at indexPath: IndexPath)
32 | func shouldHighlightItem(at indexPath: IndexPath) -> Bool
33 | func didHighlightItem(at indexPath: IndexPath)
34 | func didUnhighlightItem(at indexPath: IndexPath)
35 | func sizeForItem(at indexPath: IndexPath, boundingSize: CGSize) -> CGSize
36 | func shouldSelect(at indexPath: IndexPath) -> Bool
37 | func shouldDeselect(at indexPath: IndexPath) -> Bool
38 |
39 | func itemAdjustsWidth(at index: Int) -> Bool
40 | func itemAdjustsHeight(at index: Int) -> Bool
41 |
42 | func append(item: AbstractCollectionItem)
43 | func append(items: [AbstractCollectionItem])
44 | func removeAll()
45 |
46 | func currentItemIds() -> [String]
47 | }
48 |
49 | /// Default implementation for very rare used methods
50 | public extension AbstractCollectionSection {
51 | var isEmpty: Bool { return numberOfItems() == 0 }
52 |
53 | func willDisplayItem(at indexPath: IndexPath, cell: UICollectionViewCell) {}
54 | func didEndDisplayingItem(at indexPath: IndexPath, cell: UICollectionViewCell) {}
55 | func didDeselectItem(at indexPath: IndexPath) {}
56 | func shouldHighlightItem(at indexPath: IndexPath) -> Bool { return true }
57 | func didHighlightItem(at indexPath: IndexPath) {}
58 | func didUnhighlightItem(at indexPath: IndexPath) {}
59 | func itemAdjustsWidth(at index: Int) -> Bool { return false }
60 | func itemAdjustsHeight(at index: Int) -> Bool { return false }
61 | func append(item: AbstractCollectionItem) { }
62 | func append(items: [AbstractCollectionItem]) { }
63 | func removeAll() { }
64 | func shouldSelect(at indexPath: IndexPath) -> Bool { return true }
65 | func shouldDeselect(at indexPath: IndexPath) -> Bool { return true }
66 | }
67 |
--------------------------------------------------------------------------------
/Examples/PhotoGrid/PhotoCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PhotoCell.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 2/16/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 | import Photos
12 | import Combine
13 |
14 | final class PhotoCell: UICollectionViewCell {
15 | let imageView = UIImageView()
16 | var cancellable: Cancellable?
17 |
18 | override init(frame: CGRect) {
19 | super.init(frame: frame)
20 | addSubview(imageView)
21 | imageView.translatesAutoresizingMaskIntoConstraints = false
22 | imageView.contentMode = .scaleAspectFill
23 | imageView.clipsToBounds = true
24 | NSLayoutConstraint.activate([
25 | imageView.topAnchor.constraint(equalTo: topAnchor),
26 | imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
27 | imageView.leftAnchor.constraint(equalTo: leftAnchor),
28 | imageView.rightAnchor.constraint(equalTo: rightAnchor)
29 | ])
30 | }
31 |
32 | required init?(coder: NSCoder) {
33 | fatalError("init(coder:) has not been implemented")
34 | }
35 |
36 | override func prepareForReuse() {
37 | cancellable?.cancel()
38 | cancellable = nil
39 | }
40 | }
41 |
42 | extension PhotoCell: ConfigurableCollectionItem {
43 | static func estimatedSize(item: PHAsset, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
44 | let side = min(boundingSize.width, boundingSize.height)
45 | let cellSide = ((side - section.minimumInterItemSpacing * 2) / 3).rounded(.down)
46 | return CGSize(width: cellSide, height: cellSide)
47 | }
48 |
49 | func configure(item: PHAsset) {
50 | cancellable = PHImageManager.default().image(asset: item).assign(to: \.image, on: imageView)
51 | }
52 | }
53 |
54 | extension PHImageManager {
55 | private static let queue = DispatchQueue(label: "image.downloader")
56 |
57 | func image(asset: PHAsset) -> AnyPublisher {
58 | var requestId: PHImageRequestID!
59 | return Future { (promise) in
60 | Self.queue.async {
61 | let options = PHImageRequestOptions()
62 | options.deliveryMode = .opportunistic
63 | options.isNetworkAccessAllowed = true
64 | options.resizeMode = .fast
65 | options.isSynchronous = false
66 | requestId = self.requestImage(for: asset, targetSize: CGSize(width: 100, height:100), contentMode: .aspectFill, options: options) { (img, userInfo) in
67 | promise(.success(img))
68 | }
69 | }
70 | }
71 | .handleEvents(receiveCancel: { self.cancelImageRequest(requestId) })
72 | .receive(on: DispatchQueue.main)
73 | .eraseToAnyPublisher()
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Examples/Filter/Cells/SelectFilterCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SelectFilterCell.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 3/21/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 | import Combine
12 |
13 |
14 | final class SelectFilterCell: UICollectionViewCell {
15 | private let titleLabel = UILabel()
16 | private let radioButtonImageView = UIImageView()
17 | var cancellable: Cancellable?
18 |
19 | override init(frame: CGRect) {
20 | super.init(frame: frame)
21 |
22 | backgroundColor = .systemBackground
23 | addSubview(radioButtonImageView)
24 | radioButtonImageView.translatesAutoresizingMaskIntoConstraints = false
25 |
26 | radioButtonImageView.contentMode = .scaleAspectFit
27 |
28 | addSubview(titleLabel)
29 | titleLabel.translatesAutoresizingMaskIntoConstraints = false
30 |
31 | NSLayoutConstraint.activate([
32 | radioButtonImageView.rightAnchor.constraint(equalTo: rightAnchor, constant: -16),
33 | radioButtonImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
34 | radioButtonImageView.widthAnchor.constraint(equalToConstant: 18),
35 | radioButtonImageView.heightAnchor.constraint(equalToConstant: 18),
36 |
37 | titleLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: 16),
38 | titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
39 | titleLabel.rightAnchor.constraint(equalTo: radioButtonImageView.leftAnchor, constant: -10)
40 | ])
41 | }
42 |
43 | required init?(coder: NSCoder) {
44 | fatalError("init(coder:) has not been implemented")
45 | }
46 |
47 | override func prepareForReuse() {
48 | cancellable?.cancel()
49 | cancellable = nil
50 | }
51 | }
52 |
53 | extension SelectFilterCell: ConfigurableCollectionItem {
54 | static func estimatedSize(item: SelectFilterCellViewModel, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
55 | return .init(width: boundingSize.width, height: filterCellHeight)
56 | }
57 |
58 | func configure(item: SelectFilterCellViewModel) {
59 | titleLabel.text = item.title
60 | cancellable = item.output
61 | .map { $0 ? UIImage(named: "checkmark") : nil }
62 | .assign(to: \.image, on: radioButtonImageView)
63 | }
64 | }
65 |
66 | final class SelectFilterCellViewModel {
67 | let title: String
68 | let id: String
69 | lazy var output: AnyPublisher = _output.eraseToAnyPublisher()
70 | private let _output: CurrentValueSubject
71 |
72 | init(title: String, id: String, initiallySelected: Bool) {
73 | self.title = title
74 | self.id = id
75 | self._output = CurrentValueSubject(initiallySelected)
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/Examples/Filter/Model.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Model.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 3/21/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import UIKit.UIImage
11 |
12 | protocol FilterProtocol {
13 | var type: FilterType { get }
14 | var title: String { get }
15 | var id: String { get }
16 | }
17 |
18 | protocol SelectableFilterProtocol {
19 | var id: String { get }
20 | var title: String { get }
21 | }
22 |
23 |
24 | enum SelectionStyle {
25 | case single
26 | case multi
27 |
28 | var onImage: UIImage? {
29 | switch self {
30 | case .multi:
31 | return UIImage(named: "select_on")
32 | case .single:
33 | return UIImage(named: "checkmark")
34 | }
35 | }
36 |
37 | var offImage: UIImage? {
38 | switch self {
39 | case .multi:
40 | return UIImage(named: "select_off")
41 | case .single:
42 | return nil
43 | }
44 | }
45 | }
46 |
47 | enum FilterType {
48 | case singleSelect
49 | case multiSelect
50 | case numRange
51 | case bool
52 | case singleInput
53 | case fromToInput
54 | }
55 |
56 | struct StringFilter: Hashable, FilterProtocol {
57 | let type: FilterType
58 | let title: String
59 | let id = UUID().uuidString
60 | let payload: Payload
61 |
62 | struct Payload: Hashable {
63 | let entries: [StringFilterEntry]
64 | let multiselect: Bool
65 | }
66 | }
67 |
68 | struct StringFilterEntry: Hashable, SelectableFilterProtocol {
69 | var title: String { return displayName }
70 |
71 | let id = UUID().uuidString
72 | let displayName: String
73 | }
74 |
75 | struct BoolFilter: Hashable, FilterProtocol, SelectableFilterProtocol {
76 | let type: FilterType = .bool
77 | let title: String
78 | let id: String = UUID().uuidString
79 | let payload: Payload
80 |
81 | struct Payload: Hashable {
82 | let initialySelected: Bool
83 | }
84 | }
85 |
86 | struct NumberFilter: Hashable, FilterProtocol {
87 | let type: FilterType = .numRange
88 | let title: String
89 | let id: String = UUID().uuidString
90 | let payload: Payload
91 |
92 | struct Payload: Hashable {
93 | let min: Double
94 | let max: Double
95 | let step: Double
96 | let selectedMin: Double?
97 | let selectedMax: Double?
98 | }
99 | }
100 |
101 | struct ManualInputFilter: Hashable, FilterProtocol {
102 | let type: FilterType = .fromToInput
103 | let title: String
104 | let id: String = UUID().uuidString
105 | let payload: Payload
106 |
107 | struct Payload: Hashable {
108 | let fields: [ManualInputField]
109 | }
110 | }
111 |
112 | struct ManualInputField: Hashable {
113 | let key: String
114 | let initialValue: String?
115 | }
116 |
--------------------------------------------------------------------------------
/Examples/SocialNetworkProfile/Cells/MultilineTextCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MultilineTextCell.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 7/28/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | final class MultilineTextCell: UICollectionViewCell {
13 |
14 | private let textLabel = UILabel()
15 |
16 | static let font = UIFont.systemFont(ofSize: 15)
17 |
18 | override init(frame: CGRect) {
19 | super.init(frame: frame)
20 | backgroundColor = .secondarySystemGroupedBackground
21 | clipsToBounds = true
22 | layer.cornerRadius = 8
23 | setupTextLabel()
24 | }
25 |
26 | required init?(coder: NSCoder) {
27 | fatalError("init(coder:) has not been implemented")
28 | }
29 |
30 | private func setupTextLabel() {
31 | addSubview(textLabel)
32 | textLabel.translatesAutoresizingMaskIntoConstraints = false
33 | textLabel.backgroundColor = .secondarySystemGroupedBackground
34 |
35 | NSLayoutConstraint.activate([
36 | textLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8),
37 | textLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: 8),
38 | textLabel.rightAnchor.constraint(equalTo: rightAnchor, constant: -8)
39 | ,textLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8)
40 | ])
41 |
42 | textLabel.numberOfLines = 0
43 | textLabel.font = Self.font
44 | }
45 | }
46 |
47 | extension MultilineTextCell: ConfigurableCollectionItem {
48 | static func estimatedSize(item: MultilineTextViewModel, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
49 |
50 | let font = Self.font
51 | let maxHeight: CGFloat = item.isExpanded ? .greatestFiniteMagnitude : font.lineHeight.rounded(.up) * 3
52 | let w = boundingSize.width - section.insetForSection.left - section.insetForSection.right
53 | let height: CGFloat = (item.text as NSString).boundingRect(
54 | with: CGSize(width: w - 16, height: maxHeight),
55 | options: [.usesFontLeading, .usesLineFragmentOrigin],
56 | attributes: [.font: font],
57 | context: nil).height
58 |
59 | return CGSize(width: w, height: height.rounded(.up) + 16)
60 | }
61 |
62 | func configure(item: MultilineTextViewModel) {
63 | textLabel.numberOfLines = item.isExpanded ? 0 : 3
64 | textLabel.text = item.text
65 | }
66 | }
67 |
68 | final class MultilineTextViewModel: Hashable {
69 | static func == (lhs: MultilineTextViewModel, rhs: MultilineTextViewModel) -> Bool {
70 | lhs.text == rhs.text && lhs.isExpanded == rhs.isExpanded
71 | }
72 |
73 | func hash(into hasher: inout Hasher) {
74 | hasher.combine(text)
75 | hasher.combine(isExpanded)
76 | }
77 |
78 | let text: String
79 | var isExpanded = false
80 |
81 | init(text: String) {
82 | self.text = text
83 | }
84 |
85 |
86 | }
87 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/DeepDiff/iOS/UITableView+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UITableView+Extensions.swift
3 | // DeepDiff
4 | //
5 | // Created by Khoa Pham.
6 | // Copyright © 2018 Khoa Pham. All rights reserved.
7 | //
8 |
9 | #if os(iOS) || os(tvOS)
10 | import UIKit
11 |
12 | public extension UITableView {
13 |
14 | /// Animate reload in a batch update
15 | ///
16 | /// - Parameters:
17 | /// - changes: The changes from diff
18 | /// - section: The section that all calculated IndexPath belong
19 | /// - insertionAnimation: The animation for insert rows
20 | /// - deletionAnimation: The animation for delete rows
21 | /// - replacementAnimation: The animation for reload rows
22 | /// - updateData: Update your data source model
23 | /// - completion: Called when operation completes
24 | func reload(
25 | changes: [Change],
26 | section: Int = 0,
27 | insertionAnimation: UITableView.RowAnimation = .automatic,
28 | deletionAnimation: UITableView.RowAnimation = .automatic,
29 | replacementAnimation: UITableView.RowAnimation = .automatic,
30 | updateData: () -> Void,
31 | completion: ((Bool) -> Void)? = nil) {
32 |
33 | let changesWithIndexPath = IndexPathConverter().convert(changes: changes, section: section)
34 |
35 | unifiedPerformBatchUpdates({
36 | updateData()
37 | self.insideUpdate(
38 | changesWithIndexPath: changesWithIndexPath,
39 | insertionAnimation: insertionAnimation,
40 | deletionAnimation: deletionAnimation
41 | )
42 | }, completion: { finished in
43 | completion?(finished)
44 | })
45 |
46 | // reloadRows needs to be called outside the batch
47 | outsideUpdate(changesWithIndexPath: changesWithIndexPath, replacementAnimation: replacementAnimation)
48 | }
49 |
50 | // MARK: - Helper
51 |
52 | private func unifiedPerformBatchUpdates(
53 | _ updates: (() -> Void),
54 | completion: (@escaping (Bool) -> Void)) {
55 |
56 | if #available(iOS 11, tvOS 11, *) {
57 | performBatchUpdates(updates, completion: completion)
58 | } else {
59 | beginUpdates()
60 | updates()
61 | endUpdates()
62 | completion(true)
63 | }
64 | }
65 |
66 | private func insideUpdate(
67 | changesWithIndexPath: ChangeWithIndexPath,
68 | insertionAnimation: UITableView.RowAnimation,
69 | deletionAnimation: UITableView.RowAnimation) {
70 |
71 | changesWithIndexPath.deletes.executeIfPresent {
72 | deleteRows(at: $0, with: deletionAnimation)
73 | }
74 |
75 | changesWithIndexPath.inserts.executeIfPresent {
76 | insertRows(at: $0, with: insertionAnimation)
77 | }
78 |
79 | changesWithIndexPath.moves.executeIfPresent {
80 | $0.forEach { move in
81 | moveRow(at: move.from, to: move.to)
82 | }
83 | }
84 | }
85 |
86 | private func outsideUpdate(
87 | changesWithIndexPath: ChangeWithIndexPath,
88 | replacementAnimation: UITableView.RowAnimation) {
89 |
90 | changesWithIndexPath.replaces.executeIfPresent {
91 | reloadRows(at: $0, with: replacementAnimation)
92 | }
93 | }
94 | }
95 | #endif
96 |
--------------------------------------------------------------------------------
/Examples/SocialNetworkProfile/Cells/AvatarCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AvatarCell.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 2/18/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | final class AvatarCell: UICollectionViewCell {
13 |
14 | private let imageView = UIImageView()
15 | private let nameLabel = UILabel()
16 | private let ageAndCityLabel = UILabel()
17 |
18 |
19 | override init(frame: CGRect) {
20 | super.init(frame: frame)
21 |
22 | backgroundColor = .secondarySystemGroupedBackground
23 | setupImageView()
24 | setupNameLabel()
25 | setupAgeAndCityLabel()
26 |
27 | NSLayoutConstraint.activate([
28 | imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
29 | imageView.leftAnchor.constraint(equalTo: leftAnchor, constant: 16),
30 | imageView.heightAnchor.constraint(equalToConstant: 48),
31 | imageView.widthAnchor.constraint(equalToConstant: 48),
32 | nameLabel.bottomAnchor.constraint(equalTo: centerYAnchor, constant: -2),
33 | nameLabel.leftAnchor.constraint(equalTo: imageView.rightAnchor, constant: 16),
34 | nameLabel.rightAnchor.constraint(equalTo: rightAnchor, constant: -16),
35 | ageAndCityLabel.topAnchor.constraint(equalTo: centerYAnchor, constant: 2),
36 | ageAndCityLabel.leftAnchor.constraint(equalTo: nameLabel.leftAnchor),
37 | ageAndCityLabel.rightAnchor.constraint(equalTo: nameLabel.rightAnchor),
38 | ])
39 | }
40 |
41 | required init?(coder: NSCoder) {
42 | fatalError("init(coder:) has not been implemented")
43 | }
44 |
45 | private func setupImageView() {
46 | addSubview(imageView)
47 | imageView.contentMode = .scaleAspectFill
48 | imageView.translatesAutoresizingMaskIntoConstraints = false
49 | imageView.clipsToBounds = true
50 | imageView.layer.cornerRadius = 24
51 | imageView.backgroundColor = .tertiarySystemGroupedBackground
52 | }
53 |
54 | private func setupNameLabel() {
55 | addSubview(nameLabel)
56 | nameLabel.translatesAutoresizingMaskIntoConstraints = false
57 | nameLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium)
58 | }
59 |
60 | private func setupAgeAndCityLabel() {
61 | addSubview(ageAndCityLabel)
62 | ageAndCityLabel.translatesAutoresizingMaskIntoConstraints = false
63 | ageAndCityLabel.textColor = .systemGray
64 | ageAndCityLabel.font = UIFont.systemFont(ofSize: 13, weight: .regular)
65 | }
66 | }
67 |
68 | extension AvatarCell: ConfigurableCollectionItem {
69 | static func estimatedSize(item: User, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
70 | return CGSize(width: boundingSize.width, height: 76)
71 | }
72 |
73 | func configure(item: User) {
74 | nameLabel.text = "\(item.firstName) \(item.lastName)"
75 | ageAndCityLabel.text = "26 y. \(item.city)"
76 | imageView.image = UIImage(named: "hazard")
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/IVCollectionKitTests/AnimatedUpdatesTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnimatedUpdatesTests.swift
3 | // IVCollectionKitTests
4 | //
5 | // Created by Igor Vedeneev on 2/24/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import XCTest
10 |
11 | class AnimatedUpdatesTests: IVTestCase {
12 |
13 | var section1 = CollectionSection()
14 | var section2 = CollectionSection()
15 |
16 | func test_simpleUpdates() {
17 | director += section1
18 | section1 += ["1", "2", "3"].map(CollectionItem.init)
19 |
20 | director.reload()
21 | collectionView.layoutIfNeeded()
22 |
23 | director += section2
24 | section2 += ["4", "5", "6", "7"].map(CollectionItem.init)
25 |
26 | let e = expectation(description: "perform updates")
27 | director.performUpdates(forceReloadDataForLargeAmountOfChanges: false) {
28 | e.fulfill()
29 | }
30 |
31 | waitForExpectations(timeout: 0.5) { (e) in
32 | XCTAssert(self.collectionView.numberOfSections == 2)
33 | XCTAssert(self.collectionView.numberOfItems(inSection: 0) == 3)
34 | XCTAssert(self.collectionView.numberOfItems(inSection: 1) == 4)
35 | }
36 | }
37 |
38 | func test_section_and_items_update() {
39 | director += section1
40 | section1 += ["1", "2", "3"].map(CollectionItem.init)
41 |
42 | director.reload()
43 | collectionView.layoutIfNeeded()
44 |
45 | director.insert(section: section2, at: 0)
46 | let insertSectionsStrings = ["4", "5", "6", "7"]
47 | section2 += insertSectionsStrings.map(CollectionItem.init)
48 |
49 | section1.items.remove(at: 0)
50 | section1.items.insert(CollectionItem(item: "test"), at: 0)
51 |
52 | let e = expectation(description: "perform updates")
53 | director.performUpdates(forceReloadDataForLargeAmountOfChanges: false) {
54 | e.fulfill()
55 | }
56 |
57 | waitForExpectations(timeout: 0.5) { (e) in
58 | XCTAssert(self.collectionView.numberOfSections == 2)
59 | XCTAssert(self.collectionView.numberOfItems(inSection: 0) == 4)
60 | XCTAssert(self.collectionView.numberOfItems(inSection: 1) == 3)
61 |
62 | let indexPaths = Array(0..<4).map { IndexPath(item: $0, section: 0) }
63 | let cells = indexPaths.compactMap { self.collectionView.cellForItem(at: $0) as? StringCell }
64 | XCTAssert(cells.count == 4, "Expected 4 cells, got \(cells.count)")
65 | let texts = zip(insertSectionsStrings, cells.compactMap { $0.titleLabel.text })
66 | let textsAreEqual = texts.reduce(true, { $0 && $1.0 == $1.1 })
67 | XCTAssert(textsAreEqual)
68 | }
69 | }
70 |
71 | func test_onlyItemsUpdate() {
72 |
73 | }
74 |
75 | func testPerformanceExample() {
76 | // This is an example of a performance test case.
77 | self.measure {
78 | // Put the code you want to measure the time of here.
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/IVCollectionKit.xcodeproj/xcshareddata/xcschemes/IVCollectionKit.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
33 |
39 |
40 |
41 |
42 |
43 |
53 |
54 |
60 |
61 |
67 |
68 |
69 |
70 |
72 |
73 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/Examples/Base/CollectionHeader.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CollectionHeader.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 2/17/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | final class CollectionHeader: UICollectionReusableView {
13 | private let label = UILabel()
14 |
15 | override init(frame: CGRect) {
16 | super.init(frame: frame)
17 |
18 | addSubview(label)
19 | label.textColor = .systemGray
20 | label.font = UIFont.systemFont(ofSize: 12, weight: .light)
21 | label.translatesAutoresizingMaskIntoConstraints = false
22 | label.numberOfLines = 1
23 | NSLayoutConstraint.activate([
24 | label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
25 | label.leftAnchor.constraint(equalTo: leftAnchor, constant: 16),
26 | label.rightAnchor.constraint(equalTo: rightAnchor, constant: -16)
27 | ])
28 | }
29 |
30 | required init?(coder: NSCoder) {
31 | fatalError("init(coder:) has not been implemented")
32 | }
33 | }
34 |
35 | extension CollectionHeader: ConfigurableCollectionItem {
36 | static func estimatedSize(item: String, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
37 | return CGSize(width: boundingSize.width, height: 48)
38 | }
39 |
40 | func configure(item: String) {
41 | label.text = item.uppercased()
42 | }
43 | }
44 |
45 | final class CollectionFooter: UICollectionReusableView {
46 | private let label = UILabel()
47 |
48 | override init(frame: CGRect) {
49 | super.init(frame: frame)
50 |
51 | addSubview(label)
52 | label.textColor = .systemGray
53 | label.font = UIFont.systemFont(ofSize: 12, weight: .light)
54 | label.translatesAutoresizingMaskIntoConstraints = false
55 | label.numberOfLines = 0
56 | NSLayoutConstraint.activate([
57 | label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
58 | label.leftAnchor.constraint(equalTo: leftAnchor, constant: 16),
59 | label.rightAnchor.constraint(equalTo: rightAnchor, constant: -16),
60 | label.topAnchor.constraint(equalTo: topAnchor, constant: 4)
61 | ])
62 | }
63 |
64 | required init?(coder: NSCoder) {
65 | fatalError("init(coder:) has not been implemented")
66 | }
67 | }
68 |
69 | extension CollectionFooter: ConfigurableCollectionItem {
70 | static func estimatedSize(item: String, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
71 | let labelSize = CGSize(width: boundingSize.width - 32, height: .greatestFiniteMagnitude)
72 | let textHeight = (item as NSString)
73 | .boundingRect(
74 | with: labelSize,
75 | options: [.usesFontLeading, .usesLineFragmentOrigin],
76 | attributes: [.font : UIFont.systemFont(ofSize: 12, weight: .light)],
77 | context: nil
78 | ).height.rounded(.up)
79 | return CGSize(width: boundingSize.width, height: textHeight + 8)
80 | }
81 |
82 | func configure(item: String) {
83 | label.text = item.capitalized
84 | }
85 | }
86 |
87 |
--------------------------------------------------------------------------------
/Examples/Filter/Popups/FilterPopup.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FilterPopup.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 3/22/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | protocol FilterPopup {
12 | func setupHeaderView(title: String)
13 | func setupToolbar()
14 | func cancel()
15 | func reset()
16 | }
17 |
18 |
19 | extension FilterPopup where Self: UIViewController {
20 | func setupHeaderView(title: String) {
21 | let headerView = UIView()
22 | headerView.translatesAutoresizingMaskIntoConstraints = false
23 | headerView.backgroundColor = .systemBackground
24 | view.addSubview(headerView)
25 |
26 | let titleLabel = UILabel()
27 | let cancelButton = UIButton()
28 |
29 | titleLabel.font = UIFont.systemFont(ofSize: 18, weight: .semibold)
30 | titleLabel.text = title
31 |
32 | headerView.addSubview(titleLabel)
33 | headerView.addSubview(cancelButton)
34 |
35 | cancelButton.translatesAutoresizingMaskIntoConstraints = false
36 | cancelButton.backgroundColor = .secondarySystemBackground
37 | cancelButton.addTarget(self, action: "cancel", for: .touchUpInside)
38 |
39 | titleLabel.translatesAutoresizingMaskIntoConstraints = false
40 |
41 | NSLayoutConstraint.activate([
42 | headerView.topAnchor.constraint(equalTo: view.topAnchor),
43 | headerView.heightAnchor.constraint(equalToConstant: 44),
44 | headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
45 | headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
46 |
47 | cancelButton.rightAnchor.constraint(equalTo: headerView.rightAnchor, constant: -16),
48 | cancelButton.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
49 | cancelButton.widthAnchor.constraint(equalToConstant: 18),
50 | cancelButton.heightAnchor.constraint(equalToConstant: 18),
51 |
52 | titleLabel.leftAnchor.constraint(equalTo: headerView.leftAnchor, constant: 16),
53 | titleLabel.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
54 | titleLabel.rightAnchor.constraint(equalTo: cancelButton.leftAnchor, constant: -10)
55 | ])
56 | }
57 |
58 | func setupToolbar() {
59 | let toolbar = UIToolbar()
60 | toolbar.tintColor = .systemPurple
61 | toolbar.translatesAutoresizingMaskIntoConstraints = false
62 | toolbar.isTranslucent = false
63 | view.addSubview(toolbar)
64 |
65 | NSLayoutConstraint.activate([
66 | toolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
67 | toolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
68 | toolbar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
69 | ])
70 |
71 | toolbar.setItems([
72 | UIBarButtonItem(title: "Reset", style: .plain, target: self, action: "reset"),
73 | UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil),
74 | UIBarButtonItem(barButtonSystemItem: .done, target: self, action: "cancel"),
75 | ], animated: false)
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/Examples/Filter/Cells/RadioButtonCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RadioButtonCell.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 3/3/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 | import Combine
12 |
13 | let filterCellHeight: CGFloat = 56
14 |
15 | final class RadioButtonCell: UICollectionViewCell {
16 | private let titleLabel = UILabel()
17 | private let radioButtonImageView = UIImageView()
18 | var cancellable: Cancellable?
19 |
20 | override var isHighlighted: Bool {
21 | didSet {
22 | backgroundColor = isHighlighted ? UIColor(white: 0.85, alpha: 1) : .systemBackground
23 | }
24 | }
25 |
26 | override init(frame: CGRect) {
27 | super.init(frame: frame)
28 |
29 | backgroundColor = .systemBackground
30 | addSubview(radioButtonImageView)
31 | radioButtonImageView.translatesAutoresizingMaskIntoConstraints = false
32 | titleLabel.translatesAutoresizingMaskIntoConstraints = false
33 |
34 | addSubview(titleLabel)
35 |
36 | NSLayoutConstraint.activate([
37 | radioButtonImageView.rightAnchor.constraint(equalTo: rightAnchor, constant: -16),
38 | radioButtonImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
39 | radioButtonImageView.widthAnchor.constraint(equalToConstant: 18),
40 | radioButtonImageView.heightAnchor.constraint(equalToConstant: 18),
41 |
42 | titleLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: 16),
43 | titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
44 | titleLabel.rightAnchor.constraint(equalTo: radioButtonImageView.leftAnchor, constant: -10)
45 | ])
46 | }
47 |
48 | required init?(coder: NSCoder) {
49 | fatalError("init(coder:) has not been implemented")
50 | }
51 |
52 | override func prepareForReuse() {
53 | cancellable?.cancel()
54 | cancellable = nil
55 | }
56 | }
57 |
58 | extension RadioButtonCell: ConfigurableCollectionItem {
59 | static func estimatedSize(item: RadioButtonViewModel, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
60 | return .init(width: boundingSize.width, height: filterCellHeight)
61 | }
62 |
63 | func configure(item: RadioButtonViewModel) {
64 | titleLabel.text = item.title
65 | cancellable = item.output
66 | .map { [item] in $0 ? item.selectionStyle.onImage : item.selectionStyle.offImage }
67 | .assign(to: \.image, on: radioButtonImageView)
68 | }
69 | }
70 |
71 | final class RadioButtonViewModel {
72 | let title: String
73 | let id: String
74 | let selectionStyle: SelectionStyle
75 | lazy var output: AnyPublisher = _output.eraseToAnyPublisher()
76 | private let _output: CurrentValueSubject
77 |
78 | init(filter: SelectableFilterProtocol, initiallySelected: Bool, selectionStyle: SelectionStyle) {
79 | self.title = filter.title
80 | self.id = filter.id
81 | self.selectionStyle = selectionStyle
82 | self._output = CurrentValueSubject(initiallySelected)
83 | }
84 |
85 | func toggle() {
86 | _output.send(!_output.value)
87 | }
88 | }
89 |
90 |
--------------------------------------------------------------------------------
/Examples/Filter/Popups/StringFilterPopup.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StringFilterPopup.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 3/20/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | final class StringFilterPopup: CollectionViewController, PopupContentView, FilterPopup {
13 |
14 | var frameInPopup: CGRect {
15 | let safeArea: CGFloat = 34
16 | let height: CGFloat = max(CGFloat(filter.payload.entries.count * 51) + safeArea + 30.0, 300)
17 | return CGRect(x: 0, y: view.bounds.height - height, width: view.bounds.width, height: height)
18 | }
19 |
20 | var scrollView: UIScrollView? {
21 | return collectionView
22 | }
23 |
24 | var filter: StringFilter!
25 | var selectedEntries = Array()
26 |
27 | var onSelect: (([SelectableFilterProtocol]) -> Void)?
28 |
29 | override func viewDidLoad() {
30 | super.viewDidLoad()
31 |
32 | collectionView.alwaysBounceVertical = false
33 |
34 | collectionView.backgroundColor = .systemBackground
35 | topConstraint.constant = 44
36 | bottomConstraint.constant = -44
37 |
38 | setupHeaderView(title: filter.title)
39 | roundCorners()
40 | setupToolbar()
41 |
42 | let section = CollectionSection()
43 |
44 | let selectionStyle: SelectionStyle = filter.payload.multiselect ? .multi : .single
45 | let viewModels = filter.payload.entries.map { [unowned self] entry -> RadioButtonViewModel in
46 | let isSelected = self.selectedEntries.contains { $0.id == entry.id }
47 | return RadioButtonViewModel(filter: entry, initiallySelected: isSelected, selectionStyle: selectionStyle)
48 | }
49 | section += viewModels.map { [unowned self] vm in
50 | return CollectionItem(item: vm)
51 | .onSelect { [unowned self] _ in
52 | guard let entry = self.filter.payload.entries.first(where: { $0.id == vm.id }) else { return }
53 | if self.filter.payload.multiselect {
54 | vm.toggle()
55 | if let idx = self.selectedEntries.firstIndex(where: { $0.id == vm.id }) {
56 | self.selectedEntries.remove(at: idx)
57 | } else {
58 | self.selectedEntries.append(entry)
59 | }
60 | } else {
61 | vm.toggle()
62 | if let id = self.selectedEntries.first?.id {
63 | viewModels.first(where: { $0.id == id })?.toggle()
64 | }
65 | self.selectedEntries.removeAll()
66 | self.selectedEntries.append(entry)
67 | self.onSelect?(self.selectedEntries)
68 | self.dismiss(animated: true, completion: nil)
69 | }
70 | }
71 | }
72 |
73 | director += section
74 | director.reload()
75 | }
76 |
77 | override func viewDidDisappear(_ animated: Bool) {
78 | super.viewDidDisappear(animated)
79 | onSelect?(selectedEntries)
80 | }
81 |
82 | @objc func cancel() {
83 | dismiss(animated: true, completion: nil)
84 | }
85 |
86 | @objc func reset() {
87 | print("reset")
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/Examples/Filter/Cells/FilterTextValueCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FilterTextValueCell.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 3/2/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 | import Combine
12 |
13 | final class FilterTextValueCell: UICollectionViewCell {
14 | private let textField = FloatingLabelTextField()
15 | private var cancellable: Cancellable?
16 |
17 | override init(frame: CGRect) {
18 | super.init(frame: frame)
19 |
20 | addSubview(textField)
21 | textField.showUnderlineView = false
22 | textField.translatesAutoresizingMaskIntoConstraints = false
23 | textField.isEnabled = false
24 | backgroundColor = .systemBackground
25 |
26 | NSLayoutConstraint.activate([
27 | textField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
28 | textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
29 | textField.bottomAnchor.constraint(equalTo: bottomAnchor),
30 | textField.topAnchor.constraint(equalTo: topAnchor, constant: 0)
31 | ])
32 | }
33 |
34 | required init?(coder: NSCoder) {
35 | fatalError("init(coder:) has not been implemented")
36 | }
37 |
38 | override func prepareForReuse() {
39 | cancellable?.cancel()
40 | }
41 | }
42 |
43 | extension FilterTextValueCell: ConfigurableCollectionItem {
44 | static func estimatedSize(item: TextSelectViewModel, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
45 | return .init(width: boundingSize.width, height: filterCellHeight)
46 |
47 | }
48 |
49 | func configure(item: TextSelectViewModel) {
50 | cancellable = item.output.assign(to: \.text, on: textField)
51 | textField.kg_placeholder = item.title
52 | }
53 | }
54 |
55 |
56 | final class TextSelectViewModel {
57 | let title: String
58 | let id: String
59 | lazy var output: AnyPublisher = _output
60 | .map { entries -> String in
61 | return entries.reduce(into: "", {
62 | if !$0.isEmpty {
63 | $0.append(", ")
64 | }
65 | $0.append($1.title) })
66 | }.eraseToAnyPublisher()
67 | private let _output = CurrentValueSubject<[SelectableFilterProtocol], Never>([])
68 |
69 | init(filter: FilterProtocol) {
70 | self.title = filter.title
71 | self.id = filter.id
72 | }
73 |
74 | func updateSelection(_ entries: [SelectableFilterProtocol]) {
75 | _output.send(entries)
76 | }
77 |
78 | func currentValue() -> [SelectableFilterProtocol] {
79 | return _output.value
80 | }
81 | }
82 |
83 | final class NumberSelectViewModel {
84 | let title: String
85 | let id: String
86 | lazy var output: AnyPublisher = _output
87 | .map { entries -> String in
88 | return entries.reduce(into: "", {
89 | if !$0.isEmpty {
90 | $0.append(", ")
91 | }
92 | $0.append($1.title) })
93 | }.eraseToAnyPublisher()
94 | private let _output = CurrentValueSubject<[SelectableFilterProtocol], Never>([])
95 |
96 | init(filter: FilterProtocol) {
97 | self.title = filter.title
98 | self.id = filter.id
99 | }
100 |
101 | func updateSelection(_ entries: [SelectableFilterProtocol]) {
102 | _output.send(entries)
103 | }
104 |
105 | func currentValue() -> [SelectableFilterProtocol] {
106 | return _output.value
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/Examples/Menu/MenuViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MenuViewController.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 2/16/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | extension UINavigationController: PopupContentView {
13 | var frameInPopup: CGRect { return CGRect(x: 0, y: 80, width: view.bounds.width, height: view.bounds.height - 50) }
14 | var scrollView: UIScrollView? { return (viewControllers.last as? PopupContentView)?.scrollView }
15 | }
16 |
17 | final class MenuViewController: CollectionViewController {
18 |
19 | override func viewDidLoad() {
20 | super.viewDidLoad()
21 | title = "Examples"
22 | collectionView.backgroundColor = .systemGroupedBackground
23 | let cells = CollectionItem(item: "Multiple cells").adjustsWidth(true).onSelect { [weak self] _ in
24 | self?.navigationController?.pushViewController(ManyCellsViewController(), animated: true)
25 | }
26 |
27 | let social = CollectionItem(item: "Social profile").adjustsWidth(true).onSelect { [weak self] _ in
28 | self?.navigationController?.pushViewController(ProfileViewController(), animated: true)
29 | }
30 |
31 | let filter = CollectionItem(item: "Complex filter").adjustsWidth(true).onSelect { [weak self] _ in
32 | self?.navigationController?.pushViewController(FilterViewController(), animated: true)
33 | }
34 |
35 | let s1 = CollectionSection(items: [cells, social, filter])
36 | s1.lineSpacing = 1
37 | s1.headerItem = CollectionHeaderFooterView(item: "Complex")
38 | director += s1
39 |
40 | let paginationSection = CollectionSection()
41 | paginationSection.headerItem = CollectionHeaderFooterView(item: "Pagination")
42 | let paginationItem = CollectionItem(item: "Pagination")
43 | .adjustsWidth(true)
44 | .onSelect { [weak self] _ in
45 | self?.navigationController?.pushViewController(PaginationViewController(), animated: true)
46 | }
47 | paginationSection += paginationItem
48 | director += paginationSection
49 |
50 | let photos = CollectionItem(item: "Custom Section (photo grid)").adjustsWidth(true).onSelect { [weak self] _ in
51 | self?.navigationController?.pushViewController(PhotoGridViewController(), animated: true)
52 | }
53 |
54 | let s2 = CollectionSection(items: [photos])
55 | s2.headerItem = CollectionHeaderFooterView(item: "custom section")
56 | director += s2
57 |
58 | let s3 = CollectionSection(items: [
59 | CollectionItem(item: "PopUp!").adjustsWidth(true).onSelect { [weak self] _ in
60 | let _vc = PopupController()
61 | _vc.content.roundCorners()
62 | _vc.content.setViewControllers([ProfileViewController()], animated: false)
63 | self?.present(_vc, animated: true, completion: nil)
64 | }
65 | ])
66 | s3.headerItem = CollectionHeaderFooterView(item: "custom layout")
67 | s3.footerItem = CollectionHeaderFooterView(item: "If we try to run make after the changes, only the target say_hello will be executed. That's because only the first target in the makefile is the default target. Often called the default goal, this is the reason you will see all as the first target in most projects. It is the responsibility of all to call other targets. We can override this behavior using a special phony target called .DEFAULT_GOAL.")
68 | director += s3
69 |
70 | director.performUpdates()
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Examples/Filter/Core/FloatingLabelTextField.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TextField.swift
3 | // jetfly
4 | //
5 | // Created by Igor Vedeneev on 26/08/2018.
6 | // Copyright © 2018 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class FloatingLabelTextField : UITextField {
12 |
13 | var placeholderFont: UIFont?
14 | private let underlineLayer = CALayer()
15 | private var placeholderLabel = UILabel()
16 | var flotatingLabelTopPosition: CGFloat = -14
17 | var showUnderlineView = true
18 |
19 | var kg_placeholder: String? {
20 | didSet {
21 | guard kg_placeholder != nil else { return }
22 | placeholderLabel.text = kg_placeholder
23 | placeholderLabel.sizeToFit()
24 | }
25 | }
26 |
27 | deinit {
28 | NotificationCenter.default.removeObserver(self)
29 | }
30 |
31 | required init?(coder aDecoder: NSCoder) {
32 | super.init(coder: aDecoder)
33 | initialSetup()
34 | }
35 |
36 | init() {
37 | super.init(frame: .zero)
38 | initialSetup()
39 | }
40 |
41 | private func initialSetup() {
42 | placeholderFont = .systemFont(ofSize: 13)
43 | layer.addSublayer(underlineLayer)
44 | underlineLayer.backgroundColor = UIColor.separator.cgColor
45 | borderStyle = .none
46 | clipsToBounds = false
47 |
48 | placeholderLabel.textColor = UIColor.secondaryLabel
49 | addSubview(placeholderLabel)
50 | clearButtonMode = .always
51 |
52 | NotificationCenter.default.addObserver(self,
53 | selector: #selector(kg_textDidChange(notif:)),
54 | name: UITextField.textDidChangeNotification,
55 | object: nil)
56 | }
57 |
58 | private func setupPlaceholderLabel() {
59 | placeholderLabel.frame = CGRect(origin: .zero, size: .zero)
60 | }
61 |
62 | private func animatePlaceholderLabelOnTop() {
63 | UIView.animate(withDuration: 0.2) {
64 | self.setPlaceholderTopAttributes()
65 | }
66 | }
67 |
68 | private func animatePlaceholderLabelOnBottom() {
69 | UIView.animate(withDuration: 0.2) {
70 | self.setPlaceholderBottomAttributes()
71 | }
72 | }
73 |
74 | private func setPlaceholderTopAttributes() {
75 | placeholderLabel.transform = CGAffineTransform(scaleX: 0.75, y: 0.75)
76 | placeholderLabel.frame.origin.x = 0
77 | let top: CGFloat = (bounds.height - placeholderLabel.font.lineHeight + padding.top) / 2 - font!.lineHeight * 0.75 - 4
78 | placeholderLabel.frame.origin.y = top
79 | }
80 |
81 | private func setPlaceholderBottomAttributes() {
82 | placeholderLabel.transform = .identity
83 | placeholderLabel.frame.origin.x = 0
84 | placeholderLabel.frame.origin.y = (bounds.height - placeholderLabel.font.lineHeight + padding.top) / 2
85 | }
86 |
87 | @objc private func kg_textDidChange(notif: Notification) {
88 | guard let textField = notif.object as? FloatingLabelTextField, textField == self else { return }
89 |
90 | if placeholderLabel.superview == nil {
91 | addSubview(placeholderLabel)
92 | }
93 |
94 | if let txt = text, !txt.isEmpty {
95 | if self.placeholderLabel.frame.origin.y != flotatingLabelTopPosition {
96 | animatePlaceholderLabelOnTop()
97 | }
98 | } else {
99 | animatePlaceholderLabelOnBottom()
100 | }
101 | }
102 |
103 | let padding = UIEdgeInsets(top: 15, left: 0, bottom: 0, right: 0);
104 |
105 | override func textRect(forBounds bounds: CGRect) -> CGRect {
106 | return bounds.inset(by: padding)
107 | }
108 |
109 | override func placeholderRect(forBounds bounds: CGRect) -> CGRect {
110 | return bounds.inset(by: padding)
111 | }
112 |
113 | override func editingRect(forBounds bounds: CGRect) -> CGRect {
114 | return bounds.inset(by: padding)
115 | }
116 |
117 | override func layoutSubviews() {
118 | super.layoutSubviews()
119 |
120 | if (text ?? "").isEmpty {
121 | setPlaceholderBottomAttributes()
122 | } else {
123 | setPlaceholderTopAttributes()
124 | }
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/IVCollectionKitTests/CollectionUpdaterTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CollectionUpdaterTests.swift
3 | // IVCollectionKitTests
4 | //
5 | // Created by Igor Vedeneev on 4/25/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import IVCollectionKit
11 |
12 | class CollectionUpdaterTests: IVTestCase {
13 | private lazy var updater = CollectionUpdater(collectionView)
14 | var section1 = CollectionSection()
15 | var section2 = CollectionSection()
16 |
17 | func test_emptyDirectorReturnsReload() {
18 | director += section1
19 |
20 | let u = _updates()
21 | let isReload: Bool
22 | switch u {
23 | case .reload:
24 | isReload = true
25 | default:
26 | isReload = false
27 | }
28 |
29 | XCTAssert(isReload, "unexpected update type. expected reload, got update")
30 | }
31 |
32 | func test_OnlyItemsUpdate() {
33 | director += section1
34 |
35 | director.reload()
36 | collectionView.layoutIfNeeded()
37 |
38 | section1 += CollectionItem(item: "1")
39 |
40 | let updates = _updates()
41 |
42 | if case Update.update(let sections, let items) = updates {
43 | XCTAssert(sections.isEmpty
44 | && items.inserts.count == 1
45 | && items.deletes.isEmpty
46 | && items.replaces.isEmpty
47 | && items.moves.isEmpty, "incorrect updates")
48 | } else {
49 | XCTAssert(false, "incorrect update type. expected update but got reload")
50 | }
51 | }
52 |
53 | func test_sectionAndItemsUpdate() {
54 | director += section1
55 | director.reload()
56 | collectionView.layoutIfNeeded()
57 |
58 | director.remove(section: section1)
59 | director += section2
60 | section2 += CollectionItem(item: "1")
61 | section2 += CollectionItem(item: "2")
62 |
63 | let u = _updates()
64 | if case Update.update(let sections, let items) = u {
65 | let sectionDeletesCount = sections.compactMap { $0.delete }.count
66 | let sectionInsertsCount = sections.compactMap { $0.insert }.count
67 |
68 | XCTAssert(sections.count == 2, "incorrect sections update count")
69 | XCTAssert(sectionDeletesCount == 1, "incorrect sections DELETES count")
70 | XCTAssert(sectionInsertsCount == 1, "incorrect sections INSERTS count")
71 |
72 | XCTAssert(items.inserts.count == 0 // ignore inserts in new section
73 | && items.deletes.isEmpty
74 | && items.replaces.isEmpty
75 | && items.moves.isEmpty, "incorrect updates")
76 | } else {
77 | XCTAssert(false, "incorrect update type. expected update but got reload")
78 | }
79 | }
80 |
81 | /// delete item from section 0 and insert section at 0
82 | func test_deleteItemAndInsertSection() {
83 | director += section2
84 | section2 += CollectionItem(item: "1")
85 |
86 | reload()
87 |
88 | director += section1
89 | section2.removeAll()
90 |
91 | let u = _updates()
92 |
93 | if case Update.update(let sections, let items) = u {
94 | let sectionInsertsCount = sections.compactMap { $0.insert }.count
95 |
96 | XCTAssert(sectionInsertsCount == 1, "incorrect sections INSERTS count")
97 | XCTAssert(items.deletes.count == 1, "expected one item DELETE, got \(items.deletes.count)")
98 | let itemUpdate = items.deletes.first!
99 | XCTAssert(itemUpdate.section == 0, "incorrect delete indexpath")
100 | } else {
101 | XCTAssert(false, "incorrect update type. expected update but got reload")
102 | }
103 | }
104 |
105 | func test_moveItemAndPreviousSection() {
106 |
107 | }
108 |
109 | private func _updates() -> Update {
110 | return updater.calculateUpdates(oldSectionIds: director.sectionIds,
111 | currentSections: director.sections,
112 | itemMap: director.lastCommitedSectionAndItemsIdentifiers,
113 | forceReloadDataForLargeAmountOfChanges: false)
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/CollectionSection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CollectionSection.swift
3 | // CollectionKit
4 | //
5 | // Created by Igor Vedeneev on 13.09.17.
6 | // Copyright © 2017 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | /// Class represents `UICollectionView` section model.
12 | /// It contains models for each cell in section, models for header and footer
13 | /// Also it contains values for `insetForSection`, `minimumInterItemSpacing` and `lineSpacing`
14 | open class CollectionSection : AbstractCollectionSection {
15 |
16 | public let identifier: String
17 |
18 | public var items: [AbstractCollectionItem] = []
19 | public var headerItem: AbstractCollectionHeaderFooterItem?
20 | public var footerItem: AbstractCollectionHeaderFooterItem?
21 |
22 | open var insetForSection: UIEdgeInsets = .zero
23 | open var minimumInterItemSpacing: CGFloat = .leastNormalMagnitude
24 | open var lineSpacing: CGFloat = 0
25 |
26 | public init(id: String = UUID().uuidString, items: [AbstractCollectionItem] = []) {
27 | self.items = items
28 | self.identifier = id
29 | }
30 |
31 | public func cell(for director: CollectionDirector, indexPath: IndexPath) -> UICollectionViewCell {
32 | let item = items[indexPath.row]
33 | let cell = director.private_dequeueReusableCell(of: item.cellType, reuseIdentifier: item.reuseIdentifier, for: indexPath)
34 | item.configure(cell)
35 |
36 | return cell
37 | }
38 |
39 | public func numberOfItems() -> Int {
40 | return items.count
41 | }
42 |
43 | public func currentItemIds() -> [String] {
44 | return items.map { $0.identifier }
45 | }
46 |
47 | public func append(item: AbstractCollectionItem) {
48 | items.append(item)
49 | }
50 |
51 | public func append(items: [AbstractCollectionItem]) {
52 | self.items.append(contentsOf: items)
53 | }
54 |
55 | public func insert(item: AbstractCollectionItem, at index: Int) {
56 | items.insert(item, at: index)
57 | }
58 |
59 | public func removeAll() {
60 | items.removeAll()
61 | }
62 |
63 | open func willDisplayItem(at indexPath: IndexPath, cell: UICollectionViewCell) {
64 | guard indexPath.item < numberOfItems() else { return }
65 | items[indexPath.item].onDisplay?(indexPath, cell)
66 | }
67 |
68 | open func didEndDisplayingItem(at indexPath: IndexPath, cell: UICollectionViewCell) {
69 | guard indexPath.item < numberOfItems() else { return }
70 | items[indexPath.item].onEndDisplay?(indexPath, cell)
71 | }
72 |
73 | open func didSelectItem(at indexPath: IndexPath) {
74 | guard indexPath.item < numberOfItems() else { return }
75 | items[indexPath.item].onSelect?(indexPath)
76 | }
77 |
78 | open func didDeselectItem(at indexPath: IndexPath) {
79 | guard indexPath.item < numberOfItems() else { return }
80 | items[indexPath.item].onDeselect?(indexPath)
81 | }
82 |
83 | open func shouldHighlightItem(at indexPath: IndexPath) -> Bool {
84 | guard indexPath.item < numberOfItems() else { return false }
85 | return items[indexPath.item].shouldHighlight
86 | }
87 |
88 | open func didHighlightItem(at indexPath: IndexPath) {
89 | guard indexPath.item < numberOfItems() else { return }
90 | items[indexPath.item].onHighlight?(indexPath)
91 | }
92 |
93 | open func didUnhighlightItem(at indexPath: IndexPath) {
94 | guard indexPath.item < numberOfItems() else { return }
95 | items[indexPath.item].onUnighlight?(indexPath)
96 | }
97 |
98 | open func sizeForItem(at indexPath: IndexPath, boundingSize: CGSize) -> CGSize {
99 | return items[indexPath.item].estimatedSize(boundingSize: boundingSize, in: self)
100 | }
101 |
102 | public func itemAdjustsWidth(at index: Int) -> Bool {
103 | guard !isEmpty else { return false }
104 | return items[index].adjustsWidth
105 | }
106 |
107 | public func itemAdjustsHeight(at index: Int) -> Bool {
108 | guard !isEmpty else { return false }
109 | return items[index].adjustsHeight
110 | }
111 |
112 | public func shouldSelect(at indexPath: IndexPath) -> Bool {
113 | return items[indexPath.row].shouldSelect
114 | }
115 |
116 | public func shouldDeselect(at indexPath: IndexPath) -> Bool {
117 | return items[indexPath.row].shouldDeselect
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/Examples/Filter/Popups/NumberPickerPopup.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatePickerPopup.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 3/21/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | final class NumberPickerPopup: UIViewController, PopupContentView, FilterPopup {
13 | var frameInPopup: CGRect {
14 | let h: CGFloat = 34 + 250
15 | return CGRect(x: 0, y: view.bounds.height - h, width: view.bounds.width, height: h)
16 | }
17 |
18 | var scrollView: UIScrollView? { return nil }
19 |
20 | var min: Double { return filter.payload.min }
21 | var max: Double { return filter.payload.max }
22 | var step: Double { return filter.payload.step }
23 |
24 | var filter: NumberFilter!
25 |
26 | var onSelect: ((Double, Double) -> Void)?
27 |
28 | private var selectedMin: Double? = nil
29 | private var selectedMax: Double? = nil
30 |
31 | lazy var pickerView = UIPickerView()
32 |
33 | lazy var numberFormatter: NumberFormatter = {
34 | let nf = NumberFormatter()
35 | nf.allowsFloats = true
36 | nf.usesGroupingSeparator = false
37 | nf.numberStyle = .decimal
38 | return nf
39 | }()
40 |
41 | override func viewDidLoad() {
42 | super.viewDidLoad()
43 |
44 | roundCorners()
45 | view.addSubview(pickerView)
46 | setupHeaderView(title: filter.title)
47 | setupToolbar()
48 | pickerView.translatesAutoresizingMaskIntoConstraints = false
49 |
50 | pickerView.dataSource = self
51 | pickerView.delegate = self
52 |
53 | view.backgroundColor = .systemBackground
54 | pickerView.backgroundColor = .systemBackground
55 |
56 | NSLayoutConstraint.activate([
57 | pickerView.topAnchor.constraint(equalTo: view.topAnchor, constant: 44),
58 | pickerView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -44),
59 | pickerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
60 | pickerView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
61 | ])
62 | }
63 |
64 | @objc func cancel() {
65 | dismiss(animated: true, completion: nil)
66 | }
67 |
68 | @objc func reset() {
69 | selectedMin = nil
70 | selectedMax = nil
71 | pickerView.selectRow(0, inComponent: 0, animated: true)
72 | pickerView.selectRow(0, inComponent: 1, animated: true)
73 | }
74 | }
75 |
76 | extension NumberPickerPopup : UIPickerViewDataSource {
77 | func numberOfComponents(in pickerView: UIPickerView) -> Int {
78 | return 2
79 | }
80 |
81 | func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
82 | if component == 0 {
83 | let _max = selectedMax ?? max
84 | return Int(((_max - min) / step).rounded(.up)) + 2
85 | }
86 |
87 | if component == 1 {
88 | let _min = selectedMin ?? min
89 | return Int(((max - _min) / step).rounded(.up)) + 2
90 | }
91 |
92 | fatalError("incorrect compopent")
93 | }
94 | }
95 |
96 | extension NumberPickerPopup: UIPickerViewDelegate {
97 | func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
98 | if row == 0 {
99 | if component == 0 {
100 | return "from"
101 | } else {
102 | return "to"
103 | }
104 | }
105 |
106 | if component == 0 {
107 | let value = min + Double(row - 1) * step
108 | return numberFormatter.string(from: NSNumber(value: value))
109 | } else {
110 | let value = max - Double(row - 1) * step
111 | return numberFormatter.string(from: NSNumber(value: value))
112 | }
113 | }
114 |
115 | func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
116 |
117 | defer {
118 | pickerView.reloadAllComponents()
119 | }
120 | if row == 0 {
121 | if component == 0 {
122 | selectedMin = nil
123 | } else {
124 | selectedMax = nil
125 | }
126 |
127 | return
128 | }
129 |
130 | if component == 0 {
131 | selectedMin = min + Double(row) * step - step
132 | } else {
133 | selectedMax = max - Double(row) * step + step
134 | }
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/CollectionUpdater.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CollectionUpdater.swift
3 | // CollectionKit
4 | //
5 | // Created by Igor Vedeneev on 22.11.17.
6 | // Copyright © 2017 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | private let maxAmoutOfAnimatedSectionChanges = 10
12 |
13 | /// Update model for collectionView
14 | enum Update {
15 | /// `reloadData` should be called
16 | case reload
17 | /// Section and itenms update for `performBatchUpdate` method
18 | case update(sections: [Change], items: ChangeWithIndexPath)
19 | }
20 |
21 | /// Responsible for update calculation
22 | final class CollectionUpdater {
23 |
24 | init(_ cv: UICollectionView?) {
25 | self.collectionView = cv
26 | }
27 |
28 | weak var collectionView: UICollectionView?
29 |
30 | func calculateUpdates(
31 | oldSectionIds: [String],
32 | currentSections: [AbstractCollectionSection],
33 | itemMap: [String: [String]],
34 | forceReloadDataForLargeAmountOfChanges: Bool) -> Update
35 | {
36 | if oldSectionIds.isEmpty {
37 | return .reload
38 | }
39 |
40 | let newSectionIds = currentSections.map { $0.identifier }
41 | let sectionChanges = diff(old: oldSectionIds, new: newSectionIds)
42 | let converter = IndexPathConverter()
43 |
44 | if sectionChanges.count > maxAmoutOfAnimatedSectionChanges && forceReloadDataForLargeAmountOfChanges {
45 | return .reload
46 | }
47 |
48 | var itemChanges = Array()
49 | currentSections.enumerated().forEach { (idx, section) in
50 | let oldItemIds = itemMap[section.identifier] ?? section.currentItemIds()
51 | let diff_ = diff(old: oldItemIds, new: section.currentItemIds())
52 | guard !diff_.isEmpty else { return }
53 | itemChanges.append(converter.convert(changes: diff_, section: idx))
54 | }
55 |
56 | let inserts = itemChanges.flatMap { $0.inserts }
57 | let reloads = itemChanges.flatMap { $0.replaces }
58 | if sectionChanges.isEmpty {
59 | return .update(
60 | sections: sectionChanges,
61 | items: ChangeWithIndexPath(
62 | inserts: inserts,
63 | deletes: itemChanges.flatMap { $0.deletes },
64 | replaces: reloads,
65 | moves: itemChanges.flatMap { $0.moves }
66 | )
67 | )
68 | }
69 |
70 | var deletes = Array()
71 | deletes.reserveCapacity(itemChanges.flatMap { $0.deletes }.count)
72 |
73 | var moves = Array<(from: IndexPath, to: IndexPath)>()
74 | moves.reserveCapacity(itemChanges.flatMap { $0.moves }.count)
75 |
76 |
77 | var sectionMap = Dictionary() // map between old and new section indicies
78 | for i in 0.. IndexPath in
87 | let fixedSection = sectionMap[indexPath.section] ?? indexPath.section
88 | return IndexPath(item: indexPath.item, section: fixedSection)
89 | }
90 |
91 | deletes.append(contentsOf: fixedDeletes)
92 | }
93 |
94 | changesWithIndexPath.moves.executeIfPresent { _moves in
95 | let fixedMoves = _moves.map { (arg) -> (IndexPath, IndexPath) in
96 | let (from, to) = arg
97 | let fixedFromSection = sectionMap[from.section] ?? from.section
98 | let fixedFromIndexPath = IndexPath(item: from.item, section: fixedFromSection)
99 | return (fixedFromIndexPath, to)
100 | }
101 |
102 | moves.append(contentsOf: fixedMoves)
103 | }
104 | }
105 |
106 | return .update(
107 | sections: sectionChanges,
108 | items: ChangeWithIndexPath(
109 | inserts: itemChanges.flatMap { $0.inserts },
110 | deletes: deletes,
111 | replaces: itemChanges.flatMap { $0.replaces },
112 | moves: moves
113 | )
114 | )
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/Examples/Filter/FilterViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FilterViewController.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 3/2/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 | import Combine
12 |
13 |
14 |
15 | final class FilterViewController: CollectionViewController {
16 |
17 | let df: DateFormatter = {
18 | let d = DateFormatter()
19 | d.dateFormat = "yyyy"
20 | return d
21 | }()
22 |
23 |
24 | override func viewDidLoad() {
25 | super.viewDidLoad()
26 |
27 | let s1 = CollectionSection()
28 | s1.lineSpacing = 1
29 | s1.insetForSection = UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 0)
30 | director += s1
31 |
32 | let s2 = CollectionSection()
33 | s2.lineSpacing = 1
34 |
35 | let filters: [FilterProtocol] = [
36 | StringFilter(type: .singleSelect, title: "Previous owners", payload: StringFilter.Payload(entries: ["One", "Two or less"].map(StringFilterEntry.init), multiselect: false)),
37 | NumberFilter(title: "Year", payload: NumberFilter.Payload(min: 1890, max: 2020, step: 1, selectedMin: nil, selectedMax: nil)),
38 | NumberFilter(title: "Engine", payload: NumberFilter.Payload(min: 0.2, max: 4.4, step: 0.1, selectedMin: nil, selectedMax: nil)),
39 | StringFilter(type: .singleSelect, title: "Transmission", payload: StringFilter.Payload(entries: ["Automatic", "Manual"].map(StringFilterEntry.init), multiselect: true)),
40 | StringFilter(type: .singleSelect, title: "Drive", payload: StringFilter.Payload(entries: ["Front wheel", "Four wheel", "Rear wheel"].map(StringFilterEntry.init), multiselect: true)),
41 | StringFilter(type: .singleSelect, title: "Steering wheel position", payload: StringFilter.Payload(entries: ["Left side", "Right side"].map(StringFilterEntry.init), multiselect: false)),
42 | ManualInputFilter(title: "Price", payload: .init(fields: [.init(key: "from", initialValue: nil), .init(key: "to", initialValue: nil)]))
43 | ]
44 |
45 | s2 += filters.map { filter in
46 | let vm = TextSelectViewModel(filter: filter)
47 | return CollectionItem(item: vm).onSelect { [weak self] (_) in
48 | let vc: UIViewController
49 | switch filter.type {
50 | case .singleSelect, .multiSelect:
51 | let _vc = PopupController()
52 | vc = _vc
53 | _vc.content.selectedEntries = vm.currentValue()
54 | _vc.content.filter = filter as! StringFilter
55 | _vc.content.onSelect = { [unowned vm] entries in
56 | vm.updateSelection(entries)
57 | }
58 | case .numRange:
59 | let _vc = PopupController()
60 | vc = _vc
61 | _vc.content.filter = filter as! NumberFilter
62 | case .fromToInput:
63 | let _vc = PopupController()
64 | vc = _vc
65 | _vc.content.filter = filter as! ManualInputFilter
66 | default:
67 | vc = UITableViewController()
68 | }
69 |
70 | self?.present(vc, animated: true, completion: nil)
71 | }
72 | }
73 | s2.insetForSection = UIEdgeInsets(top: 20, left: 0, bottom: 20, right: 0)
74 | director += s2
75 |
76 | let s3 = CollectionSection()
77 | s3.lineSpacing = 1
78 | s3.insetForSection = UIEdgeInsets(top: 20, left: 0, bottom: 20, right: 0)
79 | director += s3
80 |
81 |
82 | let boolFilters = [
83 | BoolFilter(title: "With warranty", payload: .init(initialySelected: false)),
84 | BoolFilter(title: "With photo", payload: .init(initialySelected: true))
85 | ]
86 |
87 | s3 += boolFilters.map { filter in
88 | let vm = RadioButtonViewModel(filter: filter, initiallySelected: filter.payload.initialySelected, selectionStyle: .multi)
89 | return CollectionItem(item: vm)
90 | .onSelect { _ in
91 | vm.toggle()
92 | }
93 | }
94 |
95 | director.reload()
96 | }
97 | }
98 |
99 |
100 | final class TestPopup: UIViewController, PopupContentView {
101 | var frameInPopup: CGRect {
102 | return .init(x: 0, y: 150, width: view.bounds.width, height: view.bounds.height - 150)
103 | }
104 |
105 | var scrollView: UIScrollView? { return nil }
106 |
107 | override func viewDidLoad() {
108 | super.viewDidLoad()
109 | roundCorners()
110 | view.backgroundColor = .systemBlue
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/DeepDiff/Shared/Algorithms/WagnerFischer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WagnerFischer.swift
3 | // DeepDiff
4 | //
5 | // Created by Khoa Pham.
6 | // Copyright © 2018 Khoa Pham. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | // https://en.wikipedia.org/wiki/Wagner%E2%80%93Fischer_algorithm
12 |
13 | public final class WagnerFischer {
14 | private let reduceMove: Bool
15 |
16 | public init(reduceMove: Bool = false) {
17 | self.reduceMove = reduceMove
18 | }
19 |
20 | public func diff(old: [T], new: [T]) -> [Change] {
21 | let previousRow = Row()
22 | previousRow.seed(with: new)
23 | let currentRow = Row()
24 |
25 | if let changes = preprocess(old: old, new: new) {
26 | return changes
27 | }
28 |
29 | // row in matrix
30 | old.enumerated().forEach { indexInOld, oldItem in
31 | // reset current row
32 | currentRow.reset(
33 | count: previousRow.slots.count,
34 | indexInOld: indexInOld,
35 | oldItem: oldItem
36 | )
37 |
38 | // column in matrix
39 | new.enumerated().forEach { indexInNew, newItem in
40 | if isEqual(oldItem: old[indexInOld], newItem: new[indexInNew]) {
41 | currentRow.update(indexInNew: indexInNew, previousRow: previousRow)
42 | } else {
43 | currentRow.updateWithMin(
44 | previousRow: previousRow,
45 | indexInNew: indexInNew,
46 | newItem: newItem,
47 | indexInOld: indexInOld,
48 | oldItem: oldItem
49 | )
50 | }
51 | }
52 |
53 | // set previousRow
54 | previousRow.slots = currentRow.slots
55 | }
56 |
57 | let changes = currentRow.lastSlot()
58 | if reduceMove {
59 | return MoveReducer().reduce(changes: changes)
60 | } else {
61 | return changes
62 | }
63 | }
64 |
65 | // MARK: - Helper
66 |
67 | private func isEqual(oldItem: T, newItem: T) -> Bool {
68 | return T.compareContent(oldItem, newItem)
69 | }
70 | }
71 |
72 | // We can adapt the algorithm to use less space, O(m) instead of O(mn),
73 | // since it only requires that the previous row and current row be stored at any one time
74 | class Row {
75 | /// Each slot is a collection of Change
76 | var slots: [[Change]] = []
77 |
78 | /// Seed with .insert from new
79 | func seed(with new: Array) {
80 | // First slot should be empty
81 | slots = Array(repeatElement([], count: new.count + 1))
82 |
83 | // Each slot increases in the number of changes
84 | new.enumerated().forEach { index, item in
85 | let slotIndex = convert(indexInNew: index)
86 | slots[slotIndex] = combine(
87 | slot: slots[slotIndex-1],
88 | change: .insert(Insert(item: item, index: index))
89 | )
90 | }
91 | }
92 |
93 | /// Reset with empty slots
94 | /// First slot is .delete
95 | func reset(count: Int, indexInOld: Int, oldItem: T) {
96 | if slots.isEmpty {
97 | slots = Array(repeatElement([], count: count))
98 | }
99 |
100 | slots[0] = combine(
101 | slot: slots[0],
102 | change: .delete(Delete(item: oldItem, index: indexInOld))
103 | )
104 | }
105 |
106 | /// Use .replace from previousRow
107 | func update(indexInNew: Int, previousRow: Row) {
108 | let slotIndex = convert(indexInNew: indexInNew)
109 | slots[slotIndex] = previousRow.slots[slotIndex - 1]
110 | }
111 |
112 | /// Choose the min
113 | func updateWithMin(previousRow: Row, indexInNew: Int, newItem: T, indexInOld: Int, oldItem: T) {
114 | let slotIndex = convert(indexInNew: indexInNew)
115 | let topSlot = previousRow.slots[slotIndex]
116 | let leftSlot = slots[slotIndex - 1]
117 | let topLeftSlot = previousRow.slots[slotIndex - 1]
118 |
119 | let minCount = min(topSlot.count, leftSlot.count, topLeftSlot.count)
120 |
121 | // Order of cases does not matter
122 | switch minCount {
123 | case topSlot.count:
124 | slots[slotIndex] = combine(
125 | slot: topSlot,
126 | change: .delete(Delete(item: oldItem, index: indexInOld))
127 | )
128 | case leftSlot.count:
129 | slots[slotIndex] = combine(
130 | slot: leftSlot,
131 | change: .insert(Insert(item: newItem, index: indexInNew))
132 | )
133 | case topLeftSlot.count:
134 | slots[slotIndex] = combine(
135 | slot: topLeftSlot,
136 | change: .replace(Replace(oldItem: oldItem, newItem: newItem, index: indexInNew))
137 | )
138 | default:
139 | assertionFailure()
140 | }
141 | }
142 |
143 | /// Add one more change
144 | func combine(slot: [Change], change: Change) -> [Change] {
145 | var slot = slot
146 | slot.append(change)
147 | return slot
148 | }
149 |
150 | //// Last slot
151 | func lastSlot() -> [Change] {
152 | return slots[slots.count - 1]
153 | }
154 |
155 | /// Convert to slotIndex, as slots has 1 extra at the beginning
156 | func convert(indexInNew: Int) -> Int {
157 | return indexInNew + 1
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/CollectionItem.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CollectionItem.swift
3 | // CollectionKit
4 | //
5 | // Created by Igor Vedeneev on 13.09.17.
6 | // Copyright © 2017 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | /// Class represents cell model. Stores cell viewModel.
12 | /// Responsible for cell size calculation and all events handling(e.g `onSelect`, `onDisplay`)
13 | open class CollectionItem: AbstractCollectionItem where CellType: UICollectionViewCell {
14 | /// called when `collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)` called
15 | open var onSelect: ((_ indexPath: IndexPath) -> Void)?
16 | open var onDeselect: ((_ indexPath: IndexPath) -> Void)?
17 | open var onDisplay: ((_ indexPath: IndexPath, _ cell: UICollectionViewCell) -> Void)?
18 | open var onEndDisplay: ((_ indexPath: IndexPath, _ cell: UICollectionViewCell) -> Void)?
19 | open var onHighlight: ((_ indexPath: IndexPath) -> Void)?
20 | open var onUnighlight: ((_ indexPath: IndexPath) -> Void)?
21 | open var shouldSelect: Bool = true
22 | open var shouldDeselect: Bool = true
23 | open var shouldHighlight: Bool = true
24 | /// Width of cell = collectionView.width - horizontal section insets - horizontal collectionView insets.
25 | /// Width from `estimatedSize(boundingSize:)` will be ignored
26 | open var adjustsWidth: Bool = false
27 | /// Height of cell = collectionView.height - vertical section insets - vertical collectionView insets
28 | /// Height from `estimatedSize(boundingSize:)` will be ignored
29 | open var adjustsHeight: Bool = false
30 | /// ViewModel for
31 | open private(set) var item: CellType.T
32 | open var reuseIdentifier: String { return CellType.reuseIdentifier }
33 | /// identifier used for diff calculating
34 | public var identifier: String {
35 | let hashableId: AnyHashable = (item as? AnyHashable) ?? UUID().uuidString as AnyHashable
36 | let hashValue = hashableId.hashValue
37 | return reuseIdentifier + "_" + String(hashValue)
38 | }
39 |
40 | public func estimatedSize(boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
41 | return CellType.estimatedSize(item: item, boundingSize: boundingSize, in: section)
42 | }
43 |
44 | public var cellType: AnyClass {
45 | return CellType.self
46 | }
47 |
48 | public init(item: CellType.T) {
49 | self.item = item
50 | }
51 |
52 | public func configure(_ cell: UICollectionReusableView) {
53 | (cell as? CellType)?.configure(item: item)
54 | }
55 |
56 | @discardableResult
57 | public func onSelect(_ block:@escaping (_ indexPath: IndexPath) -> Void) -> Self {
58 | self.onSelect = block
59 | return self
60 | }
61 |
62 | @discardableResult
63 | public func onDeselect(_ block:@escaping (_ indexPath: IndexPath) -> Void) -> Self {
64 | self.onDeselect = block
65 | return self
66 | }
67 |
68 | @discardableResult
69 | public func onDisplay(_ block:@escaping (_ indexPath: IndexPath, _ cell: UICollectionViewCell) -> Void) -> Self {
70 | self.onDisplay = block
71 | return self
72 | }
73 |
74 | @discardableResult
75 | public func onEndDisplay(_ block:@escaping (_ indexPath: IndexPath, _ cell: UICollectionViewCell) -> Void) -> Self {
76 | self.onEndDisplay = block
77 | return self
78 | }
79 |
80 | @discardableResult
81 | public func onHighlight(_ block:@escaping (_ indexPath: IndexPath) -> Void) -> Self {
82 | self.onHighlight = block
83 | return self
84 | }
85 |
86 | @discardableResult
87 | public func onUnighlight(_ block:@escaping (_ indexPath: IndexPath) -> Void) -> Self {
88 | self.onUnighlight = block
89 | return self
90 | }
91 | /// Width of cell = collectionView.width - horizontal section insets - horizontal collectionView insets.
92 | /// Width from `estimatedSize(boundingSize:)` will be ignored
93 | @discardableResult
94 | public func adjustsWidth(_ adjusts: Bool) -> Self {
95 | self.adjustsWidth = adjusts
96 | return self
97 | }
98 | /// Height of cell = collectionView.height - vertical section insets - vertical collectionView insets
99 | /// Height from `estimatedSize(boundingSize:)` will be ignored
100 | @discardableResult
101 | public func adjustsHeight(_ adjusts: Bool) -> Self {
102 | self.adjustsHeight = adjusts
103 | return self
104 | }
105 |
106 | @discardableResult
107 | public func shouldHighlight(_ value: Bool) -> Self {
108 | self.shouldHighlight = value
109 | return self
110 | }
111 |
112 | @discardableResult
113 | public func shouldSelect(_ value: Bool) -> Self {
114 | self.shouldSelect = value
115 | return self
116 | }
117 |
118 | @discardableResult
119 | public func shouldDeselect(_ value: Bool) -> Self {
120 | self.shouldDeselect = value
121 | return self
122 | }
123 | }
124 |
125 | public protocol SelectableCellViewModel {
126 | var onSelect: ((IndexPath) -> ())? { get set }
127 | }
128 |
129 | public extension CollectionItem where CellType.T: SelectableCellViewModel {
130 | /// If cell viewModel conforms `SelectableCellViewModel`, u can replace `onSelect` implementation of `CollectionItem` with `onSelectFromViewModel()` in cell viewModel
131 | ///
132 | /// ViewModel implemendation and creation:
133 | /// ```
134 | /// class TestViewModel: SelectableCellViewModel {
135 | /// var onSelect: ((IndexPath) -> ())?
136 | /// }
137 | ///
138 | /// let viewModel = TestViewModel()
139 | /// viewModel.onSelect = { indexPath in
140 | /// print(indexPath)
141 | /// }
142 | /// ```
143 | ///
144 | /// Usage:
145 | /// `let testItem = CollectionItem(item: viewModel).onSelectFromViewModel()`
146 | /// - note: uses `unowned self` reference
147 | @discardableResult
148 | func onSelectFromViewModel() -> Self {
149 | onSelect { [unowned self] (indexPath) in
150 | self.item.onSelect?(indexPath)
151 | }
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CollectionKit
2 |
3 |
4 | Framework to manage complex `UICollectionView` in declarative way and very few lines of code.
5 | Heavily inspired by https://github.com/maxsokolov/TableKit and https://github.com/Instagram/IGListKit
6 |
7 |
8 | # Installation
9 | - Via CocoaPods: `pod 'IVCollectionKit'`
10 | - Via Carthage `github "ivedeneev/CollectionKit"`
11 | - Via Swift Package Manager: `.package(url: "https://github.com/ivedeneev/CollectionKit", branch: "master")`
12 |
13 | # Features
14 | - [x] Declarative `UICollectionView` management
15 | - [x] No need to implement `UICollectionViewDataSource` and `UICollectionViewDelegate`
16 | - [x] Easy way to map your models into cells
17 | - [x] Auto diffing
18 | - [x] Supports cells & reusable views from code and xibs and storyboard
19 | - [x] Flexible
20 | - [x] Register cells and reusable views automatically
21 | - [x] Fix scroll indicator clipping at iOS11 (http://www.openradar.me/34308893)
22 |
23 | # Getting Started
24 |
25 | Key concepts of `CollectionKit` are `Section`, `Item` and `Director`.
26 | `Item` is responsible for `UICollectionViewCell` configuration, size and actions
27 | `Section` is responsible for group of items and provides information for each item, header/footer and all kind of insets/margins: section insets, minimum inter item spacing and line spacing
28 | `Director` is responsible for providing all information, needed for `UICollectionView` and its updations
29 |
30 | ## Basic usage
31 |
32 | Setup UICollectionView and director:
33 |
34 | Setup collection view
35 | ```swift
36 | collectionView = UICollectionView(frame: view.bounds, colletionViewLayout: UICollectionViewFlowLayout())
37 | collectionDirector = CollectionDirector(colletionView: collectionView)
38 | ```
39 |
40 | Create items
41 | ```swift
42 | let item1 = CollectionItem(item: "hello!")
43 | let item2 = CollectionItem(item: "im")
44 | let item3 = CollectionItem(item: "ColletionKit")
45 | let item4 = CollectionItem(item: "greeting.png")
46 | ```
47 |
48 | Create section and put items in section
49 | ```swift
50 | let section = CollectionSection()
51 | let items = [item1, item2, item3, item4]
52 | section += items
53 | director += section
54 | ```
55 |
56 | Put section in director and reload director
57 | ```swift
58 | director += section
59 | director.reload()
60 | ```
61 |
62 | ## Cell configuration
63 | Cell must implement `ConfigurableCollectionCell` protocol. You need to specify cell size and configuration methods:
64 | ```swift
65 | extension CollectionCell : ConfigurableCollectionItem {
66 | static func estimatedSize(item: String, boundingSize: CGSize, in section: AbstractCollectionSection) -> CGSize {
67 | return CGSize(width: boundingSize.width - 40, height: 44)
68 | }
69 |
70 | func configure(item: String) {
71 | textLabel.text = item
72 | }
73 | }
74 |
75 | Note, that `contentInsets` value of collection view is respected in `boundingSize` parameter
76 | ```
77 | ### "Auto sizing cells"
78 |
79 | Framework doesnt support auto-sizing cells, but you can adjust cell width and height to collection view dimensions
80 |
81 | ```swift
82 | let item = CollectionItem(item: "text").adjustsWidth(true)
83 | ```
84 |
85 | It means that width of this cell will be equal to `collectionView.bounds.width` minus collectionView content insets and section insets. width from `estimatedSize` method is ignored for this case. `adjustsHeight(Bool)` method has same logic, but for vertical insets.
86 |
87 |
88 | ### Cell actions
89 | Implement such actions like `didSelectItem` or `shouldHighlightItem` using functional syntax
90 | ```swift
91 | let row = CollectionItem(item: "text")
92 | .onSelect({ (_) in
93 | print("i was tapped!")
94 | }).onDisplay({ (_) in
95 | print("i was displayed")
96 | })
97 | ```
98 | Available actions:
99 | - `onSelect`
100 | - `onDeselect`
101 | - `onDisplay`
102 | - `onEndDisplay`
103 | - `onHighlight`
104 | - `onUnighlight`
105 | - `shouldHighlight`
106 |
107 | ## Section configuration
108 | You can setup inter item spacing, line spacing and section insets using section object:
109 | ```swift
110 | let section = CollectionSection()
111 | section.minimumInterItemSpacing = 2
112 | section.insetForSection = UIEdgeInsetsMake(0, 20, 0, 20)
113 | section.lineSpacing = 2
114 | ```
115 | Also you can set section header and footer:
116 | ```swift
117 | section.headerItem = CollectionHeaderFooterView(item: "This is header")
118 | section.footerItem = CollectionHeaderFooterView(item: "This is footer")
119 | ```
120 |
121 | ## Updating & reloading
122 |
123 | `IVCollectionKit` provides 2 ways for updatung `UICollectionView` content:
124 | - reload (using `reloadData`)
125 | - animated updates(using `performBatchUpdates`)
126 |
127 | Note, that all models, that you use in `CollectionItem` initializations should conform `Hashable` protocol. Framework provides fallback for non-hashable models, but it may cause unexpected behaviour during animated updates.
128 | ```swift
129 | director.performUpdates()
130 | director.performUpdates { finished: Bool in
131 | print("updates completed")
132 | }
133 |
134 | If you need to animate cell size you can use `director.setNeedsUpdates()` method. This method doesnt trigger cell calculatuion under the hood
135 | ```
136 | **IMPORTANT!** if you use animated updates via `performUpdates` or `setNeedsUpdate` dont use update methods of UICollectionView directly. It may lead to unpredictable behaviour
137 |
138 | ## Pagination
139 | To support Pagination you should implement `CollectionDirectorDelegate` protocol. See [examples](https://github.com/ivedeneev/CollectionKit/blob/master/Examples/Pagination/PaginationViewController.swift)
140 | ```swift
141 | director.delegate = self
142 |
143 | extension PaginationViewController: CollectionDirectorDelegate {
144 | func didScrollToBottom(director: CollectionDirector) {
145 |
146 | }
147 | }
148 | ```
149 |
150 | ## IVCollectionView
151 | `IVCollectionView` is experemental `UICollectionView` subclass designed to manage incorrect updates. You can use it instead of ordinary CollectionView. Typical use case is many simultanious updates.
152 |
153 | ## Custom sections
154 | You can provide your own section implementation using `AbstractCollectionSection` protocol. For example, you can use it for using `CollectionDirector` with `Realm.Results` and save `Results` lazy behaviour or implementing expandable sections (see exapmles). Also you can create subclass of `CollectionSection` if you dont need radically different behaviour
155 |
156 | ## UIScrollViewDelegate
157 | If you need to implement `UISrollViewDelegate` methods for your collection view (e.g pagination support) you can use `scrollDelegate` property
158 |
159 | ```swift
160 | final class ViewController: UIViewController, UIScrollViewDelegate {
161 | var director: CollectionDirector!
162 | ...
163 |
164 | private func setupDirector() {
165 | director.scrollDelegate = self
166 | }
167 | }
168 | ```
169 |
--------------------------------------------------------------------------------
/Examples/SocialNetworkProfile/ProfileViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileViewController.swift
3 | // Examples
4 | //
5 | // Created by Igor Vedeneev on 2/18/20.
6 | // Copyright © 2020 Igor Vedeneev. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import IVCollectionKit
11 |
12 | final class ProfileViewController: CollectionViewController, PopupContentView {
13 | var frameInPopup: CGRect { return CGRect(x: 0, y: 80, width: view.bounds.width, height: view.bounds.height - 50) }
14 |
15 | var scrollView: UIScrollView? { return collectionView }
16 |
17 |
18 | var user: User?
19 | var friends = [User]()
20 | var posts = [String]()
21 |
22 | override func viewDidLoad() {
23 | super.viewDidLoad()
24 |
25 | loadData(val: 0)
26 |
27 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
28 | self.loadData(val: 1)
29 | }
30 |
31 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
32 | self.loadData(val: 2)
33 | }
34 |
35 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.77) {
36 | self.loadData(val: 3)
37 | }
38 | }
39 |
40 | func loadData(val: Int) {
41 | switch val {
42 | case 0:
43 | user = User(id: "iv", firstName: "Igor", lastName: "Vedeneev", imageUrl: URL(string: "www.google.ru")!, city: "Moscow", info: [], description: nil)
44 | case 1:
45 | user = User(id: "iv", firstName: "Igor", lastName: "Vedeneev", imageUrl: URL(string: "www.google.ru")!, city: "Moscow", info: [
46 | User.Info(id: "phone", icon: "phone", value: "7 (999) 999 99 99"),
47 | User.Info(id: "address", icon: "address", value: "hidden"),
48 | User.Info(id: "uni", icon: "uni", value: "HSE")], description: "Leaders of hard-hit states are considering new limits on businesses. Germany is dealing with a surge. President Trump shared a video with misleading coronavirus claims.\n\nRIGHT NOW: New York will now require travelers from Puerto Rico, Washington D.C. and 34 states to quarantine for 14 days, Gov. Andrew M. Cuomo said. The new states added to the list are Illinois, Kentucky and Minnesota.")
49 | case 2:
50 | friends = [User(id: "sp", firstName: "Sergey", lastName: "Petrov", imageUrl: URL(string: "www.google.ru")!, city: "Morshansk", info: [], description: nil),
51 | User(id: "pk", firstName: "Petr", lastName: "Klimov", imageUrl: URL(string:"www.google.ru")!, city: "Kubinka", info: [], description: nil),
52 | User(id: "es", firstName: "Elena", lastName: "Smirnova", imageUrl: URL(string:"www.google.ru")!, city: "Kubinka", info: [], description: nil),
53 | User(id: "vk", firstName: "Valeria", lastName: "Klimova", imageUrl: URL(string:"www.google.ru")!, city: "Kubinka", info: [], description: nil),
54 | User(id: "vy", firstName: "Viktor", lastName: "Yudaev", imageUrl: URL(string:"www.google.ru")!, city: "Tambov", info: [], description: nil),
55 | User(id: "avdm", firstName: "Andy", lastName: "Van der Meyde", imageUrl: URL(string: "www.google.ru")!, city: "Amsterdam", info: [], description: nil),
56 | ]
57 |
58 | case 3:
59 | user?.info.append(.init(id: "333", icon: "phone", value: "+7 (4752) 52-57-66"))
60 | friends.insert(User(id: "kr", firstName: "Kristina", lastName: "Test", imageUrl: URL(string: "www.amster.ru")!, city: "Moskva", info: [], description: nil), at: 0)
61 |
62 | posts = (0..<20).map {
63 | let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
64 | return "\($0)\n" + String((0..<300).map { _ in letters.randomElement()! })
65 | }
66 | default:
67 | break
68 | }
69 |
70 | configure()
71 | }
72 |
73 | func configure() {
74 | defer {
75 | director.performUpdates()
76 | }
77 |
78 | let userSection = CollectionSection(id: "user")
79 | let descriptionSecion = CollectionSection(id: "desc")
80 | let friendSection = CollectionSection(id: "friend")
81 | let postsSection = CollectionSection(id: "posts")
82 |
83 | var sections = Array()
84 | guard let user = user else { return }
85 | title = user.firstName
86 |
87 | userSection += CollectionItem(item: user).onSelect { [unowned self] (_) in
88 | self.collectionView.scrollToItem(at: IndexPath(item: 10, section: 3), at: .centeredVertically, animated: true)
89 | }
90 | userSection.insetForSection = UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 0)
91 | let infoRows = user.info.map(CollectionItem.init)
92 | userSection += infoRows
93 | sections.append(userSection)
94 |
95 | if let desc = user.description {
96 | descriptionSecion.headerItem = CollectionHeaderFooterView(item: "About me")
97 |
98 | let descViewModel = MultilineTextViewModel(text: desc)
99 | descriptionSecion += CollectionItem(item: descViewModel)
100 | .onSelect { [descViewModel, director] _ in
101 | descViewModel.isExpanded = !descViewModel.isExpanded
102 | director.performUpdates()
103 | }
104 |
105 | descriptionSecion.insetForSection = UIEdgeInsets(top: 0, left: 8, bottom: 16, right: 8)
106 |
107 | sections.append(descriptionSecion)
108 | }
109 |
110 | if !friends.isEmpty {
111 | friendSection.insetForSection = UIEdgeInsets(top: 32, left: 16, bottom: 20, right: 16)
112 | friendSection.minimumInterItemSpacing = 8
113 | friendSection.lineSpacing = 8
114 |
115 | friendSection += friends.map(CollectionItem.init)
116 |
117 | let buttonVm = ButtonViewModel(icon: nil, title: "See All", handler: { print("yezzzzzzzzz") })
118 | friendSection.footerItem = CollectionHeaderFooterView(item: buttonVm)
119 | sections.append(friendSection)
120 | }
121 |
122 | if !posts.isEmpty {
123 | postsSection.headerItem = CollectionHeaderFooterView(item: "Posts")
124 |
125 | postsSection += posts.map { post in
126 | let vm = MultilineTextViewModel(text: post)
127 | return CollectionItem(item: vm)
128 | .onSelect { [vm, director] _ in
129 | vm.isExpanded = !vm.isExpanded
130 | director.performUpdates()
131 | }
132 | }
133 |
134 | postsSection.insetForSection = UIEdgeInsets(top: 0, left: 8, bottom: 16, right: 8)
135 | postsSection.lineSpacing = 8
136 |
137 | sections.append(postsSection)
138 | }
139 |
140 | director.sections = sections
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | #########################
2 | # .gitignore file for Xcode4 and Xcode5 Source projects
3 | #
4 | # Apple bugs, waiting for Apple to fix/respond:
5 | #
6 | # 15564624 - what does the xccheckout file in Xcode5 do? Where's the documentation?
7 | #
8 | # Version 2.6
9 | # For latest version, see: http://stackoverflow.com/questions/49478/git-ignore-file-for-xcode-projects
10 | #
11 | # 2015 updates:
12 | # - Fixed typo in "xccheckout" line - thanks to @lyck for pointing it out!
13 | # - Fixed the .idea optional ignore. Thanks to @hashier for pointing this out
14 | # - Finally added "xccheckout" to the ignore. Apple still refuses to answer support requests about this, but in practice it seems you should ignore it.
15 | # - minor tweaks from Jona and Coeur (slightly more precise xc* filtering/names)
16 | # 2014 updates:
17 | # - appended non-standard items DISABLED by default (uncomment if you use those tools)
18 | # - removed the edit that an SO.com moderator made without bothering to ask me
19 | # - researched CocoaPods .lock more carefully, thanks to Gokhan Celiker
20 | # 2013 updates:
21 | # - fixed the broken "save personal Schemes"
22 | # - added line-by-line explanations for EVERYTHING (some were missing)
23 | #
24 | # NB: if you are storing "built" products, this WILL NOT WORK,
25 | # and you should use a different .gitignore (or none at all)
26 | # This file is for SOURCE projects, where there are many extra
27 | # files that we want to exclude
28 | #
29 | #########################
30 |
31 | #####
32 | # OS X temporary files that should never be committed
33 | #
34 | # c.f. http://www.westwind.com/reference/os-x/invisibles.html
35 |
36 | .DS_Store
37 |
38 | # c.f. http://www.westwind.com/reference/os-x/invisibles.html
39 |
40 | .Trashes
41 |
42 | # c.f. http://www.westwind.com/reference/os-x/invisibles.html
43 |
44 | *.swp
45 |
46 | #
47 | # *.lock - this is used and abused by many editors for many different things.
48 | # For the main ones I use (e.g. Eclipse), it should be excluded
49 | # from source-control, but YMMV.
50 | # (lock files are usually local-only file-synchronization on the local FS that should NOT go in git)
51 | # c.f. the "OPTIONAL" section at bottom though, for tool-specific variations!
52 | #
53 | # In particular, if you're using CocoaPods, you'll want to comment-out this line:
54 | #*.lock
55 |
56 |
57 | #
58 | # profile - REMOVED temporarily (on double-checking, I can't find it in OS X docs?)
59 | #profile
60 |
61 |
62 | ####
63 | # Xcode temporary files that should never be committed
64 | #
65 | # NB: NIB/XIB files still exist even on Storyboard projects, so we want this...
66 |
67 | *~.nib
68 |
69 |
70 | ####
71 | # Xcode build files -
72 | #
73 | # NB: slash on the end, so we only remove the FOLDER, not any files that were badly named "DerivedData"
74 |
75 | DerivedData/
76 |
77 | # NB: slash on the end, so we only remove the FOLDER, not any files that were badly named "build"
78 |
79 | build/
80 |
81 | # Bundler
82 | vendor/
83 | .bundle/bin
84 |
85 | #####
86 | # Xcode private settings (window sizes, bookmarks, breakpoints, custom executables, smart groups)
87 | #
88 | # This is complicated:
89 | #
90 | # SOMETIMES you need to put this file in version control.
91 | # Apple designed it poorly - if you use "custom executables", they are
92 | # saved in this file.
93 | # 99% of projects do NOT use those, so they do NOT want to version control this file.
94 | # ..but if you're in the 1%, comment out the line "*.pbxuser"
95 |
96 | # .pbxuser: http://lists.apple.com/archives/xcode-users/2004/Jan/msg00193.html
97 |
98 | *.pbxuser
99 |
100 | # .mode1v3: http://lists.apple.com/archives/xcode-users/2007/Oct/msg00465.html
101 |
102 | *.mode1v3
103 |
104 | # .mode2v3: http://lists.apple.com/archives/xcode-users/2007/Oct/msg00465.html
105 |
106 | *.mode2v3
107 |
108 | # .perspectivev3: http://stackoverflow.com/questions/5223297/xcode-projects-what-is-a-perspectivev3-file
109 |
110 | *.perspectivev3
111 |
112 | # NB: also, whitelist the default ones, some projects need to use these
113 | !default.pbxuser
114 | !default.mode1v3
115 | !default.mode2v3
116 | !default.perspectivev3
117 |
118 |
119 | ####
120 | # Xcode 4 - semi-personal settings
121 | #
122 | # Apple Shared data that Apple put in the wrong folder
123 | # c.f. http://stackoverflow.com/a/19260712/153422
124 | # FROM ANSWER: Apple says "don't ignore it"
125 | # FROM COMMENTS: Apple is wrong; Apple code is too buggy to trust; there are no known negative side-effects to ignoring Apple's unofficial advice and instead doing the thing that actively fixes bugs in Xcode
126 | # Up to you, but ... current advice: ignore it.
127 | *.xccheckout
128 |
129 | #
130 | #
131 | # OPTION 1: ---------------------------------
132 | # throw away ALL personal settings (including custom schemes!
133 | # - unless they are "shared")
134 | # As per build/ and DerivedData/, this ought to have a trailing slash
135 | #
136 | # NB: this is exclusive with OPTION 2 below
137 | xcuserdata/
138 |
139 | # OPTION 2: ---------------------------------
140 | # get rid of ALL personal settings, but KEEP SOME OF THEM
141 | # - NB: you must manually uncomment the bits you want to keep
142 | #
143 | # NB: this *requires* git v1.8.2 or above; you may need to upgrade to latest OS X,
144 | # or manually install git over the top of the OS X version
145 | # NB: this is exclusive with OPTION 1 above
146 | #
147 | #xcuserdata/**/*
148 |
149 | # (requires option 2 above): Personal Schemes
150 | #
151 | #!xcuserdata/**/xcschemes/*
152 |
153 | ####
154 | # XCode 4 workspaces - more detailed
155 | #
156 | # Workspaces are important! They are a core feature of Xcode - don't exclude them :)
157 | #
158 | # Workspace layout is quite spammy. For reference:
159 | #
160 | # /(root)/
161 | # /(project-name).xcodeproj/
162 | # project.pbxproj
163 | # /project.xcworkspace/
164 | # contents.xcworkspacedata
165 | # /xcuserdata/
166 | # /(your name)/xcuserdatad/
167 | # UserInterfaceState.xcuserstate
168 | # /xcshareddata/
169 | # /xcschemes/
170 | # (shared scheme name).xcscheme
171 | # /xcuserdata/
172 | # /(your name)/xcuserdatad/
173 | # (private scheme).xcscheme
174 | # xcschememanagement.plist
175 | #
176 | #
177 |
178 | ####
179 | # Xcode 4 - Deprecated classes
180 | #
181 | # Allegedly, if you manually "deprecate" your classes, they get moved here.
182 | #
183 | # We're using source-control, so this is a "feature" that we do not want!
184 |
185 | *.moved-aside
186 |
187 | ####
188 | # OPTIONAL: Some well-known tools that people use side-by-side with Xcode / iOS development
189 | #
190 | # NB: I'd rather not include these here, but gitignore's design is weak and doesn't allow
191 | # modular gitignore: you have to put EVERYTHING in one file.
192 | #
193 | # COCOAPODS:
194 | #
195 | # c.f. http://guides.cocoapods.org/using/using-cocoapods.html#what-is-a-podfilelock
196 | # c.f. http://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control
197 | #
198 | !Podfile.lock
199 | Pods/
200 |
201 | # Carthage - A simple, decentralized dependency manager for Cocoa
202 | Carthage/
203 |
204 | # Fastlane
205 | fastlane/README.md
206 | fastlane/report.xml
207 |
208 | #
209 | # RUBY:
210 | #
211 | # c.f. http://yehudakatz.com/2010/12/16/clarifying-the-roles-of-the-gemspec-and-gemfile/
212 | #
213 | #!Gemfile.lock
214 | #
215 | # IDEA:
216 | #
217 | # c.f. https://www.jetbrains.com/objc/help/managing-projects-under-version-control.html?search=workspace.xml
218 | #
219 | #.idea/workspace.xml
220 | #
221 | # TEXTMATE:
222 | #
223 | # -- UNVERIFIED: c.f. http://stackoverflow.com/a/50283/153422
224 | #
225 | #tm_build_errors
226 |
227 | ####
228 | # UNKNOWN: recommended by others, but I can't discover what these files are
229 | .idea/*
230 |
231 | # Archive
232 | *.zip
233 | *.ipa
234 |
--------------------------------------------------------------------------------
/Examples/Filter/Core/PopupController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ActionSheet.swift
3 | // Megadisk
4 | //
5 | // Created by Igor Vedeneev on 8/25/19.
6 | // Copyright © 2019 AGIMA. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | /// Контейнер для поповеров (например, пикер фото или действия с файлами)
12 | final class PopupController: UIViewController, UIViewControllerTransitioningDelegate, UIGestureRecognizerDelegate, UIScrollViewDelegate {
13 |
14 |
15 | var interactor = PopupAnimationInteractor()
16 |
17 | override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
18 | super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
19 | modalTransitionStyle = .crossDissolve
20 | modalPresentationStyle = .custom
21 | transitioningDelegate = self
22 | }
23 |
24 | required init?(coder aDecoder: NSCoder) {
25 | fatalError("init(coder:) has not been implemented")
26 | }
27 |
28 | let content = T()
29 | let pinView = UIView()
30 |
31 | var dimColor: UIColor = UIColor.black.withAlphaComponent(0.7)
32 |
33 | override func viewDidLoad() {
34 | super.viewDidLoad()
35 | view.backgroundColor = .clear
36 |
37 | let dismissTouchReceivingView = UIView(frame: view.bounds)
38 | view.addSubview(dismissTouchReceivingView)
39 | let tapGR = UITapGestureRecognizer(target: self, action: #selector(_dismiss))
40 | dismissTouchReceivingView.addGestureRecognizer(tapGR)
41 | tapGR.delegate = self
42 |
43 | view.addSubview(content.view)
44 | addChild(content)
45 | content.didMove(toParent: self)
46 | content.view.frame = content.frameInPopup
47 |
48 | let pan1 = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
49 | pan1.delegate = self
50 | content.view.addGestureRecognizer(pan1)
51 | content.view.isUserInteractionEnabled = true
52 |
53 | let pan2 = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
54 | pan2.delegate = self
55 | content.scrollView?.addGestureRecognizer(pan2)
56 | }
57 |
58 | func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
59 | if gestureRecognizer is UITapGestureRecognizer { return true }
60 |
61 | guard let pan = gestureRecognizer as? UIPanGestureRecognizer, let panView = pan.view else { return false }
62 |
63 | let velocity = pan.velocity(in: panView)
64 | let isVertical = abs(velocity.y) > abs(velocity.x)
65 | let isEnabled: Bool
66 | if let scrollView = panView as? UIScrollView {
67 | isEnabled = scrollView.contentOffset.y <= 0 && velocity.y > 0
68 | } else {
69 | isEnabled = true
70 | }
71 | return isVertical && isEnabled
72 | }
73 |
74 | @objc func handlePan(_ sender: UIPanGestureRecognizer) {
75 | let percentThreshold: CGFloat = 0.2
76 | let translation = sender.translation(in: contentView)
77 | let verticalMovement = translation.y / contentFrame.height
78 | let downwardMovement = fmaxf(Float(verticalMovement), 0.0)
79 | let downwardMovementPercent = fminf(downwardMovement, 1.0)
80 | var progress = CGFloat(downwardMovementPercent)
81 | progress = progress * (1-progress * 0.5) * 0.85
82 | switch sender.state {
83 | case .began:
84 | interactor.hasStarted = true
85 | dismiss(animated: true, completion: nil)
86 | case .changed:
87 | interactor.shouldFinish = progress > percentThreshold
88 | interactor.update(progress)
89 | case .cancelled:
90 | interactor.hasStarted = false
91 | interactor.cancel()
92 | case .ended:
93 | let velocity = sender.velocity(in: content.view)
94 |
95 | if velocity.y > 100 {
96 | interactor.shouldFinish = true
97 | }
98 |
99 | if velocity.y < 0 {
100 | interactor.shouldFinish = false
101 | }
102 |
103 | interactor.hasStarted = false
104 | interactor.shouldFinish ? interactor.finish() : interactor.cancel()
105 | default:
106 | break
107 | }
108 | }
109 |
110 | @objc func _dismiss() {
111 | dismiss(animated: true, completion: nil)
112 | }
113 |
114 | //MARK: UIViewControllerTransitioningDelegate
115 |
116 | func animationController(forPresented presented: UIViewController,
117 | presenting: UIViewController,
118 | source: UIViewController) -> UIViewControllerAnimatedTransitioning?
119 | {
120 | return FadeAnimator(type: .present, contentView: contentView, dimColor: dimColor)
121 | }
122 |
123 | func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning?
124 | {
125 | return FadeAnimator(type: .dismiss, contentView: contentView, dimColor: dimColor)
126 | }
127 |
128 | func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
129 | return interactor.hasStarted ? interactor : nil
130 | }
131 | }
132 |
133 | class FadeAnimator: NSObject, UIViewControllerAnimatedTransitioning {
134 | enum TransitionType {
135 | case present
136 | case dismiss
137 | }
138 |
139 | let type: TransitionType
140 | let duration: TimeInterval
141 | var contentView: UIView
142 |
143 | private let dimColor: UIColor
144 |
145 | init(type: TransitionType, duration: TimeInterval = 0.25, contentView: UIView, dimColor: UIColor) {
146 | self.type = type
147 | self.duration = duration
148 | self.contentView = contentView
149 | self.dimColor = dimColor
150 |
151 | super.init()
152 | }
153 |
154 | func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
155 | return duration
156 | }
157 |
158 | func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
159 | guard let fromVC = transitionContext.viewController(forKey: .from),
160 | let toVC = transitionContext.viewController(forKey: .to) else { return }
161 | let transVC: PopupControllerProtocol
162 | if type == .present {
163 | transVC = toVC as! PopupControllerProtocol
164 | transitionContext.containerView.insertSubview(toVC.view, aboveSubview: fromVC.view)
165 | transVC.contentView.transform = CGAffineTransform(translationX: 0, y: transVC.contentFrame.height)
166 | } else {
167 | transVC = fromVC as! PopupControllerProtocol
168 | }
169 |
170 | let duration: TimeInterval = transitionDuration(using: transitionContext)
171 | UIView.animate(withDuration: duration, delay: 0, options: [.curveLinear], animations: {
172 | if self.type == .present {
173 | toVC.view.backgroundColor = self.dimColor
174 | transVC.contentView.transform = .identity
175 | } else {
176 | fromVC.view.backgroundColor = .clear
177 | transVC.contentView.transform = CGAffineTransform(translationX: 0, y: transVC.contentFrame.height)
178 | }
179 | }) { _ in
180 | transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
181 | }
182 | }
183 | }
184 |
185 | class PortraitOrientationViewController: UIViewController {
186 | override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
187 | return .portrait
188 | }
189 | }
190 |
191 | final class PopupAnimationInteractor: UIPercentDrivenInteractiveTransition {
192 | var hasStarted = false
193 | var shouldFinish = false
194 | }
195 |
196 | protocol PopupContentView: UIViewController {
197 | var frameInPopup: CGRect { get }
198 | var scrollView: UIScrollView? { get }
199 | func setupKeyboardObserving()
200 | func roundCorners()
201 | }
202 |
203 | protocol PopupControllerProtocol: UIViewController, UIViewControllerTransitioningDelegate {
204 | var contentView: UIView { get }
205 | var contentFrame: CGRect { get }
206 | }
207 |
208 | extension PopupController: PopupControllerProtocol {
209 | var contentView: UIView {
210 | return content.view
211 | }
212 |
213 | var contentFrame: CGRect {
214 | return content.frameInPopup
215 | }
216 | }
217 |
218 | extension PopupContentView {
219 | func setupKeyboardObserving() {
220 | let o1 = NotificationCenter.default
221 | .addObserver(forName: UIResponder.keyboardWillChangeFrameNotification, object: nil, queue: .main) { [weak self] (note) in
222 | guard
223 | let self = self,
224 | let duration: TimeInterval = note.userInfo?["UIKeyboardAnimationDurationUserInfoKey"] as? TimeInterval,
225 | let curve: UInt = note.userInfo?["UIKeyboardAnimationCurveUserInfoKey"] as? UInt,
226 | let endFrame: CGRect = note.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect
227 | else { return }
228 |
229 | UIView.animate(
230 | withDuration: duration,
231 | delay: 0,
232 | options: [UIView.AnimationOptions(rawValue: curve)],
233 | animations: {
234 | self.view.frame.origin.y = endFrame.minY - self.view.bounds.height
235 | }, completion: nil)
236 | }
237 | }
238 |
239 | func roundCorners() {
240 | view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
241 | view.backgroundColor = .white
242 | view.layer.cornerRadius = 16
243 | view.clipsToBounds = true
244 | }
245 | }
246 |
--------------------------------------------------------------------------------
/IVCollectionKit/Source/DeepDiff/Shared/Algorithms/Heckel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Heckel.swift
3 | // DeepDiff
4 | //
5 | // Created by Khoa Pham.
6 | // Copyright © 2018 Khoa Pham. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | // https://gist.github.com/ndarville/3166060
12 |
13 | public final class Heckel {
14 | // OC and NC can assume three values: 1, 2, and many.
15 | enum Counter {
16 | case zero, one, many
17 |
18 | func increment() -> Counter {
19 | switch self {
20 | case .zero:
21 | return .one
22 | case .one:
23 | return .many
24 | case .many:
25 | return self
26 | }
27 | }
28 | }
29 |
30 | // The symbol table stores three entries for each line
31 | class TableEntry: Equatable {
32 | // The value entry for each line in table has two counters.
33 | // They specify the line's number of occurrences in O and N: OC and NC.
34 | var oldCounter: Counter = .zero
35 | var newCounter: Counter = .zero
36 |
37 | // Aside from the two counters, the line's entry
38 | // also includes a reference to the line's line number in O: OLNO.
39 | // OLNO is only interesting, if OC == 1.
40 | // Alternatively, OLNO would have to assume multiple values or none at all.
41 | var indexesInOld: [Int] = []
42 |
43 | static func ==(lhs: TableEntry, rhs: TableEntry) -> Bool {
44 | return lhs.oldCounter == rhs.oldCounter && lhs.newCounter == rhs.newCounter && lhs.indexesInOld == rhs.indexesInOld
45 | }
46 | }
47 |
48 | // The arrays OA and NA have one entry for each line in their respective files, O and N.
49 | // The arrays contain either:
50 | enum ArrayEntry: Equatable {
51 | // a pointer to the line's symbol table entry, table[line]
52 | case tableEntry(TableEntry)
53 |
54 | // the line's number in the other file (N for OA, O for NA)
55 | case indexInOther(Int)
56 |
57 | public static func == (lhs: ArrayEntry, rhs: ArrayEntry) -> Bool {
58 | switch (lhs, rhs) {
59 | case (.tableEntry(let l), .tableEntry(let r)):
60 | return l == r
61 | case (.indexInOther(let l), .indexInOther(let r)):
62 | return l == r
63 | default:
64 | return false
65 | }
66 | }
67 | }
68 |
69 | public func diff(old: [T], new: [T]) -> [Change] {
70 | // The Symbol Table
71 | // Each line works as the key in the table look-up, i.e. as table[line].
72 | var table: [T.DiffId: TableEntry] = [:]
73 |
74 | // The arrays OA and NA have one entry for each line in their respective files, O and N
75 | var oldArray = [ArrayEntry]()
76 | var newArray = [ArrayEntry]()
77 |
78 | perform1stPass(new: new, table: &table, newArray: &newArray)
79 | perform2ndPass(old: old, table: &table, oldArray: &oldArray)
80 | perform345Pass(newArray: &newArray, oldArray: &oldArray)
81 | let changes = perform6thPass(new: new, old: old, newArray: newArray, oldArray: oldArray)
82 | return changes
83 | }
84 |
85 | private func perform1stPass(
86 | new: [T],
87 | table: inout [T.DiffId: TableEntry],
88 | newArray: inout [ArrayEntry]) {
89 |
90 | // 1st pass
91 | // a. Each line i of file N is read in sequence
92 | new.forEach { item in
93 | // b. An entry for each line i is created in the table, if it doesn't already exist
94 | let entry = table[item.diffId] ?? TableEntry()
95 |
96 | // c. NC for the line's table entry is incremented
97 | entry.newCounter = entry.newCounter.increment()
98 |
99 | // d. NA[i] is set to point to the table entry of line i
100 | newArray.append(.tableEntry(entry))
101 |
102 | //
103 | table[item.diffId] = entry
104 | }
105 | }
106 |
107 | private func perform2ndPass(
108 | old: [T],
109 | table: inout [T.DiffId: TableEntry],
110 | oldArray: inout [ArrayEntry]) {
111 |
112 | // 2nd pass
113 | // Similar to first pass, except it acts on files
114 |
115 | old.enumerated().forEach { tuple in
116 | // old
117 | let entry = table[tuple.element.diffId] ?? TableEntry()
118 |
119 | // oldCounter
120 | entry.oldCounter = entry.oldCounter.increment()
121 |
122 | // lineNumberInOld which is set to the line's number
123 | entry.indexesInOld.append(tuple.offset)
124 |
125 | // oldArray
126 | oldArray.append(.tableEntry(entry))
127 |
128 | //
129 | table[tuple.element.diffId] = entry
130 | }
131 | }
132 |
133 | private func perform345Pass(newArray: inout [ArrayEntry], oldArray: inout [ArrayEntry]) {
134 | // 3rd pass
135 | // a. We use Observation 1:
136 | // If a line occurs only once in each file, then it must be the same line,
137 | // although it may have been moved.
138 | // We use this observation to locate unaltered lines that we
139 | // subsequently exclude from further treatment.
140 | // b. Using this, we only process the lines where OC == NC == 1
141 | // c. As the lines between O and N "must be the same line,
142 | // although it may have been moved", we alter the table pointers
143 | // in OA and NA to the number of the line in the other file.
144 | // d. We also locate unique virtual lines
145 | // immediately before the first and
146 | // immediately after the last lines of the files ???
147 | //
148 | // 4th pass
149 | // a. We use Observation 2:
150 | // If a line has been found to be unaltered,
151 | // and the lines immediately adjacent to it in both files are identical,
152 | // then these lines must be the same line.
153 | // This information can be used to find blocks of unchanged lines.
154 | // b. Using this, we process each entry in ascending order.
155 | // c. If
156 | // NA[i] points to OA[j], and
157 | // NA[i+1] and OA[j+1] contain identical table entry pointers
158 | // then
159 | // OA[j+1] is set to line i+1, and
160 | // NA[i+1] is set to line j+1
161 | //
162 | // 5th pass
163 | // Similar to fourth pass, except:
164 | // It processes each entry in descending order
165 | // It uses j-1 and i-1 instead of j+1 and i+1
166 |
167 | newArray.enumerated().forEach { (indexOfNew, item) in
168 | switch item {
169 | case .tableEntry(let entry):
170 | guard !entry.indexesInOld.isEmpty else {
171 | return
172 | }
173 | let indexOfOld = entry.indexesInOld.removeFirst()
174 | let isObservation1 = entry.newCounter == .one && entry.oldCounter == .one
175 | let isObservation2 = entry.newCounter != .zero && entry.oldCounter != .zero && newArray[indexOfNew] == oldArray[indexOfOld]
176 | guard isObservation1 || isObservation2 else {
177 | return
178 | }
179 | newArray[indexOfNew] = .indexInOther(indexOfOld)
180 | oldArray[indexOfOld] = .indexInOther(indexOfNew)
181 | case .indexInOther(_):
182 | break
183 | }
184 | }
185 | }
186 |
187 | private func perform6thPass(
188 | new: [T],
189 | old: [T],
190 | newArray: [ArrayEntry],
191 | oldArray: [ArrayEntry]) -> [Change] {
192 |
193 | // 6th pass
194 | // At this point following our five passes,
195 | // we have the necessary information contained in NA to tell the differences between O and N.
196 | // This pass uses NA and OA to tell when a line has changed between O and N,
197 | // and how far the change extends.
198 |
199 | // a. Determining a New Line
200 | // Recall our initial description of NA in which we said that the array has either:
201 | // one entry for each line of file N containing either
202 | // a pointer to table[line]
203 | // the line's number in file O
204 |
205 | // Using these two cases, we know that if NA[i] refers
206 | // to an entry in table (case 1), then line i must be new
207 | // We know this, because otherwise, NA[i] would have contained
208 | // the line's number in O (case 2), if it existed in O and N
209 |
210 | // b. Determining the Boundaries of the New Line
211 | // We now know that we are dealing with a new line, but we have yet to figure where the change ends.
212 | // Recall Observation 2:
213 |
214 | // If NA[i] points to OA[j], but NA[i+1] does not
215 | // point to OA[j+1], then line i is the boundary for the alteration.
216 |
217 | // You can look at it this way:
218 | // i : The quick brown fox | j : The quick brown fox
219 | // i+1: jumps over the lazy dog | j+1: jumps over the loafing cat
220 |
221 | // Here, NA[i] == OA[j], but NA[i+1] != OA[j+1].
222 | // This means our boundary is between the two lines.
223 |
224 | var changes = [Change]()
225 | var deleteOffsets = Array(repeating: 0, count: old.count)
226 |
227 | // deletions
228 | do {
229 | var runningOffset = 0
230 |
231 | oldArray.enumerated().forEach { oldTuple in
232 | deleteOffsets[oldTuple.offset] = runningOffset
233 |
234 | guard case .tableEntry = oldTuple.element else {
235 | return
236 | }
237 |
238 | changes.append(.delete(Delete(
239 | item: old[oldTuple.offset],
240 | index: oldTuple.offset
241 | )))
242 |
243 | runningOffset += 1
244 | }
245 | }
246 |
247 | // insertions, replaces, moves
248 | do {
249 | var runningOffset = 0
250 |
251 | newArray.enumerated().forEach { newTuple in
252 | switch newTuple.element {
253 | case .tableEntry:
254 | runningOffset += 1
255 | changes.append(.insert(Insert(
256 | item: new[newTuple.offset],
257 | index: newTuple.offset
258 | )))
259 | case .indexInOther(let oldIndex):
260 | if !isEqual(oldItem: old[oldIndex], newItem: new[newTuple.offset]) {
261 | changes.append(.replace(Replace(
262 | oldItem: old[oldIndex],
263 | newItem: new[newTuple.offset],
264 | index: newTuple.offset
265 | )))
266 | }
267 |
268 | let deleteOffset = deleteOffsets[oldIndex]
269 | // The object is not at the expected position, so move it.
270 | if (oldIndex - deleteOffset + runningOffset) != newTuple.offset {
271 | changes.append(.move(Move(
272 | item: new[newTuple.offset],
273 | fromIndex: oldIndex,
274 | toIndex: newTuple.offset
275 | )))
276 | }
277 | }
278 | }
279 | }
280 |
281 | return changes
282 | }
283 |
284 | func isEqual(oldItem: T, newItem: T) -> Bool {
285 | return T.compareContent(oldItem, newItem)
286 | }
287 | }
288 |
--------------------------------------------------------------------------------