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