├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── spm-ios.yml ├── .gitignore ├── .spi.yml ├── .swiftlint.yml ├── CHANGELOG.md ├── Example ├── ExampleApp.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ ├── IDETemplateMacros.plist │ │ └── xcschemes │ │ └── ExampleApp.xcscheme ├── Sources │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ └── icon.png │ │ └── Contents.json │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── ColorModel │ │ ├── ColorModel.swift │ │ └── ColorViewController.swift │ ├── FlowLayout │ │ └── SimpleFlowLayoutViewController.swift │ ├── Grid │ │ ├── ColorCellViewModelGrid.swift │ │ ├── GridColorCell.swift │ │ ├── GridPersonCell.swift │ │ ├── GridPersonCell.xib │ │ ├── GridViewController.swift │ │ └── PersonCellViewModelGrid.swift │ ├── List │ │ ├── ColorCellViewModelList.swift │ │ ├── ListViewController.swift │ │ └── PersonCellViewModelList.swift │ ├── Main │ │ ├── AppDelegate.swift │ │ ├── EmptyView.swift │ │ ├── ExampleViewController.swift │ │ ├── Model.swift │ │ └── UIKit+Extensions.swift │ ├── PersonModel │ │ ├── PersonModel.swift │ │ ├── PersonViewController.swift │ │ └── PersonViewController.xib │ ├── SimpleStatic │ │ └── SimpleStaticViewController.swift │ └── SupplementaryViews │ │ ├── FavoriteBadgeView.swift │ │ ├── FavoriteBadgeViewModel.swift │ │ ├── FooterView.swift │ │ └── HeaderView.swift └── Tests │ ├── TestPlan.xctestplan │ ├── UITests │ ├── GridUITests.swift │ ├── ListUITests.swift │ └── StaticViewUITests.swift │ └── UnitTests │ └── ExampleModelTests.swift ├── LICENSE ├── Package.swift ├── README.md ├── ReactiveCollectionsKit.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ ├── IDETemplateMacros.plist │ └── xcschemes │ └── ReactiveCollectionsKit.xcscheme ├── Sources ├── CellEventCoordinator.swift ├── CellViewModel.swift ├── Collection+Extensions.swift ├── CollectionViewConstants.swift ├── CollectionViewDriver.swift ├── CollectionViewDriverOptions.swift ├── CollectionViewModel.swift ├── DebugDescriptions.swift ├── DiffableDataSource.swift ├── DiffableSnapshot.swift ├── DiffableViewModel.swift ├── EmptyViewProvider.swift ├── Logger.swift ├── SectionViewModel.swift ├── SupplementaryFooterViewModel.swift ├── SupplementaryHeaderViewModel.swift ├── SupplementaryViewModel.swift ├── UICollectionView+Extensions.swift ├── ViewRegistration.swift ├── ViewRegistrationMethod.swift ├── ViewRegistrationProvider.swift └── ViewRegistrationViewType.swift ├── Tests ├── Fakes │ ├── FakeCellEventCoordinator.swift │ ├── FakeCellNib.xib │ ├── FakeCellNibView.swift │ ├── FakeCellViewModel.swift │ ├── FakeCollectionView.swift │ ├── FakeCollectionViewController.swift │ ├── FakeCollectionViewModel.swift │ ├── FakeEmptyView.swift │ ├── FakeFlowLayoutDelegate.swift │ ├── FakeLayout.swift │ ├── FakeNumberModel.swift │ ├── FakeScrollViewDelegate.swift │ ├── FakeSupplementaryNib.xib │ ├── FakeSupplementaryNibView.swift │ ├── FakeSupplementaryViewModel.swift │ └── FakeTextModel.swift ├── TestCellEventCoordinator.swift ├── TestCellViewModel.swift ├── TestCollectionExtensions.swift ├── TestCollectionViewConstants.swift ├── TestCollectionViewDriver.swift ├── TestCollectionViewDriverOptions.swift ├── TestCollectionViewDriverReconfigure.swift ├── TestCollectionViewModel.swift ├── TestDebugDescriptionCollection.swift ├── TestDebugDescriptionDriver.swift ├── TestDebugDescriptionSection.swift ├── TestDiffableSnapshot.swift ├── TestEmptyView.swift ├── TestFlowLayoutDelegate.swift ├── TestPlans │ └── UnitTests.xctestplan ├── TestScrollViewDelegate.swift ├── TestSectionViewModel.swift ├── TestSupplementaryViewModel.swift ├── TestViewRegistration.swift ├── TestViewRegistrationMethod.swift ├── TestViewRegistrationViewType.swift └── Utils │ ├── CollectionViewDriverOptions+Extensions.swift │ ├── String+Extensions.swift │ ├── TestBundle.swift │ ├── TestExpectationField.swift │ ├── UnitTestCase.swift │ ├── XCTestCase+Extensions.swift │ └── XCTestExpectation+Expectations.swift ├── docs ├── Classes.html ├── Classes │ └── CollectionViewDriver.html ├── Enums.html ├── Enums │ ├── ViewRegistrationMethod.html │ └── ViewRegistrationViewType.html ├── Extensions.html ├── Extensions │ └── UICollectionView.html ├── Protocols.html ├── Protocols │ ├── CellEventCoordinator.html │ ├── CellViewModel.html │ ├── DiffableViewModel.html │ ├── SupplementaryFooterViewModel.html │ ├── SupplementaryHeaderViewModel.html │ ├── SupplementaryViewModel.html │ └── ViewRegistrationProvider.html ├── Structs.html ├── Structs │ ├── AnyCellViewModel.html │ ├── AnySupplementaryViewModel.html │ ├── CollectionViewDriverOptions.html │ ├── CollectionViewModel.html │ ├── EmptyViewProvider.html │ ├── SectionViewModel.html │ └── ViewRegistration.html ├── Typealiases.html ├── badge.svg ├── css │ ├── highlight.css │ └── jazzy.css ├── img │ ├── carat.png │ ├── dash.png │ ├── gh.png │ └── spinner.gif ├── index.html ├── js │ ├── jazzy.js │ ├── jazzy.search.js │ ├── jquery.min.js │ ├── lunr.min.js │ └── typeahead.jquery.js ├── search.json └── undocumented.json └── scripts ├── build_docs.zsh └── lint.zsh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Documentation for all configuration options: 2 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "bundler" 7 | directory: "/" 8 | schedule: 9 | interval: "weekly" 10 | labels: [] 11 | 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | labels: [] 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Virtual Environments 2 | # https://github.com/actions/virtual-environments/ 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | env: 15 | PROJECT: ReactiveCollectionsKit.xcodeproj 16 | SCHEME: ReactiveCollectionsKit 17 | 18 | EXAMPLE_PROJECT: Example/ExampleApp.xcodeproj 19 | EXAMPLE_SCHEME: ExampleApp 20 | 21 | DEVELOPER_DIR: /Applications/Xcode_16.1.app/Contents/Developer 22 | 23 | IOS_DEST: "platform=iOS Simulator,name=iPhone 16,OS=latest" 24 | 25 | jobs: 26 | env-details: 27 | name: Environment details 28 | runs-on: macos-15 29 | steps: 30 | - name: xcode version 31 | run: xcodebuild -version -sdk 32 | 33 | - name: list simulators 34 | run: | 35 | xcrun simctl delete unavailable 36 | xcrun simctl list 37 | 38 | test-iOS: 39 | name: iOS unit test 40 | runs-on: macos-15 41 | steps: 42 | - name: git checkout 43 | uses: actions/checkout@v4 44 | 45 | - name: unit tests 46 | run: | 47 | set -o pipefail 48 | xcodebuild clean test \ 49 | -project "$PROJECT" \ 50 | -scheme "$SCHEME" \ 51 | -destination "$IOS_DEST" \ 52 | CODE_SIGN_IDENTITY="-" | xcpretty -c 53 | 54 | ui-test-iOS: 55 | name: iOS UI tests 56 | runs-on: macos-15 57 | steps: 58 | - name: git checkout 59 | uses: actions/checkout@v4 60 | 61 | - name: ui tests 62 | run: | 63 | set -o pipefail 64 | xcodebuild clean test \ 65 | -project "$EXAMPLE_PROJECT" \ 66 | -scheme "$EXAMPLE_SCHEME" \ 67 | -destination "$IOS_DEST" \ 68 | CODE_SIGN_IDENTITY="-" | xcpretty -c 69 | -------------------------------------------------------------------------------- /.github/workflows/spm-ios.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Virtual Environments 2 | # https://github.com/actions/virtual-environments/ 3 | 4 | name: SwiftPM Integration 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | env: 15 | DEVELOPER_DIR: /Applications/Xcode_16.1.app/Contents/Developer 16 | IOS_DEST: "platform=iOS Simulator,name=iPhone 16,OS=latest" 17 | SCHEME: ReactiveCollectionsKit 18 | 19 | jobs: 20 | main: 21 | name: SwiftPM Build 22 | runs-on: macos-15 23 | steps: 24 | - name: git checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: xcode version 28 | run: xcodebuild -version -sdk 29 | 30 | - name: list simulators 31 | run: | 32 | xcrun simctl delete unavailable 33 | xcrun simctl list 34 | 35 | # delete Xcode project to force using Package.swift 36 | - name: delete xcodeproj 37 | run: rm -rf ReactiveCollectionsKit.xcodeproj 38 | 39 | - name: Build 40 | run: | 41 | set -o pipefail 42 | xcodebuild build -scheme "$SCHEME" -destination "$IOS_DEST" | xcpretty -c 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | 4 | # ruby tools 5 | .bundle/ 6 | vendor/ 7 | 8 | # docs 9 | docs/docsets/ 10 | 11 | # Xcode 12 | xcuserdata/ 13 | build/ 14 | 15 | ## Obj-C/Swift specific 16 | *.hmap 17 | 18 | ## App packaging 19 | *.ipa 20 | *.dSYM.zip 21 | *.dSYM 22 | 23 | ## Playgrounds 24 | timeline.xctimeline 25 | playground.xcworkspace 26 | 27 | # Swift Package Manager 28 | .swiftpm 29 | .build/ 30 | 31 | # fastlane 32 | # 33 | # It is recommended to not store the screenshots in the git repo. 34 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 35 | # For more information about the recommended setup visit: 36 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 37 | fastlane/report.xml 38 | fastlane/Preview.html 39 | fastlane/screenshots/**/*.png 40 | fastlane/test_output 41 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | # https://swiftpackageindex.com/SwiftPackageIndex/SPIManifest/1.1.1/documentation/spimanifest 2 | version: 1 3 | builder: 4 | configs: 5 | - documentation_targets: [ReactiveCollectionsKit] 6 | platform: ios 7 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # SwiftLint configuration 2 | # Rule directory: https://realm.github.io/SwiftLint/rule-directory.html 3 | # GitHub: https://github.com/realm/SwiftLint 4 | 5 | excluded: 6 | - Pods 7 | - docs 8 | - build 9 | - scripts 10 | - swiftpm 11 | - .bundle 12 | - vendor 13 | 14 | disabled_rules: 15 | # lint 16 | - notification_center_detachment 17 | - weak_delegate 18 | 19 | # idiomatic 20 | - force_cast 21 | - type_name 22 | 23 | opt_in_rules: 24 | # performance 25 | - empty_count 26 | - first_where 27 | - sorted_first_last 28 | - contains_over_first_not_nil 29 | - last_where 30 | - reduce_into 31 | - contains_over_filter_count 32 | - contains_over_filter_is_empty 33 | - empty_collection_literal 34 | - final_test_case 35 | 36 | # idiomatic 37 | - fatal_error_message 38 | - xctfail_message 39 | - discouraged_object_literal 40 | - discouraged_optional_boolean 41 | - discouraged_optional_collection 42 | - for_where 43 | # - function_default_parameter_at_end 44 | - legacy_random 45 | - no_extension_access_modifier 46 | - redundant_type_annotation 47 | - static_operator 48 | - toggle_bool 49 | - unavailable_function 50 | - no_space_in_method_call 51 | - discouraged_assert 52 | - legacy_objc_type 53 | # - file_name 54 | - file_name_no_space 55 | - discouraged_none_name 56 | - return_value_from_void_function 57 | - prefer_zero_over_explicit_init 58 | - shorthand_optional_binding 59 | - xct_specific_matcher 60 | - unneeded_synthesized_initializer 61 | - static_over_final_class 62 | - prefer_key_path 63 | # - no_empty_block 64 | 65 | # style 66 | - attributes 67 | - number_separator 68 | - operator_usage_whitespace 69 | - sorted_imports 70 | - vertical_parameter_alignment_on_call 71 | - void_return 72 | - closure_spacing 73 | - empty_enum_arguments 74 | - implicit_return 75 | - modifier_order 76 | - multiline_arguments 77 | - multiline_parameters 78 | # - trailing_closure 79 | - unneeded_parentheses_in_closure_argument 80 | - vertical_whitespace_between_cases 81 | - prefer_self_in_static_references 82 | - comma_inheritance 83 | - direct_return 84 | - period_spacing 85 | - superfluous_else 86 | # - sorted_enum_cases 87 | - non_overridable_class_declaration 88 | 89 | # lint 90 | - overridden_super_call 91 | - override_in_extension 92 | - yoda_condition 93 | - array_init 94 | - empty_xctest_method 95 | - identical_operands 96 | - prohibited_super_call 97 | - duplicate_enum_cases 98 | - legacy_multiple 99 | - accessibility_label_for_image 100 | - lower_acl_than_parent 101 | - unhandled_throwing_task 102 | - private_swiftui_state 103 | - non_optional_string_data_conversion 104 | # - unused_parameter 105 | 106 | # Rules run by `swiftlint analyze` (experimental) 107 | analyzer_rules: 108 | - unused_import 109 | - unused_declaration 110 | - explicit_self 111 | - capture_variable 112 | 113 | line_length: 200 114 | file_length: 650 115 | 116 | type_body_length: 500 117 | function_body_length: 250 118 | 119 | cyclomatic_complexity: 10 120 | 121 | nesting: 122 | type_level: 2 123 | function_level: 2 124 | check_nesting_in_closures_and_statements: true 125 | always_allow_one_type_in_functions: false 126 | 127 | identifier_name: 128 | allowed_symbols: ['_'] 129 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The changelog for `ReactiveCollectionsKit`. Also see [the releases on GitHub](https://github.com/jessesquires/ReactiveCollectionsKit/releases). 4 | 5 | NEXT 6 | ----- 7 | 8 | - Improve debug descriptions (i.e., `CustomDebugStringConvertible`) for various types. ([@nuomi1](https://github.com/nuomi1), [#139](https://github.com/jessesquires/ReactiveCollectionsKit/pull/139)) 9 | - Implement (optional) debug logging for view model updates. You can now provide a logger for debugging purposes by setting `CollectionViewDriver.logger`. The library provides a default implementation via `RCKLogger.shared`. ([@nuomi1](https://github.com/nuomi1), [#141](https://github.com/jessesquires/ReactiveCollectionsKit/pull/141)) 10 | - TBA 11 | 12 | 0.1.8 13 | ----- 14 | 15 | - Allow setting a `UICollectionViewDelegateFlowLayout` object to receive flow layout events from the collection view. ([@jessesquires](https://github.com/jessesquires), [#134](https://github.com/jessesquires/ReactiveCollectionsKit/pull/134)) 16 | - Swift Concurrency improvements: 17 | - `@MainActor` annotations have been removed from most top-level types and protocols, instead opting to apply `@MainActor` to individual members only where necessary. ([@jessesquires](https://github.com/jessesquires), [#135](https://github.com/jessesquires/ReactiveCollectionsKit/pull/135)) 18 | - `DiffableViewModel` is now marked as `Sendable`. This means `Sendable` also applies to `CellViewModel`, `SupplementaryViewModel`, `SectionViewModel`, and `CollectionViewModel`. ([@jessesquires](https://github.com/jessesquires), [#137](https://github.com/jessesquires/ReactiveCollectionsKit/pull/137)) 19 | - Various performance improvements. Notably, when configuring `CollectionViewDriver` to perform diffing on a background queue via `CollectionViewDriverOptions.diffOnBackgroundQueue`, more operations are now performed in the background that were previously running on the main thread. ([@jessesquires](https://github.com/jessesquires), [#136](https://github.com/jessesquires/ReactiveCollectionsKit/pull/136), [#137](https://github.com/jessesquires/ReactiveCollectionsKit/pull/137), [@lachenmayer](https://github.com/lachenmayer), [#138](https://github.com/jessesquires/ReactiveCollectionsKit/pull/138)) 20 | 21 | 0.1.7 22 | ----- 23 | 24 | - Upgraded to Xcode 16. ([@jessesquires](https://github.com/jessesquires), [#116](https://github.com/jessesquires/ReactiveCollectionsKit/pull/116)) 25 | - Reverted back to Swift 5 language mode because of issues in UIKit. ([@jessesquires](https://github.com/jessesquires), [#116](https://github.com/jessesquires/ReactiveCollectionsKit/pull/116)) 26 | - Applying a snapshot using `reloadData` now always occurs on the main thread. ([@jessesquires](https://github.com/jessesquires), [#116](https://github.com/jessesquires/ReactiveCollectionsKit/pull/116)) 27 | - Implemented additional selection APIs for `CellViewModel`: `shouldSelect`, `shouldDeselect`, `didDeselect()`. ([@nuomi1](https://github.com/nuomi1), [#127](https://github.com/jessesquires/ReactiveCollectionsKit/pull/127)) 28 | - Allow setting a `UIScrollViewDelegate` object to receive scroll view events from the collection view. ([@ruddfawcett](https://github.com/ruddfawcett), [#131](https://github.com/jessesquires/ReactiveCollectionsKit/pull/131), [#133](https://github.com/jessesquires/ReactiveCollectionsKit/pull/133)) 29 | 30 | 0.1.6 31 | ----- 32 | 33 | - Fixed a potential crash (in `DiffableDataSource`) when hiding a collection view before animations complete when diffing. This may have caused a crash with the message _Fatal error: Attempted to read an unowned reference but the object was already deallocated_. ([@lachenmayer](https://github.com/lachenmayer), [#125](https://github.com/jessesquires/ReactiveCollectionsKit/issues/125), [#126](https://github.com/jessesquires/ReactiveCollectionsKit/issues/126)) 34 | 35 | 0.1.5 36 | ----- 37 | 38 | - Implemented `didHighlight()` and `didUnhighlight()` APIs for `CellViewModel`. ([@nuomi1](https://github.com/nuomi1), [#123](https://github.com/jessesquires/ReactiveCollectionsKit/pull/123)) 39 | 40 | 0.1.4 41 | ----- 42 | 43 | - Implemented `willDisplay()` and `didEndDisplaying()` APIs for both `CellViewModel` and `SupplementaryViewModel`. ([@nuomi1](https://github.com/nuomi1), [#121](https://github.com/jessesquires/ReactiveCollectionsKit/pull/121)) 44 | 45 | 0.1.3 46 | ----- 47 | 48 | - Improve debug descriptions for `CollectionViewModel` and `SectionViewModel` ([@nuomi1](https://github.com/nuomi1), [#119](https://github.com/jessesquires/ReactiveCollectionsKit/pull/119), [#120](https://github.com/jessesquires/ReactiveCollectionsKit/pull/120)) 49 | 50 | 0.1.2 51 | ----- 52 | 53 | - Fixed bug when chaining multiple calls to `eraseToAnyViewModel()` for both `CellViewModel` and and `SupplementaryViewModel`. Previously, it was possible "double erase" a view model by calling `eraseToAnyViewModel()` multiple times, thus actually losing type information. Now, consecutive calls to `eraseToAnyViewModel()` have no effect. ([@nuomi1](https://github.com/nuomi1), [#117](https://github.com/jessesquires/ReactiveCollectionsKit/pull/117)) 54 | 55 | 0.1.1 56 | ----- 57 | 58 | - Documentation updates. 59 | 60 | 0.1.0 61 | ----- 62 | 63 | Initial release. 🎉 64 | -------------------------------------------------------------------------------- /Example/ExampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/ExampleApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/ExampleApp.xcodeproj/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FILEHEADER 6 | 7 | // Created by Jesse Squires 8 | // https://www.jessesquires.com 9 | // 10 | // Documentation 11 | // https://jessesquires.github.io/ReactiveCollectionsKit 12 | // 13 | // GitHub 14 | // https://github.com/jessesquires/ReactiveCollectionsKit 15 | // 16 | // Copyright © 2019-present Jesse Squires 17 | // 18 | 19 | 20 | -------------------------------------------------------------------------------- /Example/ExampleApp.xcodeproj/xcshareddata/xcschemes/ExampleApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 35 | 36 | 37 | 40 | 46 | 47 | 48 | 50 | 56 | 57 | 58 | 59 | 60 | 70 | 72 | 78 | 79 | 80 | 81 | 87 | 89 | 95 | 96 | 97 | 98 | 100 | 101 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /Example/Sources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Example/Sources/Assets.xcassets/AppIcon.appiconset/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessesquires/ReactiveCollectionsKit/afde76889320f3c7dcbf53156cde214f1a585000/Example/Sources/Assets.xcassets/AppIcon.appiconset/icon.png -------------------------------------------------------------------------------- /Example/Sources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Sources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /Example/Sources/ColorModel/ColorModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import ReactiveCollectionsKit 15 | import UIKit 16 | 17 | enum Color: String, Hashable, CaseIterable { 18 | case blue 19 | case brown 20 | case green 21 | case indigo 22 | case orange 23 | case pink 24 | case purple 25 | case red 26 | case teal 27 | case yellow 28 | } 29 | 30 | struct ColorModel: Hashable { 31 | let color: Color 32 | var isFavorite = false 33 | 34 | var name: String { 35 | self.color.rawValue 36 | } 37 | 38 | var id: UniqueIdentifier { 39 | self.color.rawValue 40 | } 41 | 42 | var uiColor: UIColor { 43 | switch self.color { 44 | case .blue: 45 | return .systemBlue 46 | 47 | case .brown: 48 | return .systemBrown 49 | 50 | case .green: 51 | return .systemGreen 52 | 53 | case .indigo: 54 | return .systemIndigo 55 | 56 | case .orange: 57 | return .systemOrange 58 | 59 | case .pink: 60 | return .systemPink 61 | 62 | case .purple: 63 | return .systemPurple 64 | 65 | case .red: 66 | return .systemRed 67 | 68 | case .teal: 69 | return .systemTeal 70 | 71 | case .yellow: 72 | return .systemYellow 73 | } 74 | } 75 | } 76 | 77 | extension ColorModel { 78 | static func makeColors() -> [ColorModel] { 79 | Color.allCases.map { ColorModel(color: $0) } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Example/Sources/ColorModel/ColorViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import UIKit 15 | 16 | final class ColorViewController: UIViewController { 17 | let color: ColorModel 18 | 19 | let label = UILabel() 20 | 21 | init(color: ColorModel) { 22 | self.color = color 23 | super.init(nibName: nil, bundle: nil) 24 | self.title = "Color" 25 | } 26 | 27 | @available(*, unavailable) 28 | required init?(coder: NSCoder) { 29 | fatalError("init(coder:) has not been implemented") 30 | } 31 | 32 | override func viewDidLoad() { 33 | super.viewDidLoad() 34 | self.view.backgroundColor = .systemBackground 35 | 36 | self.label.font = UIFont.preferredFont(forTextStyle: .title1) 37 | self.label.textAlignment = .center 38 | self.label.translatesAutoresizingMaskIntoConstraints = false 39 | self.view.addSubview(self.label) 40 | let size = 200.0 41 | NSLayoutConstraint.activate([ 42 | self.label.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), 43 | self.label.centerYAnchor.constraint(equalTo: self.view.centerYAnchor), 44 | self.label.widthAnchor.constraint(equalToConstant: size), 45 | self.label.heightAnchor.constraint(equalToConstant: size) 46 | ]) 47 | 48 | self.label.text = self.color.name 49 | self.label.backgroundColor = self.color.uiColor 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Example/Sources/FlowLayout/SimpleFlowLayoutViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import ReactiveCollectionsKit 16 | import UIKit 17 | 18 | final class SimpleFlowLayoutViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout { 19 | 20 | lazy var driver = CollectionViewDriver(view: self.collectionView) 21 | 22 | // MARK: Init 23 | 24 | init() { 25 | let layout = UICollectionViewFlowLayout() 26 | super.init(collectionViewLayout: layout) 27 | } 28 | 29 | @available(*, unavailable) 30 | required init?(coder: NSCoder) { 31 | fatalError("init(coder:) has not been implemented") 32 | } 33 | 34 | // MARK: View lifecycle 35 | 36 | override func viewDidLoad() { 37 | super.viewDidLoad() 38 | self.collectionView.alwaysBounceVertical = true 39 | self.collectionView.accessibilityIdentifier = "Flow Layout" 40 | 41 | self.driver.flowLayoutDelegate = self 42 | 43 | let models = ColorModel.makeColors() 44 | 45 | let cellViewModels = models.map { 46 | ColorCellViewModelList( 47 | color: $0, 48 | contextMenuConfiguration: nil 49 | ) 50 | } 51 | 52 | let section = SectionViewModel(id: "section", cells: cellViewModels) 53 | 54 | let collectionViewModel = CollectionViewModel(id: "static_flow_layout", sections: [section]) 55 | 56 | self.driver.update(viewModel: collectionViewModel) 57 | } 58 | 59 | // MARK: UICollectionViewDelegateFlowLayout 60 | 61 | func collectionView( 62 | _ collectionView: UICollectionView, 63 | layout collectionViewLayout: UICollectionViewLayout, 64 | sizeForItemAt indexPath: IndexPath 65 | ) -> CGSize { 66 | let random = Int.random(in: 70...200) 67 | return CGSize(width: random, height: random) 68 | } 69 | 70 | func collectionView( 71 | _ collectionView: UICollectionView, 72 | layout collectionViewLayout: UICollectionViewLayout, 73 | minimumInteritemSpacingForSectionAt section: Int 74 | ) -> CGFloat { 75 | 8 76 | } 77 | 78 | func collectionView( 79 | _ collectionView: UICollectionView, 80 | layout collectionViewLayout: UICollectionViewLayout, 81 | minimumLineSpacingForSectionAt section: Int 82 | ) -> CGFloat { 83 | 8 84 | } 85 | 86 | func collectionView( 87 | _ collectionView: UICollectionView, 88 | layout collectionViewLayout: UICollectionViewLayout, 89 | insetForSectionAt section: Int 90 | ) -> UIEdgeInsets { 91 | UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Example/Sources/Grid/ColorCellViewModelGrid.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import ReactiveCollectionsKit 15 | import UIKit 16 | 17 | struct ColorCellViewModelGrid: CellViewModel { 18 | let color: ColorModel 19 | 20 | // MARK: CellViewModel 21 | 22 | var id: UniqueIdentifier { self.color.id } 23 | 24 | let contextMenuConfiguration: UIContextMenuConfiguration? 25 | 26 | func configure(cell: GridColorCell) { 27 | cell.label.text = self.color.name 28 | cell.backgroundColor = self.color.uiColor 29 | } 30 | 31 | // MARK: Hashable 32 | 33 | func hash(into hasher: inout Hasher) { 34 | hasher.combine(self.color) 35 | } 36 | 37 | static func == (left: Self, right: Self) -> Bool { 38 | left.color == right.color 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Example/Sources/Grid/GridColorCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import UIKit 15 | 16 | final class GridColorCell: UICollectionViewCell { 17 | let label = UILabel() 18 | 19 | override init(frame: CGRect) { 20 | super.init(frame: frame) 21 | self.label.translatesAutoresizingMaskIntoConstraints = false 22 | self.label.numberOfLines = 0 23 | self.label.textAlignment = .center 24 | self.contentView.addSubview(self.label) 25 | NSLayoutConstraint.activate([ 26 | self.label.topAnchor.constraint(equalTo: self.contentView.topAnchor), 27 | self.label.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), 28 | self.label.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor), 29 | self.label.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor) 30 | ]) 31 | } 32 | 33 | @available(*, unavailable) 34 | required init?(coder: NSCoder) { 35 | fatalError("init(coder:) has not been implemented") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Example/Sources/Grid/GridPersonCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import UIKit 15 | 16 | final class GridPersonCell: UICollectionViewCell { 17 | 18 | @IBOutlet weak var titleLabel: UILabel! 19 | @IBOutlet weak var subtitleLabel: UILabel! 20 | @IBOutlet weak var flagLabel: UILabel! 21 | 22 | required init?(coder: NSCoder) { 23 | super.init(coder: coder) 24 | self.backgroundColor = .systemGray6 25 | self.selectedBackgroundView = UIView() 26 | self.selectedBackgroundView?.backgroundColor = .systemGray4 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Example/Sources/Grid/GridPersonCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 29 | 38 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /Example/Sources/Grid/PersonCellViewModelGrid.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import ReactiveCollectionsKit 15 | import UIKit 16 | 17 | struct PersonCellViewModelGrid: CellViewModel { 18 | let person: PersonModel 19 | 20 | // MARK: CellViewModel 21 | 22 | var id: UniqueIdentifier { self.person.id } 23 | 24 | var registration: ViewRegistration { 25 | ViewRegistration( 26 | reuseIdentifier: self.reuseIdentifier, 27 | cellNibName: "GridPersonCell" 28 | ) 29 | } 30 | 31 | let contextMenuConfiguration: UIContextMenuConfiguration? 32 | 33 | let shouldSelect: Bool 34 | 35 | func configure(cell: GridPersonCell) { 36 | cell.titleLabel.text = self.person.name 37 | cell.subtitleLabel.text = self.person.birthDateText 38 | cell.flagLabel.text = self.person.nationality 39 | cell.contentView.alpha = self.shouldSelect ? 1 : 0.3 40 | } 41 | 42 | // MARK: Hashable 43 | 44 | func hash(into hasher: inout Hasher) { 45 | hasher.combine(self.person) 46 | hasher.combine(self.shouldSelect) 47 | } 48 | 49 | static func == (left: Self, right: Self) -> Bool { 50 | left.person == right.person 51 | && left.shouldSelect == right.shouldSelect 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Example/Sources/List/ColorCellViewModelList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import ReactiveCollectionsKit 15 | import UIKit 16 | 17 | struct ColorCellViewModelList: CellViewModel { 18 | let color: ColorModel 19 | 20 | // MARK: CellViewModel 21 | 22 | var id: UniqueIdentifier { self.color.id } 23 | 24 | let contextMenuConfiguration: UIContextMenuConfiguration? 25 | 26 | func configure(cell: UICollectionViewListCell) { 27 | var contentConfiguration = cell.defaultContentConfiguration() 28 | contentConfiguration.text = self.color.name 29 | cell.contentConfiguration = contentConfiguration 30 | cell.backgroundView = UIView() 31 | cell.backgroundView?.backgroundColor = self.color.uiColor 32 | 33 | if self.color.isFavorite { 34 | let imageView = UIImageView(image: UIImage(systemName: "star.fill")) 35 | imageView.tintColor = .label 36 | let favorite = UICellAccessory.customView( 37 | configuration: .init(customView: imageView, placement: .trailing()) 38 | ) 39 | cell.accessories = [favorite] 40 | } else { 41 | cell.accessories = [] 42 | } 43 | } 44 | 45 | func didSelect(with coordinator: (any CellEventCoordinator)?) { 46 | let colorVC = ColorViewController(color: self.color) 47 | coordinator?.underlyingViewController?.navigationController?.pushViewController(colorVC, animated: true) 48 | } 49 | 50 | // MARK: Hashable 51 | 52 | func hash(into hasher: inout Hasher) { 53 | hasher.combine(self.color) 54 | } 55 | 56 | static func == (left: Self, right: Self) -> Bool { 57 | left.color == right.color 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Example/Sources/List/ListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Combine 15 | import ReactiveCollectionsKit 16 | import UIKit 17 | 18 | final class ListViewController: ExampleViewController, CellEventCoordinator { 19 | 20 | lazy var driver: CollectionViewDriver = { 21 | let driver = CollectionViewDriver( 22 | view: self.collectionView, 23 | options: .init(diffOnBackgroundQueue: true), 24 | emptyViewProvider: sharedEmptyViewProvider, 25 | cellEventCoordinator: self 26 | ) 27 | driver.scrollViewDelegate = self 28 | return driver 29 | }() 30 | 31 | override var model: Model { 32 | didSet { 33 | // Every time the model updates, regenerate and set the view model 34 | let viewModel = self.makeViewModel() 35 | 36 | Task { @MainActor in 37 | await self.driver.update(viewModel: viewModel) 38 | print("list did update! async") 39 | } 40 | } 41 | } 42 | 43 | private var cancellables = [AnyCancellable]() 44 | 45 | // MARK: Init 46 | 47 | init() { 48 | let layout = UICollectionViewCompositionalLayout { _, layoutEnvironment in 49 | var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped) 50 | configuration.headerMode = .supplementary 51 | configuration.footerMode = .supplementary 52 | return NSCollectionLayoutSection.list( 53 | using: configuration, 54 | layoutEnvironment: layoutEnvironment 55 | ) 56 | } 57 | super.init(collectionViewLayout: layout) 58 | } 59 | 60 | // MARK: CellEventCoordinator 61 | 62 | // In this example, the cell view models handle cell selection and navigation themselves. 63 | 64 | // MARK: View lifecycle 65 | 66 | override func viewDidLoad() { 67 | super.viewDidLoad() 68 | self.driver.logger = RCKLogger.shared 69 | 70 | let viewModel = self.makeViewModel() 71 | self.driver.update(viewModel: viewModel) 72 | 73 | self.driver.$viewModel 74 | .sink { _ in 75 | print("did publish view model update") 76 | } 77 | .store(in: &self.cancellables) 78 | } 79 | 80 | // MARK: Private 81 | 82 | private func makeViewModel() -> CollectionViewModel { 83 | // Create People Section 84 | let peopleCellViewModels = self.model.people.map { 85 | let menuConfig = UIContextMenuConfiguration.configFor( 86 | itemId: $0.id, 87 | favoriteAction: { [unowned self] in 88 | self.toggleFavorite(id: $0) 89 | }, 90 | deleteAction: { [unowned self] in 91 | self.deleteItem(id: $0) 92 | } 93 | ) 94 | 95 | return PersonCellViewModelList( 96 | person: $0, 97 | contextMenuConfiguration: menuConfig 98 | ).eraseToAnyViewModel() 99 | } 100 | let peopleHeader = HeaderViewModel(title: "People", style: .small) 101 | let peopleFooter = FooterViewModel(text: "\(self.model.people.count) people") 102 | let peopleSection = SectionViewModel( 103 | id: "section_people_list", 104 | cells: peopleCellViewModels, 105 | header: peopleHeader, 106 | footer: peopleFooter 107 | ) 108 | 109 | // Create Color Section 110 | let colorCellViewModels = self.model.colors.map { 111 | let menuConfig = UIContextMenuConfiguration.configFor( 112 | itemId: $0.id, 113 | favoriteAction: { [unowned self] in 114 | self.toggleFavorite(id: $0) 115 | }, 116 | deleteAction: { [unowned self] in 117 | self.deleteItem(id: $0) 118 | } 119 | ) 120 | return ColorCellViewModelList( 121 | color: $0, 122 | contextMenuConfiguration: menuConfig 123 | ).eraseToAnyViewModel() 124 | } 125 | let colorHeader = HeaderViewModel(title: "Colors", style: .small) 126 | let colorFooter = FooterViewModel(text: "\(self.model.colors.count) colors") 127 | let colorSection = SectionViewModel( 128 | id: "section_colors_list", 129 | cells: colorCellViewModels, 130 | header: colorHeader, 131 | footer: colorFooter 132 | ) 133 | 134 | // Create final view model 135 | return CollectionViewModel(id: "list_view", sections: [peopleSection, colorSection]) 136 | } 137 | } 138 | 139 | extension ListViewController: UIScrollViewDelegate { 140 | // Example of receiving scroll view events 141 | func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { 142 | print(#function) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /Example/Sources/List/PersonCellViewModelList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import ReactiveCollectionsKit 15 | import UIKit 16 | 17 | struct PersonCellViewModelList: CellViewModel { 18 | let person: PersonModel 19 | 20 | // MARK: CellViewModel 21 | 22 | var id: UniqueIdentifier { self.person.id } 23 | 24 | let contextMenuConfiguration: UIContextMenuConfiguration? 25 | 26 | func configure(cell: UICollectionViewListCell) { 27 | var contentConfiguration = UIListContentConfiguration.subtitleCell() 28 | contentConfiguration.text = self.person.name 29 | contentConfiguration.secondaryText = self.person.birthDateText 30 | contentConfiguration.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 8, bottom: 16, trailing: 8) 31 | cell.contentConfiguration = contentConfiguration 32 | 33 | let label = UILabel() 34 | label.text = self.person.nationality 35 | let flagEmoji = UICellAccessory.customView( 36 | configuration: .init(customView: label, placement: .leading()) 37 | ) 38 | var accessories = [flagEmoji, .disclosureIndicator()] 39 | 40 | if self.person.isFavorite { 41 | let imageView = UIImageView(image: UIImage(systemName: "star.fill")) 42 | imageView.tintColor = .systemYellow 43 | let favorite = UICellAccessory.customView( 44 | configuration: .init(customView: imageView, placement: .trailing()) 45 | ) 46 | accessories.append(favorite) 47 | } 48 | 49 | cell.accessories = accessories 50 | } 51 | 52 | func didSelect(with coordinator: (any CellEventCoordinator)?) { 53 | let personVC = PersonViewController(person: self.person) 54 | coordinator?.underlyingViewController?.navigationController?.pushViewController(personVC, animated: true) 55 | } 56 | 57 | // MARK: Hashable 58 | 59 | func hash(into hasher: inout Hasher) { 60 | hasher.combine(self.person) 61 | } 62 | 63 | static func == (left: Self, right: Self) -> Bool { 64 | left.person == right.person 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Example/Sources/Main/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import UIKit 15 | 16 | @main 17 | final class AppDelegate: UIResponder, UIApplicationDelegate { 18 | var window: UIWindow? 19 | 20 | func application( 21 | _ application: UIApplication, 22 | // swiftlint:disable:next discouraged_optional_collection 23 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 24 | ) -> Bool { 25 | let grid = GridViewController() 26 | grid.title = "Grid" 27 | grid.tabBarItem.image = UIImage(systemName: "square.grid.2x2.fill") 28 | 29 | let list = ListViewController() 30 | list.title = "List" 31 | list.tabBarItem.image = UIImage(systemName: "list.dash") 32 | 33 | let simple = SimpleStaticViewController() 34 | simple.title = "Simple Static" 35 | simple.tabBarItem.image = UIImage(systemName: "list.bullet.rectangle.portrait.fill") 36 | 37 | let flow = SimpleFlowLayoutViewController() 38 | flow.title = "Flow Layout" 39 | flow.tabBarItem.image = UIImage(systemName: "square.grid.3x3.square") 40 | 41 | let tabBar = UITabBarController() 42 | tabBar.viewControllers = [ 43 | UINavigationController(rootViewController: grid), 44 | UINavigationController(rootViewController: list), 45 | UINavigationController(rootViewController: simple), 46 | UINavigationController(rootViewController: flow) 47 | ] 48 | 49 | self.window = UIWindow() 50 | self.window?.rootViewController = tabBar 51 | self.window?.makeKeyAndVisible() 52 | return true 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Example/Sources/Main/EmptyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import ReactiveCollectionsKit 16 | import UIKit 17 | 18 | @MainActor let sharedEmptyViewProvider = EmptyViewProvider { 19 | if #available(iOS 17.0, *) { 20 | var config = UIContentUnavailableConfiguration.empty() 21 | config.text = "No Content" 22 | config.secondaryText = "The list is empty! Nothing to see here." 23 | config.image = UIImage(systemName: "exclamationmark.triangle.fill") 24 | var background = UIBackgroundConfiguration.clear() 25 | background.backgroundColor = .tertiarySystemBackground 26 | config.background = background 27 | return UIContentUnavailableView(configuration: config) 28 | } 29 | 30 | let label = UILabel() 31 | label.text = "No Content" 32 | label.font = UIFont.preferredFont(forTextStyle: .title1) 33 | label.textAlignment = .center 34 | label.backgroundColor = .secondarySystemBackground 35 | return label 36 | } 37 | -------------------------------------------------------------------------------- /Example/Sources/Main/ExampleViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import ReactiveCollectionsKit 15 | import UIKit 16 | 17 | class ExampleViewController: UIViewController { 18 | let collectionView: UICollectionView 19 | 20 | var model = Model() 21 | 22 | init(collectionViewLayout: UICollectionViewLayout) { 23 | self.collectionView = UICollectionView( 24 | frame: .zero, 25 | collectionViewLayout: collectionViewLayout 26 | ) 27 | super.init(nibName: nil, bundle: nil) 28 | } 29 | 30 | @available(*, unavailable) 31 | required init?(coder: NSCoder) { 32 | fatalError("init(coder:) has not been implemented") 33 | } 34 | 35 | // MARK: View Lifecycle 36 | 37 | override func viewDidLoad() { 38 | super.viewDidLoad() 39 | 40 | self.view.addSubview(self.collectionView) 41 | self.collectionView.frame = self.view.frame 42 | self.collectionView.translatesAutoresizingMaskIntoConstraints = false 43 | NSLayoutConstraint.activate([ 44 | self.collectionView.topAnchor.constraint(equalTo: self.view.topAnchor), 45 | self.collectionView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), 46 | self.collectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), 47 | self.collectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) 48 | ]) 49 | 50 | self.addShuffleButton() 51 | self.addReloadButton() 52 | } 53 | 54 | override func viewWillAppear(_ animated: Bool) { 55 | super.viewWillAppear(animated) 56 | self.collectionView.deselectAllItems() 57 | } 58 | 59 | // MARK: Actions 60 | 61 | @objc 62 | func shuffle() { 63 | self.model.shuffle() 64 | } 65 | 66 | func reset() { 67 | self.model = Model() 68 | } 69 | 70 | func removeAll() { 71 | self.model = Model(people: [], colors: []) 72 | } 73 | 74 | func deleteItem(id: UniqueIdentifier) { 75 | self.model.delete(id: id) 76 | } 77 | 78 | func toggleFavorite(id: UniqueIdentifier) { 79 | self.model.toggleFavorite(id: id) 80 | } 81 | 82 | // MARK: Helpers 83 | 84 | private func appendRightBarButton(_ item: UIBarButtonItem) { 85 | var items = self.navigationItem.rightBarButtonItems ?? [] 86 | items.append(item) 87 | self.navigationItem.rightBarButtonItems = items 88 | } 89 | 90 | private func addShuffleButton() { 91 | let item = UIBarButtonItem(systemImage: "shuffle", target: self, action: #selector(shuffle)) 92 | self.appendRightBarButton(item) 93 | } 94 | 95 | private func addReloadButton() { 96 | let reset = UIAction( 97 | title: "Reset", 98 | systemImage: "arrow.2.squarepath" 99 | ) { [unowned self] _ in 100 | self.reset() 101 | } 102 | 103 | let removeAll = UIAction( 104 | title: "Remove All", 105 | systemImage: "trash", 106 | attributes: .destructive 107 | ) { [unowned self] _ in 108 | self.removeAll() 109 | } 110 | 111 | let menu = UIMenu(children: [reset, removeAll]) 112 | let item = UIBarButtonItem(systemItem: .refresh, primaryAction: nil, menu: menu) 113 | self.appendRightBarButton(item) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Example/Sources/Main/Model.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import ReactiveCollectionsKit 16 | 17 | struct Model { 18 | private(set) var people = PersonModel.makePeople() 19 | 20 | private(set) var colors = ColorModel.makeColors() 21 | 22 | mutating func shuffle() { 23 | self.people.shuffle() 24 | self.colors.shuffle() 25 | } 26 | 27 | mutating func delete(id: UniqueIdentifier) { 28 | if let index = self.people.firstIndex(where: { $0.id == id }) { 29 | self.people.remove(at: index) 30 | } 31 | if let index = self.colors.firstIndex(where: { $0.id == id }) { 32 | self.colors.remove(at: index) 33 | } 34 | } 35 | 36 | mutating func toggleFavorite(id: UniqueIdentifier) { 37 | if let index = self.people.firstIndex(where: { $0.id == id }) { 38 | self.people[index].isFavorite.toggle() 39 | } 40 | if let index = self.colors.firstIndex(where: { $0.id == id }) { 41 | self.colors[index].isFavorite.toggle() 42 | } 43 | } 44 | } 45 | 46 | extension Model: CustomDebugStringConvertible { 47 | var debugDescription: String { 48 | let peopleNames = self.people.map(\.name).joined(separator: "\n\t") 49 | let colorNames = self.colors.map(\.name).joined(separator: "\n\t") 50 | return """ 51 | People: 52 | \(peopleNames) 53 | 54 | Colors: 55 | \(colorNames) 56 | """ 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Example/Sources/Main/UIKit+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import ReactiveCollectionsKit 15 | import UIKit 16 | 17 | extension UIBarButtonItem { 18 | convenience init(systemImage: String, target: Any?, action: Selector?) { 19 | self.init(image: UIImage(systemName: systemImage), 20 | style: .plain, 21 | target: target, 22 | action: action) 23 | } 24 | } 25 | 26 | extension UIAction { 27 | convenience init( 28 | title: String, 29 | systemImage: String, 30 | attributes: UIMenuElement.Attributes = .init(), 31 | handler: @escaping UIActionHandler 32 | ) { 33 | self.init( 34 | title: title, 35 | image: UIImage(systemName: systemImage), 36 | attributes: attributes, 37 | handler: handler 38 | ) 39 | } 40 | } 41 | 42 | extension UIContextMenuConfiguration { 43 | typealias ItemAction = (UniqueIdentifier) -> Void 44 | 45 | static func configFor( 46 | itemId: UniqueIdentifier, 47 | favoriteAction: @escaping ItemAction, 48 | deleteAction: @escaping ItemAction) -> UIContextMenuConfiguration? { 49 | UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in 50 | let favorite = UIAction(title: "Favorite", 51 | image: UIImage(systemName: "star.fill")) { _ in 52 | favoriteAction(itemId) 53 | } 54 | 55 | let delete = UIAction(title: "Delete", 56 | image: UIImage(systemName: "trash"), 57 | attributes: .destructive) { _ in 58 | deleteAction(itemId) 59 | } 60 | 61 | return UIMenu(children: [favorite, delete]) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Example/Sources/PersonModel/PersonModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import ReactiveCollectionsKit 16 | 17 | struct PersonModel: Hashable { 18 | let name: String 19 | let birthdate: Date 20 | let nationality: String 21 | var isFavorite = false 22 | 23 | var birthDateText: String { 24 | self.birthdate.formatted(date: .long, time: .omitted) 25 | } 26 | 27 | var id: UniqueIdentifier { 28 | self.name 29 | } 30 | } 31 | 32 | extension Date { 33 | init(year: Int, month: Int, day: Int) { 34 | let components = DateComponents( 35 | calendar: .current, 36 | timeZone: .current, 37 | year: year, 38 | month: month, 39 | day: day 40 | ) 41 | self = components.date! 42 | } 43 | } 44 | 45 | extension PersonModel { 46 | static func makePeople() -> [PersonModel] { 47 | [ 48 | PersonModel(name: "Noam Chomsky", birthdate: Date(year: 1_928, month: 12, day: 7), nationality: "🇺🇸"), 49 | PersonModel(name: "Emma Goldman", birthdate: Date(year: 1_869, month: 6, day: 27), nationality: "🇷🇺"), 50 | PersonModel(name: "Mikhail Bakunin", birthdate: Date(year: 1_814, month: 5, day: 30), nationality: "🇷🇺"), 51 | PersonModel(name: "Ursula K. Le Guin", birthdate: Date(year: 1_929, month: 10, day: 21), nationality: "🇺🇸"), 52 | PersonModel(name: "Peter Kropotkin", birthdate: Date(year: 1_842, month: 12, day: 9), nationality: "🇷🇺"), 53 | PersonModel(name: "Marie Louise Berneri", birthdate: Date(year: 1_918, month: 3, day: 1), nationality: "🇮🇹") 54 | ] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Example/Sources/PersonModel/PersonViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import UIKit 15 | 16 | final class PersonViewController: UIViewController { 17 | 18 | @IBOutlet private weak var titleLabel: UILabel! 19 | @IBOutlet private weak var subtitleLabel: UILabel! 20 | @IBOutlet private weak var flagLabel: UILabel! 21 | 22 | let person: PersonModel 23 | 24 | init(person: PersonModel) { 25 | self.person = person 26 | super.init(nibName: "\(PersonViewController.self)", bundle: nil) 27 | self.title = "Person" 28 | } 29 | 30 | @available(*, unavailable) 31 | required init?(coder: NSCoder) { 32 | fatalError("init(coder:) has not been implemented") 33 | } 34 | 35 | override func viewDidLoad() { 36 | super.viewDidLoad() 37 | self.titleLabel.text = self.person.name 38 | self.subtitleLabel.text = self.person.birthDateText 39 | self.flagLabel.text = self.person.nationality 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Example/Sources/PersonModel/PersonViewController.xib: -------------------------------------------------------------------------------- 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 | 33 | 41 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /Example/Sources/SimpleStatic/SimpleStaticViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import ReactiveCollectionsKit 16 | import UIKit 17 | 18 | final class SimpleStaticViewController: UICollectionViewController { 19 | 20 | lazy var driver = CollectionViewDriver(view: self.collectionView) 21 | 22 | // MARK: Init 23 | 24 | init() { 25 | let layout = UICollectionViewCompositionalLayout.list( 26 | using: .init(appearance: .insetGrouped) 27 | ) 28 | super.init(collectionViewLayout: layout) 29 | } 30 | 31 | @available(*, unavailable) 32 | required init?(coder: NSCoder) { 33 | fatalError("init(coder:) has not been implemented") 34 | } 35 | 36 | // MARK: View lifecycle 37 | 38 | override func viewDidLoad() { 39 | super.viewDidLoad() 40 | self.collectionView.accessibilityIdentifier = "Simple Static" 41 | 42 | let models = ColorModel.makeColors() 43 | 44 | let cellViewModels = models.map { 45 | ColorCellViewModelList( 46 | color: $0, 47 | contextMenuConfiguration: nil 48 | ) 49 | } 50 | 51 | let section = SectionViewModel(id: "section", cells: cellViewModels) 52 | 53 | let collectionViewModel = CollectionViewModel(id: "static_view", sections: [section]) 54 | 55 | self.driver.update(viewModel: collectionViewModel) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Example/Sources/SupplementaryViews/FavoriteBadgeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import UIKit 16 | 17 | final class FavoriteBadgeView: UICollectionReusableView { 18 | let imageView = UIImageView() 19 | 20 | override init(frame: CGRect) { 21 | super.init(frame: frame) 22 | self.imageView.translatesAutoresizingMaskIntoConstraints = false 23 | self.addSubview(self.imageView) 24 | let inset = 4.0 25 | NSLayoutConstraint.activate([ 26 | self.imageView.topAnchor.constraint(equalTo: self.topAnchor, constant: inset), 27 | self.imageView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -inset), 28 | self.imageView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: inset), 29 | self.imageView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -inset) 30 | ]) 31 | self.imageView.image = UIImage(systemName: "star.fill") 32 | self.imageView.tintColor = .systemBackground 33 | self.backgroundColor = .systemYellow 34 | } 35 | 36 | override func layoutSubviews() { 37 | super.layoutSubviews() 38 | self.layer.cornerRadius = self.bounds.height / 2 39 | } 40 | 41 | @available(*, unavailable) 42 | required init?(coder: NSCoder) { 43 | fatalError("init(coder:) has not been implemented") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Example/Sources/SupplementaryViews/FavoriteBadgeViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import ReactiveCollectionsKit 16 | 17 | struct FavoriteBadgeViewModel: SupplementaryViewModel { 18 | static let kind = "favorite-badge-view" 19 | 20 | let isHidden: Bool 21 | 22 | // MARK: SupplementaryViewModel 23 | 24 | let id: UniqueIdentifier 25 | 26 | var registration: ViewRegistration { 27 | ViewRegistration( 28 | reuseIdentifier: self.reuseIdentifier, 29 | supplementaryViewClass: self.viewClass, 30 | kind: Self.kind 31 | ) 32 | } 33 | 34 | func configure(view: FavoriteBadgeView) { 35 | view.isHidden = self.isHidden 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Example/Sources/SupplementaryViews/FooterView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import ReactiveCollectionsKit 15 | import UIKit 16 | 17 | struct FooterViewModel: SupplementaryFooterViewModel { 18 | let text: String 19 | 20 | // MARK: SupplementaryViewModel 21 | 22 | var id: UniqueIdentifier { self.text } 23 | 24 | func configure(view: UICollectionViewListCell) { 25 | var config = UIListContentConfiguration.groupedFooter() 26 | config.text = self.text 27 | view.contentConfiguration = config 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Example/Sources/SupplementaryViews/HeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import ReactiveCollectionsKit 15 | import UIKit 16 | 17 | enum HeaderViewStyle: Hashable { 18 | case large 19 | case small 20 | } 21 | 22 | struct HeaderViewModel: SupplementaryHeaderViewModel { 23 | let title: String 24 | let style: HeaderViewStyle 25 | 26 | // MARK: SupplementaryViewModel 27 | 28 | var id: UniqueIdentifier { self.title } 29 | 30 | func configure(view: UICollectionViewListCell) { 31 | var config: UIListContentConfiguration 32 | switch self.style { 33 | case .large: 34 | config = .prominentInsetGroupedHeader() 35 | 36 | case .small: 37 | config = .groupedHeader() 38 | } 39 | config.text = self.title 40 | view.contentConfiguration = config 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Example/Tests/TestPlan.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "0E9265EF-7B03-4584-B369-B6A39E270B34", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "codeCoverage" : false, 13 | "defaultTestExecutionTimeAllowance" : 600, 14 | "maximumTestRepetitions" : 3, 15 | "repeatInNewRunnerProcess" : true, 16 | "targetForVariableExpansion" : { 17 | "containerPath" : "container:ExampleApp.xcodeproj", 18 | "identifier" : "0B31B93A2BCC62A4006F2078", 19 | "name" : "ExampleApp" 20 | }, 21 | "testExecutionOrdering" : "random", 22 | "testRepetitionMode" : "retryOnFailure", 23 | "testTimeoutsEnabled" : true 24 | }, 25 | "testTargets" : [ 26 | { 27 | "target" : { 28 | "containerPath" : "container:ExampleApp.xcodeproj", 29 | "identifier" : "0B31B9502BCC62A5006F2078", 30 | "name" : "ExampleAppTests" 31 | } 32 | }, 33 | { 34 | "target" : { 35 | "containerPath" : "container:ExampleApp.xcodeproj", 36 | "identifier" : "0B31B95A2BCC62A5006F2078", 37 | "name" : "ExampleAppUITests" 38 | } 39 | } 40 | ], 41 | "version" : 1 42 | } 43 | -------------------------------------------------------------------------------- /Example/Tests/UITests/GridUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import XCTest 15 | 16 | final class GridUITests: XCTestCase, @unchecked Sendable { 17 | @MainActor var app: XCUIApplication { XCUIApplication() } 18 | 19 | override func setUp() async throws { 20 | try await super.setUp() 21 | self.continueAfterFailure = false 22 | await self.app.launch() 23 | } 24 | 25 | @MainActor 26 | func test_grid_shuffle() { 27 | self.app.activate() 28 | 29 | let shuffleButton = self.app.navigationBars["Grid"].buttons["repeat"] 30 | 31 | for _ in 1...20 { 32 | shuffleButton.tap() 33 | } 34 | } 35 | 36 | @MainActor 37 | func test_grid_remove_reset() { 38 | self.app.activate() 39 | 40 | let shuffleButton = self.app.navigationBars["Grid"].buttons["repeat"] 41 | shuffleButton.tap() 42 | 43 | let resetButton = self.app.navigationBars["Grid"].buttons["Refresh"] 44 | resetButton.tap() 45 | 46 | let collectionViewsQuery = self.app.collectionViews 47 | collectionViewsQuery.buttons["Remove All"].tap() 48 | 49 | resetButton.tap() 50 | collectionViewsQuery.buttons["Reset"].tap() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Example/Tests/UITests/ListUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import XCTest 15 | 16 | final class ListUITests: XCTestCase, @unchecked Sendable { 17 | @MainActor var app: XCUIApplication { XCUIApplication() } 18 | 19 | override func setUp() async throws { 20 | try await super.setUp() 21 | self.continueAfterFailure = false 22 | await self.app.launch() 23 | } 24 | 25 | @MainActor 26 | func test_list_shuffle() { 27 | self.app.activate() 28 | 29 | self.app.tabBars["Tab Bar"].buttons["List"].tap() 30 | 31 | let shuffleButton = self.app.navigationBars["List"].buttons["repeat"] 32 | 33 | for _ in 1...20 { 34 | shuffleButton.tap() 35 | } 36 | } 37 | 38 | @MainActor 39 | func test_list_remove_reset() { 40 | self.app.activate() 41 | 42 | self.app.tabBars["Tab Bar"].buttons["List"].tap() 43 | 44 | let shuffleButton = self.app.navigationBars["List"].buttons["repeat"] 45 | shuffleButton.tap() 46 | 47 | let resetButton = self.app.navigationBars["List"].buttons["Refresh"] 48 | resetButton.tap() 49 | 50 | let collectionViewsQuery = self.app.collectionViews 51 | collectionViewsQuery.buttons["Remove All"].tap() 52 | 53 | resetButton.tap() 54 | collectionViewsQuery.buttons["Reset"].tap() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Example/Tests/UITests/StaticViewUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import XCTest 15 | 16 | final class StaticViewUITests: XCTestCase, @unchecked Sendable { 17 | @MainActor var app: XCUIApplication { XCUIApplication() } 18 | 19 | override func setUp() async throws { 20 | try await super.setUp() 21 | self.continueAfterFailure = false 22 | await self.app.launch() 23 | } 24 | 25 | @MainActor 26 | func test_view_other_tabs() { 27 | self.app.activate() 28 | XCTAssertTrue(true) 29 | 30 | self.app.tabBars["Tab Bar"].buttons["Simple Static"].tap() 31 | self.app.collectionViews["Simple Static"].swipeUp() 32 | self.app.collectionViews["Simple Static"].swipeDown() 33 | 34 | self.app.tabBars["Tab Bar"].buttons["Flow Layout"].tap() 35 | self.app.collectionViews["Flow Layout"].swipeUp() 36 | self.app.collectionViews["Flow Layout"].swipeDown() 37 | 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Example/Tests/UnitTests/ExampleModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | @testable import ExampleApp 15 | import XCTest 16 | 17 | final class ExampleModelTests: XCTestCase { 18 | 19 | func test_shuffle() { 20 | var model = Model() 21 | XCTAssertFalse(model.people.isEmpty) 22 | XCTAssertFalse(model.colors.isEmpty) 23 | 24 | let peopleCount = model.people.count 25 | let colorsCount = model.colors.count 26 | 27 | model.shuffle() 28 | XCTAssertEqual(model.people.count, peopleCount) 29 | XCTAssertEqual(model.colors.count, colorsCount) 30 | } 31 | 32 | func test_delete() { 33 | let personId: AnyHashable = "Noam Chomsky" 34 | let colorId: AnyHashable = "purple" 35 | 36 | var model = Model() 37 | XCTAssertTrue(model.people.contains(where: { $0.id == personId })) 38 | XCTAssertTrue(model.colors.contains(where: { $0.id == colorId })) 39 | 40 | model.delete(id: personId) 41 | XCTAssertFalse(model.people.contains(where: { $0.id == personId })) 42 | 43 | model.delete(id: colorId) 44 | XCTAssertFalse(model.colors.contains(where: { $0.id == colorId })) 45 | } 46 | 47 | func test_favorite() { 48 | let personId: AnyHashable = "Noam Chomsky" 49 | let colorId: AnyHashable = "purple" 50 | 51 | var model = Model() 52 | XCTAssertFalse(model.people.first(where: { $0.id == personId })!.isFavorite) 53 | XCTAssertFalse(model.colors.first(where: { $0.id == colorId })!.isFavorite) 54 | 55 | model.toggleFavorite(id: personId) 56 | XCTAssertTrue(model.people.first(where: { $0.id == personId })!.isFavorite) 57 | 58 | model.toggleFavorite(id: colorId) 59 | XCTAssertTrue(model.colors.first(where: { $0.id == colorId })!.isFavorite) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-present Jesse Squires 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | // The swift-tools-version declares the minimum version 3 | // of Swift required to build this package. 4 | // ---------------------------------------------------- 5 | // 6 | // Created by Jesse Squires 7 | // https://www.jessesquires.com 8 | // 9 | // Documentation 10 | // https://jessesquires.github.io/ReactiveCollectionsKit 11 | // 12 | // GitHub 13 | // https://github.com/jessesquires/ReactiveCollectionsKit 14 | // 15 | // Copyright © 2019-present Jesse Squires 16 | // 17 | 18 | import PackageDescription 19 | 20 | let package = Package( 21 | name: "ReactiveCollectionsKit", 22 | platforms: [ 23 | .iOS(.v15) 24 | ], 25 | products: [ 26 | .library( 27 | name: "ReactiveCollectionsKit", 28 | targets: ["ReactiveCollectionsKit"] 29 | ) 30 | ], 31 | dependencies: [], 32 | targets: [ 33 | .target(name: "ReactiveCollectionsKit", path: "Sources"), 34 | .testTarget( 35 | name: "ReactiveCollectionsKitTests", 36 | dependencies: ["ReactiveCollectionsKit"], 37 | path: "Tests", 38 | resources: [ 39 | .process("Fakes/FakeCellNib.xib"), 40 | .process("Fakes/FakeSupplementaryNib.xib") 41 | ] 42 | ) 43 | ], 44 | swiftLanguageModes: [.v5] 45 | ) 46 | 47 | #warning("Remove after Swift 6 language mode") 48 | let swiftSettings = [ 49 | SwiftSetting.enableExperimentalFeature("StrictConcurrency") 50 | ] 51 | 52 | for target in package.targets { 53 | var settings = target.swiftSettings ?? [] 54 | settings.append(contentsOf: swiftSettings) 55 | target.swiftSettings = settings 56 | } 57 | -------------------------------------------------------------------------------- /ReactiveCollectionsKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ReactiveCollectionsKit.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ReactiveCollectionsKit.xcodeproj/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FILEHEADER 6 | 7 | // Created by Jesse Squires 8 | // https://www.jessesquires.com 9 | // 10 | // Documentation 11 | // https://jessesquires.github.io/ReactiveCollectionsKit 12 | // 13 | // GitHub 14 | // https://github.com/jessesquires/ReactiveCollectionsKit 15 | // 16 | // Copyright © 2019-present Jesse Squires 17 | // 18 | 19 | 20 | -------------------------------------------------------------------------------- /ReactiveCollectionsKit.xcodeproj/xcshareddata/xcschemes/ReactiveCollectionsKit.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 35 | 36 | 37 | 38 | 41 | 47 | 48 | 49 | 50 | 51 | 61 | 62 | 68 | 69 | 75 | 76 | 77 | 78 | 80 | 81 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /Sources/CellEventCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import UIKit 16 | 17 | /// Conforming objects are responsible for handling various cell events. 18 | @MainActor 19 | public protocol CellEventCoordinator: AnyObject { 20 | 21 | /// Called when a cell is selected. 22 | /// - Parameter viewModel: The cell view model that corresponds to the cell. 23 | func didSelectCell(viewModel: any CellViewModel) 24 | 25 | /// Called when a cell is deselected. 26 | /// - Parameter viewModel: The cell view model that corresponds to the cell. 27 | func didDeselectCell(viewModel: any CellViewModel) 28 | 29 | /// Returns the underlying view controller that owns the collection view for the cell. 30 | /// 31 | /// You may use this to optionally handle navigation within your cell view model. 32 | var underlyingViewController: UIViewController? { get } 33 | } 34 | 35 | extension CellEventCoordinator { 36 | 37 | /// Default implementation. Does nothing. 38 | public func didSelectCell(viewModel: any CellViewModel) { } 39 | 40 | /// Default implementation. Does nothing. 41 | public func didDeselectCell(viewModel: any CellViewModel) { } 42 | 43 | /// Default implementation. Returns `nil`. 44 | public var underlyingViewController: UIViewController? { nil } 45 | } 46 | 47 | extension CellEventCoordinator where Self: UIViewController { 48 | 49 | /// Default implementation if the conformer is a `UIViewController`. 50 | /// Returns `self`. 51 | public var underlyingViewController: UIViewController? { self } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Collection+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | 16 | extension Collection { 17 | var isNotEmpty: Bool { 18 | !self.isEmpty 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/CollectionViewConstants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import UIKit 16 | 17 | /// HACK: unfortunately, these `UICollectionView` constants are marked as `@MainActor`. 18 | /// 19 | /// They do not need to be `@MainActor` and do no introduce any race conditions. 20 | /// So, we're using the raw string values instead. 21 | enum CollectionViewConstants { 22 | /// The same value as `UICollectionView.elementKindSectionHeader`. 23 | static var headerKind: SupplementaryViewKind { 24 | "UICollectionElementKindSectionHeader" 25 | } 26 | 27 | /// The same value as `UICollectionView.elementKindSectionFooter`. 28 | static var footerKind: SupplementaryViewKind { 29 | "UICollectionElementKindSectionFooter" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/CollectionViewDriverOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | 16 | /// Defines various options to customize behavior of a ``CollectionViewDriver``. 17 | public struct CollectionViewDriverOptions: Hashable { 18 | /// Specifies whether or not to perform diffing on a background queue. 19 | /// Pass `true` to perform diffing in the background, 20 | /// pass `false` to perform diffing on the main thread. 21 | public let diffOnBackgroundQueue: Bool 22 | 23 | /// Specifies whether or not the ``CollectionViewDriver`` should 24 | /// perform a hard `reloadData()` when replacing the ``CollectionViewModel`` with 25 | /// a new one, or if it should always perform a diff. 26 | /// 27 | /// A replacement occurs when providing a new ``CollectionViewModel`` to the 28 | /// ``CollectionViewDriver`` that has a **different** `id` than the previous model. 29 | public let reloadDataOnReplacingViewModel: Bool 30 | 31 | /// Initializes a `CollectionViewDriverOptions` object. 32 | /// 33 | /// - Parameters: 34 | /// - diffOnBackgroundQueue: Whether or not to perform diffing on a background queue. Default is `false`. 35 | /// - reloadDataOnReplacingViewModel: Whether or not to reload or diff during replacement. Default is `false`. 36 | public init( 37 | diffOnBackgroundQueue: Bool = false, 38 | reloadDataOnReplacingViewModel: Bool = false 39 | ) { 40 | self.diffOnBackgroundQueue = diffOnBackgroundQueue 41 | self.reloadDataOnReplacingViewModel = reloadDataOnReplacingViewModel 42 | } 43 | } 44 | 45 | extension CollectionViewDriverOptions: CustomDebugStringConvertible { 46 | public var debugDescription: String { 47 | driverOptionsDebugDescription(self) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/DiffableSnapshot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import UIKit 16 | 17 | typealias DiffableSnapshot = NSDiffableDataSourceSnapshot 18 | 19 | extension DiffableSnapshot { 20 | init(viewModel: CollectionViewModel) { 21 | self.init() 22 | 23 | let allSectionIdentifiers = viewModel.sections.map(\.id) 24 | self.appendSections(allSectionIdentifiers) 25 | 26 | viewModel.sections.forEach { 27 | let allCellIdentifiers = $0.cells.map(\.id) 28 | self.appendItems(allCellIdentifiers, toSection: $0.id) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/DiffableViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | 16 | /// The unique identifier type for a `DiffableViewModel`. 17 | public typealias UniqueIdentifier = AnyHashable 18 | 19 | /// Describes a view model that is uniquely identifiable and diffable. 20 | public protocol DiffableViewModel: Identifiable, Hashable, Sendable { 21 | /// An identifier that uniquely identifies this instance. 22 | var id: UniqueIdentifier { get } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/EmptyViewProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import UIKit 16 | 17 | /// Provides an "empty state" or "no content" view for the collection view. 18 | @MainActor 19 | public struct EmptyViewProvider { 20 | /// A closure that returns the view. 21 | public let viewBuilder: () -> UIView 22 | 23 | /// The empty view. 24 | public var view: UIView { 25 | viewBuilder() 26 | } 27 | 28 | /// Initializes an `EmptyViewProvider` with the given closure. 29 | /// 30 | /// - Parameter viewBuilder: A closure that creates and returns the empty view. 31 | public init(_ viewBuilder: @escaping () -> UIView) { 32 | self.viewBuilder = viewBuilder 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import OSLog 16 | 17 | /// Describes a type that logs messages for the library. 18 | public protocol Logging: Sendable { 19 | 20 | /// Logs the provided message. 21 | /// 22 | /// - Parameter message: The message to log. 23 | func log(_ message: @escaping @autoclosure () -> String) 24 | } 25 | 26 | /// A default ``Logging`` implementation to log debug messages. 27 | /// 28 | /// You can set this logger for the ``CollectionViewDriver.logger``. 29 | public final class RCKLogger: Logging { 30 | /// The shared logger instance. 31 | public static let shared = RCKLogger() 32 | 33 | private let _logger = Logger(subsystem: "com.jessesquires.ReactiveCollectionsKit", category: "") 34 | 35 | /// :nodoc: 36 | public func log(_ message: @escaping @autoclosure () -> String) { 37 | self._logger.debug("\(message())") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/SupplementaryFooterViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import UIKit 16 | 17 | /// Defines a view model that describes and configures a footer view 18 | /// for a section in the collection view. 19 | public protocol SupplementaryFooterViewModel: SupplementaryViewModel { 20 | /// The collection view footer element kind. 21 | static var kind: SupplementaryViewKind { get } 22 | } 23 | 24 | extension SupplementaryFooterViewModel { 25 | /// Default implementation. Returns a section footer kind. 26 | public static var kind: SupplementaryViewKind { 27 | CollectionViewConstants.footerKind 28 | } 29 | 30 | /// A default registration for this footer view model for class-based views. 31 | /// 32 | /// - Warning: Does not work for nib-based views. 33 | public var registration: ViewRegistration { 34 | ViewRegistration( 35 | reuseIdentifier: self.reuseIdentifier, 36 | supplementaryViewClass: self.viewClass, 37 | kind: Self.kind 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SupplementaryHeaderViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import UIKit 16 | 17 | /// Defines a view model that describes and configures a header view 18 | /// for a section in the collection view. 19 | public protocol SupplementaryHeaderViewModel: SupplementaryViewModel { 20 | /// The collection view header element kind. 21 | static var kind: SupplementaryViewKind { get } 22 | } 23 | 24 | extension SupplementaryHeaderViewModel { 25 | /// Default implementation. Returns a section header kind. 26 | public static var kind: SupplementaryViewKind { 27 | CollectionViewConstants.headerKind 28 | } 29 | 30 | /// A default registration for this header view model for class-based views. 31 | /// 32 | /// - Warning: Does not work for nib-based views. 33 | public var registration: ViewRegistration { 34 | ViewRegistration( 35 | reuseIdentifier: self.reuseIdentifier, 36 | supplementaryViewClass: self.viewClass, 37 | kind: Self.kind 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SupplementaryViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import UIKit 16 | 17 | /// Describes a kind of supplementary view. 18 | public typealias SupplementaryViewKind = String 19 | 20 | /// Defines a view model that describes and configures a supplementary view in a collection. 21 | public protocol SupplementaryViewModel: DiffableViewModel, ViewRegistrationProvider { 22 | /// The type of view that this view model represents and configures. 23 | associatedtype ViewType: UICollectionReusableView 24 | 25 | /// Configures the provided view for display in the collection. 26 | /// - Parameter view: The view to configure. 27 | @MainActor 28 | func configure(view: ViewType) 29 | 30 | /// Tells the view model that its supplementary view is about to be displayed in the collection view. 31 | /// This corresponds to the delegate method `collectionView(_:willDisplaySupplementaryView:forElementKind:at:)`. 32 | @MainActor 33 | func willDisplay() 34 | 35 | /// Tells the view model that its supplementary view was removed from the collection view. 36 | /// This corresponds to the delegate method `collectionView(_:didEndDisplayingSupplementaryView:forElementOfKind:at:)`. 37 | @MainActor 38 | func didEndDisplaying() 39 | } 40 | 41 | extension SupplementaryViewModel { 42 | /// Default implementation. Does nothing. 43 | @MainActor 44 | public func willDisplay() { } 45 | 46 | /// Default implementation. Does nothing. 47 | @MainActor 48 | public func didEndDisplaying() { } 49 | 50 | // MARK: Internal 51 | 52 | @MainActor 53 | func _configureGeneric(view: UICollectionReusableView) { 54 | precondition(view is ViewType, "View must be of type \(ViewType.self). Found \(view.self)") 55 | self.configure(view: view as! ViewType) 56 | } 57 | } 58 | 59 | extension SupplementaryViewModel { 60 | /// The view class for this view model. 61 | public var viewClass: AnyClass { ViewType.self } 62 | 63 | /// A default reuse identifier for cell registration. 64 | /// Returns the name of the class implementing the `CellViewModel` protocol. 65 | public var reuseIdentifier: String { "\(Self.self)" } 66 | 67 | /// Returns a type-erased version of this view model. 68 | public func eraseToAnyViewModel() -> AnySupplementaryViewModel { 69 | if let erasedViewModel = self as? AnySupplementaryViewModel { 70 | return erasedViewModel 71 | } 72 | return AnySupplementaryViewModel(self) 73 | } 74 | 75 | // MARK: Internal 76 | 77 | var _kind: SupplementaryViewKind { 78 | precondition( 79 | self.registration.viewType.isSupplementary, 80 | "Inconsistency error. Expected supplementary view registration" 81 | ) 82 | return self.registration.viewType.kind 83 | } 84 | 85 | @MainActor 86 | func dequeueAndConfigureViewFor(collectionView: UICollectionView, at indexPath: IndexPath) -> ViewType { 87 | let view = self.registration.dequeueViewFor(collectionView: collectionView, at: indexPath) as! ViewType 88 | self.configure(view: view) 89 | return view 90 | } 91 | } 92 | 93 | /// A type-erased supplementary view model. 94 | /// 95 | /// - Note: When providing supplementary views with mixed data types to a `SectionViewModel`, 96 | /// it is necessary to convert them to `AnySupplementaryViewModel`. 97 | public struct AnySupplementaryViewModel: SupplementaryViewModel { 98 | // MARK: DiffableViewModel 99 | 100 | /// :nodoc: 101 | public var id: UniqueIdentifier { self._id } 102 | 103 | // MARK: ViewRegistrationProvider 104 | 105 | /// :nodoc: 106 | public var registration: ViewRegistration { self._registration } 107 | 108 | // MARK: SupplementaryViewModel 109 | 110 | /// :nodoc: 111 | public typealias ViewType = UICollectionReusableView 112 | 113 | /// :nodoc: 114 | public func configure(view: ViewType) { 115 | self._configure(view) 116 | } 117 | 118 | /// :nodoc: 119 | public func willDisplay() { 120 | self._willDisplay() 121 | } 122 | 123 | /// :nodoc: 124 | public func didEndDisplaying() { 125 | self._didEndDisplaying() 126 | } 127 | 128 | /// :nodoc: "override" the extension 129 | public let viewClass: AnyClass 130 | 131 | /// :nodoc: "override" the extension 132 | public let reuseIdentifier: String 133 | 134 | // MARK: Internal 135 | 136 | var isHeader: Bool { 137 | self._registration.viewType.isHeader 138 | } 139 | 140 | var isFooter: Bool { 141 | self._registration.viewType.isFooter 142 | } 143 | 144 | var isOtherSupplementaryView: Bool { 145 | !self.isHeader && !self.isFooter 146 | } 147 | 148 | // MARK: Private 149 | 150 | private let _viewModel: AnyHashable 151 | private let _id: UniqueIdentifier 152 | private let _registration: ViewRegistration 153 | private let _configure: @Sendable @MainActor (ViewType) -> Void 154 | private let _willDisplay: @Sendable @MainActor () -> Void 155 | private let _didEndDisplaying: @Sendable @MainActor () -> Void 156 | 157 | // MARK: Init 158 | 159 | /// Initializes an `AnySupplementaryViewModel` from the provided supplementary view model. 160 | /// 161 | /// - Parameter viewModel: The view model to type-erase. 162 | public init(_ viewModel: T) { 163 | // prevent "double" / "nested" erasure 164 | if let erasedViewModel = viewModel as? Self { 165 | self = erasedViewModel 166 | return 167 | } 168 | self._viewModel = viewModel 169 | self._id = viewModel.id 170 | self._registration = viewModel.registration 171 | self._configure = { 172 | viewModel._configureGeneric(view: $0) 173 | } 174 | self._willDisplay = { 175 | viewModel.willDisplay() 176 | } 177 | self._didEndDisplaying = { 178 | viewModel.didEndDisplaying() 179 | } 180 | self.viewClass = viewModel.viewClass 181 | self.reuseIdentifier = viewModel.reuseIdentifier 182 | } 183 | } 184 | 185 | extension AnySupplementaryViewModel: Equatable { 186 | /// :nodoc: 187 | public static func == (left: AnySupplementaryViewModel, right: AnySupplementaryViewModel) -> Bool { 188 | left._viewModel == right._viewModel 189 | } 190 | } 191 | 192 | extension AnySupplementaryViewModel: Hashable { 193 | /// :nodoc: 194 | public func hash(into hasher: inout Hasher) { 195 | self._viewModel.hash(into: &hasher) 196 | } 197 | } 198 | 199 | extension AnySupplementaryViewModel: CustomDebugStringConvertible { 200 | /// :nodoc: 201 | public var debugDescription: String { 202 | "\(self._viewModel)" 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /Sources/UICollectionView+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import UIKit 16 | 17 | extension UICollectionView { 18 | /// Deselects all items that are selected. 19 | /// - Parameter animated: Whether or not to animate the deselection. 20 | public func deselectAllItems(animated: Bool = true) { 21 | self.indexPathsForSelectedItems?.forEach { 22 | self.deselectItem(at: $0, animated: animated) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/ViewRegistration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import UIKit 16 | 17 | /// Describes all information needed to register a view for reuse with a `UICollectionView`. 18 | public struct ViewRegistration: Hashable, Sendable { 19 | /// The view reuse identifier. 20 | public let reuseIdentifier: String 21 | 22 | /// The type of view to register. 23 | public let viewType: ViewRegistrationViewType 24 | 25 | /// The view registration method. 26 | public let method: ViewRegistrationMethod 27 | 28 | /// Initializes a `ViewRegistration`. 29 | /// 30 | /// - Parameters: 31 | /// - reuseIdentifier: The view reuse identifier. 32 | /// - viewType: The type of view to register. 33 | /// - method: The view registration method. 34 | public init( 35 | reuseIdentifier: String, 36 | viewType: ViewRegistrationViewType, 37 | method: ViewRegistrationMethod 38 | ) { 39 | self.reuseIdentifier = reuseIdentifier 40 | self.viewType = viewType 41 | self.method = method 42 | } 43 | 44 | // MARK: Dequeuing Views 45 | 46 | @MainActor 47 | func dequeueViewFor(collectionView: UICollectionView, at indexPath: IndexPath) -> UICollectionReusableView { 48 | switch self.viewType { 49 | case .cell: 50 | return self._dequeueCellFor(collectionView: collectionView, at: indexPath) 51 | 52 | case .supplementary(let kind): 53 | return self._dequeueSupplementaryViewFor(kind: kind, collectionView: collectionView, at: indexPath) 54 | } 55 | } 56 | 57 | @MainActor 58 | private func _dequeueCellFor(collectionView: UICollectionView, at indexPath: IndexPath) -> UICollectionViewCell { 59 | collectionView.dequeueReusableCell(withReuseIdentifier: self.reuseIdentifier, for: indexPath) 60 | } 61 | 62 | @MainActor 63 | private func _dequeueSupplementaryViewFor(kind: String, collectionView: UICollectionView, at indexPath: IndexPath) -> UICollectionReusableView { 64 | collectionView.dequeueReusableSupplementaryView( 65 | ofKind: kind, 66 | withReuseIdentifier: self.reuseIdentifier, 67 | for: indexPath 68 | ) 69 | } 70 | 71 | // MARK: Registering Views 72 | 73 | @MainActor 74 | func registerWith(collectionView: UICollectionView) { 75 | switch self.viewType { 76 | case .cell: 77 | self._registerCellWith(collectionView: collectionView) 78 | 79 | case .supplementary(let kind): 80 | self._registerSupplementaryView(kind: kind, with: collectionView) 81 | } 82 | } 83 | 84 | @MainActor 85 | private func _registerCellWith(collectionView: UICollectionView) { 86 | switch self.method { 87 | case .viewClass(let anyClass): 88 | collectionView.register(anyClass, forCellWithReuseIdentifier: self.reuseIdentifier) 89 | 90 | case .nib(let name, let bundle): 91 | let nib = UINib(nibName: name, bundle: bundle) 92 | collectionView.register(nib, forCellWithReuseIdentifier: self.reuseIdentifier) 93 | } 94 | } 95 | 96 | @MainActor 97 | private func _registerSupplementaryView(kind: String, with collectionView: UICollectionView) { 98 | switch self.method { 99 | case .viewClass(let anyClass): 100 | collectionView.register( 101 | anyClass, 102 | forSupplementaryViewOfKind: kind, 103 | withReuseIdentifier: self.reuseIdentifier 104 | ) 105 | 106 | case .nib(let name, let bundle): 107 | let nib = UINib(nibName: name, bundle: bundle) 108 | collectionView.register( 109 | nib, 110 | forSupplementaryViewOfKind: kind, 111 | withReuseIdentifier: self.reuseIdentifier 112 | ) 113 | } 114 | } 115 | } 116 | 117 | /// Initializes a `ViewRegistration`. 118 | /// 119 | /// - Parameters: 120 | /// - reuseIdentifier: The view reuse identifier. 121 | /// - viewType: The type of view to register. 122 | /// - method: The view registration method. 123 | extension ViewRegistration { 124 | 125 | /// Convenience initializer for a class-based cell. 126 | /// 127 | /// - Parameters: 128 | /// - reuseIdentifier: The cell reuse identifier. 129 | /// - cellClass: The cell class. 130 | public init(reuseIdentifier: String, cellClass: AnyClass) { 131 | self.init( 132 | reuseIdentifier: reuseIdentifier, 133 | viewType: .cell, 134 | method: .viewClass(cellClass) 135 | ) 136 | } 137 | 138 | /// Convenience initializer for a nib-based cell in the main bundle. 139 | /// 140 | /// - Parameters: 141 | /// - reuseIdentifier: The cell reuse identifier. 142 | /// - cellNibName: The nib name for the cell. 143 | public init(reuseIdentifier: String, cellNibName: String) { 144 | self.init( 145 | reuseIdentifier: reuseIdentifier, 146 | viewType: .cell, 147 | method: .nib(name: cellNibName, bundle: nil) 148 | ) 149 | } 150 | 151 | /// Convenience initializer for a class-based supplementary view. 152 | /// 153 | /// - Parameters: 154 | /// - reuseIdentifier: The view reuse identifier. 155 | /// - supplementaryViewClass: The view class. 156 | /// - kind: The supplementary view kind. 157 | public init(reuseIdentifier: String, supplementaryViewClass: AnyClass, kind: String) { 158 | self.init( 159 | reuseIdentifier: reuseIdentifier, 160 | viewType: .supplementary(kind: kind), 161 | method: .viewClass(supplementaryViewClass) 162 | ) 163 | } 164 | 165 | /// Convenience initializer for a nib-based supplementary view in the main bundle. 166 | /// 167 | /// - Parameters: 168 | /// - reuseIdentifier: The view reuse identifier. 169 | /// - supplementaryViewNibName: The nib name for the view. 170 | /// - kind: The supplementary view kind. 171 | public init(reuseIdentifier: String, supplementaryViewNibName: String, kind: String) { 172 | self.init( 173 | reuseIdentifier: reuseIdentifier, 174 | viewType: .supplementary(kind: kind), 175 | method: .nib(name: supplementaryViewNibName, bundle: nil) 176 | ) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /Sources/ViewRegistrationMethod.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | 16 | /// The method by which to register a view for reuse in a `UICollectionView`. 17 | public enum ViewRegistrationMethod: Hashable, Sendable { 18 | /// Registration for a class-based view. 19 | case viewClass(AnyClass) 20 | 21 | /// Registration for a nib-based view. 22 | case nib(name: String, bundle: Bundle?) 23 | 24 | // MARK: Internal 25 | 26 | var _viewClassName: String? { 27 | switch self { 28 | case .viewClass(let anyClass): "\(anyClass)" 29 | case .nib: nil 30 | } 31 | } 32 | 33 | var _nibName: String? { 34 | switch self { 35 | case .viewClass: nil 36 | case .nib(let name, _): name 37 | } 38 | } 39 | 40 | var _nibBundle: Bundle? { 41 | switch self { 42 | case .viewClass: nil 43 | case .nib(_, let bundle): bundle 44 | } 45 | } 46 | 47 | // MARK: Equatable 48 | 49 | /// :nodoc: 50 | public static func == (left: Self, right: Self) -> Bool { 51 | left._viewClassName == right._viewClassName 52 | && left._nibName == right._nibName 53 | && left._nibBundle == right._nibBundle 54 | } 55 | 56 | // MARK: Hashable 57 | 58 | /// :nodoc: 59 | public func hash(into hasher: inout Hasher) { 60 | hasher.combine(self._viewClassName) 61 | hasher.combine(self._nibName) 62 | hasher.combine(self._nibBundle) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/ViewRegistrationProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import UIKit 16 | 17 | /// Provides registration information for a reusable view in a `UICollectionView`. 18 | public protocol ViewRegistrationProvider { 19 | /// The view registration information. 20 | var registration: ViewRegistration { get } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ViewRegistrationViewType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | 16 | /// Describes the type of view to be registered for reuse. 17 | public enum ViewRegistrationViewType: Hashable, Sendable { 18 | /// Describes a cell. 19 | case cell 20 | 21 | /// Describes a supplementary view. 22 | case supplementary(kind: String) 23 | 24 | // MARK: Internal 25 | 26 | var kind: String { 27 | switch self { 28 | case .cell: "cell" 29 | case .supplementary(let kind): kind 30 | } 31 | } 32 | 33 | var isCell: Bool { 34 | switch self { 35 | case .cell: true 36 | case .supplementary: false 37 | } 38 | } 39 | 40 | var isSupplementary: Bool { 41 | switch self { 42 | case .cell: false 43 | case .supplementary: true 44 | } 45 | } 46 | 47 | var isHeader: Bool { 48 | self.kind == CollectionViewConstants.headerKind 49 | } 50 | 51 | var isFooter: Bool { 52 | self.kind == CollectionViewConstants.footerKind 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeCellEventCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import ReactiveCollectionsKit 16 | import UIKit 17 | import XCTest 18 | 19 | final class FakeCellEventCoordinator: CellEventCoordinator { 20 | var selectedCell: (any CellViewModel)? 21 | var expectationDidSelect: XCTestExpectation? 22 | func didSelectCell(viewModel: any CellViewModel) { 23 | self.selectedCell = viewModel 24 | self.expectationDidSelect?.fulfillAndLog() 25 | } 26 | 27 | var deselectedCell: (any CellViewModel)? 28 | var expectationDidDeselect: XCTestExpectation? 29 | func didDeselectCell(viewModel: any CellViewModel) { 30 | self.deselectedCell = viewModel 31 | self.expectationDidDeselect?.fulfillAndLog() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeCellNib.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeCellNibView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import UIKit 17 | import XCTest 18 | 19 | final class FakeCellNibView: UICollectionViewCell { 20 | @IBOutlet var label: UILabel! 21 | } 22 | 23 | struct FakeCellNibViewModel: CellViewModel { 24 | let id: UniqueIdentifier 25 | 26 | var registration: ViewRegistration { 27 | ViewRegistration( 28 | reuseIdentifier: self.reuseIdentifier, 29 | viewType: .cell, 30 | method: .nib( 31 | name: "FakeCellNib", 32 | bundle: .testBundle 33 | ) 34 | ) 35 | } 36 | 37 | var expectationConfigureCell: XCTestExpectation? 38 | func configure(cell: FakeCellNibView) { 39 | self.expectationConfigureCell?.fulfillAndLog() 40 | } 41 | 42 | var expectationDidSelect: XCTestExpectation? 43 | func didSelect(with coordinator: (any CellEventCoordinator)?) { 44 | self.expectationDidSelect?.fulfillAndLog() 45 | } 46 | 47 | var expectationDidDeselect: XCTestExpectation? 48 | func didDeelect(with coordinator: (any CellEventCoordinator)?) { 49 | self.expectationDidDeselect?.fulfillAndLog() 50 | } 51 | 52 | var expectationWillDisplay: XCTestExpectation? 53 | func willDisplay() { 54 | self.expectationWillDisplay?.fulfillAndLog() 55 | } 56 | 57 | var expectationDidEndDisplaying: XCTestExpectation? 58 | func didEndDisplaying() { 59 | self.expectationDidEndDisplaying?.fulfillAndLog() 60 | } 61 | 62 | var expectationDidHighlight: XCTestExpectation? 63 | func didHighlight() { 64 | self.expectationDidHighlight?.fulfillAndLog() 65 | } 66 | 67 | var expectationDidUnhighlight: XCTestExpectation? 68 | func didUnhighlight() { 69 | self.expectationDidUnhighlight?.fulfillAndLog() 70 | } 71 | 72 | static func == (left: Self, right: Self) -> Bool { 73 | left.id == right.id 74 | } 75 | 76 | func hash(into hasher: inout Hasher) { 77 | hasher.combine(self.id) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeCellViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | struct FakeCellViewModel: CellViewModel { 19 | let id: UniqueIdentifier = String.random 20 | 21 | func configure(cell: FakeCollectionCell) { } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeCollectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import UIKit 16 | import XCTest 17 | 18 | final class FakeCollectionCell: UICollectionViewCell { } 19 | 20 | final class FakeCollectionLayout: UICollectionViewFlowLayout { } 21 | 22 | final class FakeCollectionView: UICollectionView { 23 | 24 | var dequeueCellExpectation: XCTestExpectation? 25 | override func dequeueReusableCell(withReuseIdentifier identifier: String, 26 | for indexPath: IndexPath) -> UICollectionViewCell { 27 | self.dequeueCellExpectation?.fulfillAndLog() 28 | return super.dequeueReusableCell(withReuseIdentifier: identifier, for: indexPath) 29 | } 30 | 31 | var registerCellClassExpectation: XCTestExpectation? 32 | override func register(_ cellClass: AnyClass?, forCellWithReuseIdentifier identifier: String) { 33 | self.registerCellClassExpectation?.fulfillAndLog() 34 | super.register(cellClass, forCellWithReuseIdentifier: identifier) 35 | } 36 | 37 | var registerCellNibExpectation: XCTestExpectation? 38 | override func register(_ nib: UINib?, forCellWithReuseIdentifier identifier: String) { 39 | self.registerCellNibExpectation?.fulfillAndLog() 40 | super.register(nib, forCellWithReuseIdentifier: identifier) 41 | } 42 | 43 | var dequeueSupplementaryViewExpectation: XCTestExpectation? 44 | override func dequeueReusableSupplementaryView(ofKind elementKind: String, 45 | withReuseIdentifier identifier: String, 46 | for indexPath: IndexPath) -> UICollectionReusableView { 47 | self.dequeueSupplementaryViewExpectation?.fulfillAndLog() 48 | return super.dequeueReusableSupplementaryView(ofKind: elementKind, 49 | withReuseIdentifier: identifier, 50 | for: indexPath) 51 | } 52 | 53 | var registerSupplementaryViewExpectation: XCTestExpectation? 54 | override func register(_ viewClass: AnyClass?, 55 | forSupplementaryViewOfKind elementKind: String, 56 | withReuseIdentifier identifier: String) { 57 | self.registerSupplementaryViewExpectation?.fulfillAndLog() 58 | super.register(viewClass, 59 | forSupplementaryViewOfKind: elementKind, 60 | withReuseIdentifier: identifier) 61 | } 62 | 63 | var registerSupplementaryNibExpectation: XCTestExpectation? 64 | override func register(_ nib: UINib?, 65 | forSupplementaryViewOfKind kind: String, 66 | withReuseIdentifier identifier: String) { 67 | self.registerSupplementaryNibExpectation?.fulfillAndLog() 68 | super.register(nib, 69 | forSupplementaryViewOfKind: kind, 70 | withReuseIdentifier: identifier) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeCollectionViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import UIKit 16 | import XCTest 17 | 18 | final class FakeCollectionViewController: UIViewController { 19 | lazy var collectionView: FakeCollectionView = { 20 | let collection = FakeCollectionView(frame: self.view.frame, collectionViewLayout: FakeCollectionLayout()) 21 | collection.translatesAutoresizingMaskIntoConstraints = false 22 | self.view.translatesAutoresizingMaskIntoConstraints = false 23 | self.view.addSubview(collection) 24 | return collection 25 | }() 26 | 27 | init() { 28 | super.init(nibName: nil, bundle: nil) 29 | } 30 | 31 | @available(*, unavailable) 32 | required init?(coder: NSCoder) { 33 | fatalError("init(coder:) has not been implemented") 34 | } 35 | 36 | override func viewDidLoad() { 37 | super.viewDidLoad() 38 | 39 | NSLayoutConstraint.activate([ 40 | self.collectionView.topAnchor.constraint(equalTo: self.view.topAnchor), 41 | self.collectionView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), 42 | self.collectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), 43 | self.collectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) 44 | ]) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeEmptyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import UIKit 17 | import XCTest 18 | 19 | final class FakeEmptyView: UIView { 20 | override init(frame: CGRect) { 21 | super.init(frame: frame) 22 | let label = UILabel(frame: frame) 23 | label.text = "Empty" 24 | self.addSubview(label) 25 | } 26 | 27 | @available(*, unavailable) 28 | required init?(coder: NSCoder) { 29 | fatalError("init(coder:) has not been implemented") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeFlowLayoutDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import ReactiveCollectionsKit 16 | import UIKit 17 | import XCTest 18 | 19 | final class FakeFlowLayoutDelegate: NSObject, UICollectionViewDelegateFlowLayout { 20 | 21 | var expectationSizeForItem: XCTestExpectation? 22 | func collectionView( 23 | _ collectionView: UICollectionView, 24 | layout collectionViewLayout: UICollectionViewLayout, 25 | sizeForItemAt indexPath: IndexPath 26 | ) -> CGSize { 27 | self.expectationSizeForItem?.fulfillAndLog() 28 | return .zero 29 | } 30 | 31 | var expectationInsetForSection: XCTestExpectation? 32 | func collectionView( 33 | _ collectionView: UICollectionView, 34 | layout collectionViewLayout: UICollectionViewLayout, 35 | insetForSectionAt section: Int 36 | ) -> UIEdgeInsets { 37 | self.expectationInsetForSection?.fulfillAndLog() 38 | return .zero 39 | } 40 | 41 | var expectationMinimumLineSpacing: XCTestExpectation? 42 | func collectionView( 43 | _ collectionView: UICollectionView, 44 | layout collectionViewLayout: UICollectionViewLayout, 45 | minimumLineSpacingForSectionAt section: Int 46 | ) -> CGFloat { 47 | self.expectationMinimumLineSpacing?.fulfillAndLog() 48 | return .zero 49 | } 50 | 51 | var expectationMinimumInteritemSpacing: XCTestExpectation? 52 | func collectionView( 53 | _ collectionView: UICollectionView, 54 | layout collectionViewLayout: UICollectionViewLayout, 55 | minimumInteritemSpacingForSectionAt section: Int 56 | ) -> CGFloat { 57 | self.expectationMinimumInteritemSpacing?.fulfillAndLog() 58 | return .zero 59 | } 60 | 61 | var expectationSizeForHeader: XCTestExpectation? 62 | func collectionView( 63 | _ collectionView: UICollectionView, 64 | layout collectionViewLayout: UICollectionViewLayout, 65 | referenceSizeForHeaderInSection section: Int 66 | ) -> CGSize { 67 | self.expectationSizeForHeader?.fulfillAndLog() 68 | return .zero 69 | } 70 | 71 | var expectationSizeForFooter: XCTestExpectation? 72 | func collectionView( 73 | _ collectionView: UICollectionView, 74 | layout collectionViewLayout: UICollectionViewLayout, 75 | referenceSizeForFooterInSection section: Int 76 | ) -> CGSize { 77 | self.expectationSizeForFooter?.fulfillAndLog() 78 | return .zero 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import UIKit 17 | 18 | extension UICollectionViewCompositionalLayout { 19 | static func fakeLayout(addSupplementaryViews: Bool = true, 20 | useNibViews: Bool = false) -> UICollectionViewCompositionalLayout { 21 | let fractionalWidth = CGFloat(0.5) 22 | 23 | // Supplementary Item 24 | let viewSize = NSCollectionLayoutSize(widthDimension: .absolute(50), 25 | heightDimension: .absolute(50)) 26 | 27 | let view: NSCollectionLayoutSupplementaryItem 28 | if useNibViews { 29 | view = NSCollectionLayoutSupplementaryItem(layoutSize: viewSize, 30 | elementKind: FakeSupplementaryNibViewModel.kind, 31 | containerAnchor: .init(edges: .top)) 32 | } else { 33 | view = NSCollectionLayoutSupplementaryItem(layoutSize: viewSize, 34 | elementKind: FakeSupplementaryViewModel.kind, 35 | containerAnchor: .init(edges: .top)) 36 | } 37 | 38 | // Item 39 | let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(fractionalWidth), 40 | heightDimension: .fractionalHeight(1)) 41 | let item = NSCollectionLayoutItem( 42 | layoutSize: itemSize, 43 | supplementaryItems: addSupplementaryViews ? [view] : [] 44 | ) 45 | 46 | // Group 47 | let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), 48 | heightDimension: .fractionalWidth(fractionalWidth)) 49 | let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) 50 | 51 | // Headers and Footers 52 | let headerFooterSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), 53 | heightDimension: .estimated(50)) 54 | let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize, 55 | elementKind: FakeHeaderViewModel.kind, 56 | alignment: .top) 57 | let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerFooterSize, 58 | elementKind: FakeFooterViewModel.kind, 59 | alignment: .bottom) 60 | 61 | // Section 62 | let section = NSCollectionLayoutSection(group: group) 63 | section.boundarySupplementaryItems = [sectionHeader, sectionFooter] 64 | 65 | return UICollectionViewCompositionalLayout(section: section) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeNumberModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import ReactiveCollectionsKit 16 | import UIKit 17 | import XCTest 18 | 19 | struct FakeNumberModel: Hashable { 20 | let number = Int.random(in: 0...1_000_000) 21 | let id: String 22 | 23 | init(id: String = .random) { 24 | self.id = id 25 | } 26 | } 27 | 28 | struct FakeNumberCellViewModel: CellViewModel { 29 | let model: FakeNumberModel 30 | 31 | var id: UniqueIdentifier { 32 | self.model.id 33 | } 34 | 35 | var shouldSelect = true 36 | 37 | var shouldDeselect = true 38 | 39 | var shouldHighlight = true 40 | 41 | var contextMenuConfiguration: UIContextMenuConfiguration? 42 | 43 | var expectationConfigureCell: XCTestExpectation? 44 | func configure(cell: FakeNumberCollectionCell) { 45 | self.expectationConfigureCell?.fulfillAndLog() 46 | } 47 | 48 | var expectationDidSelect: XCTestExpectation? 49 | func didSelect(with coordinator: (any CellEventCoordinator)?) { 50 | self.expectationDidSelect?.fulfillAndLog() 51 | } 52 | 53 | var expectationDidDeselect: XCTestExpectation? 54 | func didDeselect(with coordinator: (any CellEventCoordinator)?) { 55 | self.expectationDidDeselect?.fulfillAndLog() 56 | } 57 | 58 | var expectationWillDisplay: XCTestExpectation? 59 | func willDisplay() { 60 | self.expectationWillDisplay?.fulfillAndLog() 61 | } 62 | 63 | var expectationDidEndDisplaying: XCTestExpectation? 64 | func didEndDisplaying() { 65 | self.expectationDidEndDisplaying?.fulfillAndLog() 66 | } 67 | 68 | var expectationDidHighlight: XCTestExpectation? 69 | func didHighlight() { 70 | self.expectationDidHighlight?.fulfillAndLog() 71 | } 72 | 73 | var expectationDidUnhighlight: XCTestExpectation? 74 | func didUnhighlight() { 75 | self.expectationDidUnhighlight?.fulfillAndLog() 76 | } 77 | 78 | init( 79 | model: FakeNumberModel = FakeNumberModel(), 80 | shouldSelect: Bool = true, 81 | shouldDeselect: Bool = true, 82 | shouldHighlight: Bool = true, 83 | contextMenuConfiguration: UIContextMenuConfiguration? = nil 84 | ) { 85 | self.model = model 86 | self.shouldSelect = shouldSelect 87 | self.shouldDeselect = shouldDeselect 88 | self.shouldHighlight = shouldHighlight 89 | self.contextMenuConfiguration = contextMenuConfiguration 90 | } 91 | 92 | static func == (left: Self, right: Self) -> Bool { 93 | left.model == right.model 94 | } 95 | 96 | func hash(into hasher: inout Hasher) { 97 | hasher.combine(self.model) 98 | } 99 | } 100 | 101 | final class FakeNumberCollectionCell: UICollectionViewCell { } 102 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeScrollViewDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import ReactiveCollectionsKit 16 | import UIKit 17 | import XCTest 18 | 19 | final class FakeScrollViewDelegate: NSObject, UIScrollViewDelegate { 20 | 21 | var expectationDidScroll: XCTestExpectation? 22 | func scrollViewDidScroll(_ scrollView: UIScrollView) { 23 | self.expectationDidScroll?.fulfillAndLog() 24 | } 25 | 26 | var expectationWillBeginDrag: XCTestExpectation? 27 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { 28 | self.expectationWillBeginDrag?.fulfillAndLog() 29 | } 30 | 31 | var expectationWillEndDrag: XCTestExpectation? 32 | func scrollViewWillEndDragging( 33 | _ scrollView: UIScrollView, 34 | withVelocity velocity: CGPoint, 35 | targetContentOffset: UnsafeMutablePointer 36 | ) { 37 | self.expectationWillEndDrag?.fulfillAndLog() 38 | } 39 | 40 | var expectationDidEndDrag: XCTestExpectation? 41 | func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { 42 | self.expectationDidEndDrag?.fulfillAndLog() 43 | } 44 | 45 | var expectationShouldScrollToTop: XCTestExpectation? 46 | func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { 47 | self.expectationShouldScrollToTop?.fulfillAndLog() 48 | return true 49 | } 50 | 51 | var expectationDidScrollToTop: XCTestExpectation? 52 | func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { 53 | self.expectationDidScrollToTop?.fulfillAndLog() 54 | } 55 | 56 | var expectationWillBeginDecelerate: XCTestExpectation? 57 | func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) { 58 | self.expectationWillBeginDecelerate?.fulfillAndLog() 59 | } 60 | 61 | var expectationDidEndDecelerate: XCTestExpectation? 62 | func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { 63 | self.expectationDidEndDecelerate?.fulfillAndLog() 64 | } 65 | 66 | var expectationViewForZoom: XCTestExpectation? 67 | func viewForZooming(in scrollView: UIScrollView) -> UIView? { 68 | self.expectationViewForZoom?.fulfillAndLog() 69 | return nil 70 | } 71 | 72 | var expectationWillBeginZoom: XCTestExpectation? 73 | func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { 74 | self.expectationWillBeginZoom?.fulfillAndLog() 75 | } 76 | 77 | var expectationDidEndZoom: XCTestExpectation? 78 | func scrollViewDidEndZooming(_ scrollView: UIScrollView, 79 | with view: UIView?, 80 | atScale scale: CGFloat) { 81 | self.expectationDidEndZoom?.fulfillAndLog() 82 | } 83 | 84 | var expectationDidZoom: XCTestExpectation? 85 | func scrollViewDidZoom(_ scrollView: UIScrollView) { 86 | self.expectationDidZoom?.fulfillAndLog() 87 | } 88 | 89 | var expectationDidEndScrollAnimation: XCTestExpectation? 90 | func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { 91 | self.expectationDidEndScrollAnimation?.fulfillAndLog() 92 | } 93 | 94 | var expectationDidChangeInset: XCTestExpectation? 95 | func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) { 96 | self.expectationDidChangeInset?.fulfillAndLog() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeSupplementaryNib.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeSupplementaryNibView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import UIKit 17 | import XCTest 18 | 19 | final class FakeSupplementaryNibView: UICollectionReusableView { 20 | @IBOutlet var label: UILabel! 21 | } 22 | 23 | struct FakeSupplementaryNibViewModel: SupplementaryViewModel { 24 | static let kind = "FakeKindWithNib" 25 | 26 | let id: UniqueIdentifier = String.random 27 | 28 | var registration: ViewRegistration { 29 | ViewRegistration( 30 | reuseIdentifier: self.reuseIdentifier, 31 | viewType: .supplementary(kind: Self.kind), 32 | method: .nib( 33 | name: "FakeSupplementaryNib", 34 | bundle: .testBundle 35 | ) 36 | ) 37 | } 38 | 39 | var expectationConfigureView: XCTestExpectation? 40 | func configure(view: FakeSupplementaryNibView) { 41 | self.expectationConfigureView?.fulfillAndLog() 42 | } 43 | 44 | static func == (left: Self, right: Self) -> Bool { 45 | left.id == right.id 46 | } 47 | 48 | func hash(into hasher: inout Hasher) { 49 | hasher.combine(self.id) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeSupplementaryViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | struct FakeSupplementaryViewModel: SupplementaryViewModel { 19 | static let kind = "FakeKind" 20 | 21 | let title: String 22 | 23 | var id: UniqueIdentifier { self.title } 24 | 25 | var registration: ViewRegistration { 26 | ViewRegistration( 27 | reuseIdentifier: self.reuseIdentifier, 28 | supplementaryViewClass: FakeSupplementaryView.self, 29 | kind: Self.kind 30 | ) 31 | } 32 | 33 | var expectationConfigureView: XCTestExpectation? 34 | func configure(view: FakeSupplementaryView) { 35 | self.expectationConfigureView?.fulfillAndLog() 36 | } 37 | 38 | var expectationWillDisplay: XCTestExpectation? 39 | func willDisplay() { 40 | self.expectationWillDisplay?.fulfillAndLog() 41 | } 42 | 43 | var expectationDidEndDisplaying: XCTestExpectation? 44 | func didEndDisplaying() { 45 | self.expectationDidEndDisplaying?.fulfillAndLog() 46 | } 47 | 48 | init(title: String = .random) { 49 | self.title = title 50 | } 51 | 52 | static func == (left: Self, right: Self) -> Bool { 53 | left.title == right.title 54 | } 55 | 56 | func hash(into hasher: inout Hasher) { 57 | hasher.combine(self.title) 58 | } 59 | } 60 | 61 | final class FakeSupplementaryView: UICollectionViewCell { } 62 | 63 | struct FakeHeaderViewModel: SupplementaryHeaderViewModel { 64 | let title: String 65 | 66 | var id: UniqueIdentifier { "Header" } 67 | 68 | var expectationConfigureView: XCTestExpectation? 69 | func configure(view: FakeCollectionHeaderView) { 70 | self.expectationConfigureView?.fulfillAndLog() 71 | } 72 | 73 | var expectationWillDisplay: XCTestExpectation? 74 | func willDisplay() { 75 | self.expectationWillDisplay?.fulfillAndLog() 76 | } 77 | 78 | var expectationDidEndDisplaying: XCTestExpectation? 79 | func didEndDisplaying() { 80 | self.expectationDidEndDisplaying?.fulfillAndLog() 81 | } 82 | 83 | init(title: String = .random) { 84 | self.title = title 85 | } 86 | 87 | static func == (left: Self, right: Self) -> Bool { 88 | left.title == right.title 89 | } 90 | 91 | func hash(into hasher: inout Hasher) { 92 | hasher.combine(self.title) 93 | } 94 | } 95 | 96 | final class FakeCollectionHeaderView: UICollectionReusableView { } 97 | 98 | struct FakeFooterViewModel: SupplementaryFooterViewModel { 99 | let title: String 100 | 101 | var id: UniqueIdentifier { "Footer" } 102 | 103 | var expectationConfigureView: XCTestExpectation? 104 | func configure(view: FakeCollectionFooterView) { 105 | self.expectationConfigureView?.fulfillAndLog() 106 | } 107 | 108 | var expectationWillDisplay: XCTestExpectation? 109 | func willDisplay() { 110 | self.expectationWillDisplay?.fulfillAndLog() 111 | } 112 | 113 | var expectationDidEndDisplaying: XCTestExpectation? 114 | func didEndDisplaying() { 115 | self.expectationDidEndDisplaying?.fulfillAndLog() 116 | } 117 | 118 | init(title: String = .random) { 119 | self.title = title 120 | } 121 | 122 | static func == (left: Self, right: Self) -> Bool { 123 | left.title == right.title 124 | } 125 | 126 | func hash(into hasher: inout Hasher) { 127 | hasher.combine(self.title) 128 | } 129 | } 130 | 131 | final class FakeCollectionFooterView: UICollectionReusableView { } 132 | -------------------------------------------------------------------------------- /Tests/Fakes/FakeTextModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import ReactiveCollectionsKit 16 | import UIKit 17 | import XCTest 18 | 19 | struct FakeTextModel: Hashable { 20 | let text: String 21 | 22 | init(text: String = .random) { 23 | self.text = text 24 | } 25 | } 26 | 27 | struct FakeTextCellViewModel: CellViewModel { 28 | let model: FakeTextModel 29 | 30 | var id: UniqueIdentifier { 31 | self.model.text 32 | } 33 | 34 | var shouldSelect = true 35 | 36 | var shouldDeselect = true 37 | 38 | var shouldHighlight = true 39 | 40 | var contextMenuConfiguration: UIContextMenuConfiguration? 41 | 42 | var expectationConfigureCell: XCTestExpectation? 43 | func configure(cell: FakeTextCollectionCell) { 44 | self.expectationConfigureCell?.fulfillAndLog() 45 | } 46 | 47 | var expectationDidSelect: XCTestExpectation? 48 | func didSelect(with coordinator: (any CellEventCoordinator)?) { 49 | self.expectationDidSelect?.fulfillAndLog() 50 | } 51 | 52 | var expectationDidDeselect: XCTestExpectation? 53 | func didDeselect(with coordinator: (any CellEventCoordinator)?) { 54 | self.expectationDidDeselect?.fulfillAndLog() 55 | } 56 | 57 | var expectationWillDisplay: XCTestExpectation? 58 | func willDisplay() { 59 | self.expectationWillDisplay?.fulfillAndLog() 60 | } 61 | 62 | var expectationDidEndDisplaying: XCTestExpectation? 63 | func didEndDisplaying() { 64 | self.expectationDidEndDisplaying?.fulfillAndLog() 65 | } 66 | 67 | var expectationDidHighlight: XCTestExpectation? 68 | func didHighlight() { 69 | self.expectationDidHighlight?.fulfillAndLog() 70 | } 71 | 72 | var expectationDidUnhighlight: XCTestExpectation? 73 | func didUnhighlight() { 74 | self.expectationDidUnhighlight?.fulfillAndLog() 75 | } 76 | 77 | init( 78 | model: FakeTextModel = FakeTextModel(), 79 | shouldSelect: Bool = true, 80 | shouldDeselect: Bool = true, 81 | shouldHighlight: Bool = true, 82 | contextMenuConfiguration: UIContextMenuConfiguration? = nil 83 | ) { 84 | self.model = model 85 | self.shouldSelect = shouldSelect 86 | self.shouldDeselect = shouldDeselect 87 | self.shouldHighlight = shouldHighlight 88 | self.contextMenuConfiguration = contextMenuConfiguration 89 | } 90 | 91 | static func == (left: Self, right: Self) -> Bool { 92 | left.model == right.model 93 | } 94 | 95 | func hash(into hasher: inout Hasher) { 96 | hasher.combine(self.model) 97 | } 98 | } 99 | 100 | final class FakeTextCollectionCell: UICollectionViewCell { } 101 | -------------------------------------------------------------------------------- /Tests/TestCellEventCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | final class TestCellEventCoordinator: UnitTestCase, @unchecked Sendable { 19 | 20 | @MainActor 21 | func test_underlyingViewController() { 22 | class CustomVC: UIViewController, CellEventCoordinator { } 23 | let controller = CustomVC() 24 | XCTAssertEqual(controller.underlyingViewController, controller) 25 | 26 | let coordinator = FakeCellEventCoordinator() 27 | XCTAssertNil(coordinator.underlyingViewController) 28 | } 29 | 30 | @MainActor 31 | func test_didSelectCell_getsCalled() { 32 | let cell = FakeCellViewModel() 33 | let section = SectionViewModel(id: "id", cells: [cell]) 34 | let model = CollectionViewModel(id: "id", sections: [section]) 35 | 36 | let coordinator = FakeCellEventCoordinator() 37 | coordinator.expectationDidSelect = self.expectation() 38 | 39 | let driver = CollectionViewDriver( 40 | view: self.collectionView, 41 | viewModel: model, 42 | options: .test(), 43 | cellEventCoordinator: coordinator 44 | ) 45 | 46 | let indexPath = IndexPath(item: 0, section: 0) 47 | driver.collectionView(self.collectionView, didSelectItemAt: indexPath) 48 | 49 | XCTAssertEqual(coordinator.selectedCell as! FakeCellViewModel, cell) 50 | 51 | self.waitForExpectations() 52 | 53 | self.keepDriverAlive(driver) 54 | } 55 | 56 | @MainActor 57 | func test_didDeselectCell_getsCalled() { 58 | let cell = FakeCellViewModel() 59 | let section = SectionViewModel(id: "id", cells: [cell]) 60 | let model = CollectionViewModel(id: "id", sections: [section]) 61 | 62 | let coordinator = FakeCellEventCoordinator() 63 | coordinator.expectationDidDeselect = self.expectation() 64 | 65 | let driver = CollectionViewDriver( 66 | view: self.collectionView, 67 | viewModel: model, 68 | options: .test(), 69 | cellEventCoordinator: coordinator 70 | ) 71 | 72 | let indexPath = IndexPath(item: 0, section: 0) 73 | driver.collectionView(self.collectionView, didDeselectItemAt: indexPath) 74 | 75 | XCTAssertEqual(coordinator.deselectedCell as! FakeCellViewModel, cell) 76 | 77 | self.waitForExpectations() 78 | 79 | self.keepDriverAlive(driver) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Tests/TestCollectionExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | final class TestCollectionExtensions: XCTestCase { 19 | 20 | func test_isNotEmpty() { 21 | XCTAssertTrue([1, 2, 3].isNotEmpty) 22 | XCTAssertFalse([].isNotEmpty) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/TestCollectionViewConstants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import UIKit 17 | import XCTest 18 | 19 | final class TestCollectionViewConstants: XCTestCase { 20 | 21 | @MainActor 22 | func test_header() { 23 | XCTAssertEqual(CollectionViewConstants.headerKind, UICollectionView.elementKindSectionHeader) 24 | } 25 | 26 | @MainActor 27 | func test_footer() { 28 | XCTAssertEqual(CollectionViewConstants.footerKind, UICollectionView.elementKindSectionFooter) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/TestCollectionViewDriverOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | final class TestCollectionViewDriverOptions: XCTestCase { 19 | 20 | func test_defaultValues() { 21 | let options = CollectionViewDriverOptions() 22 | XCTAssertFalse(options.diffOnBackgroundQueue) 23 | XCTAssertFalse(options.reloadDataOnReplacingViewModel) 24 | } 25 | 26 | func test_debugDescription() { 27 | let options = CollectionViewDriverOptions() 28 | XCTAssertEqual( 29 | options.debugDescription, 30 | """ 31 | CollectionViewDriverOptions { 32 | diffOnBackgroundQueue: false 33 | reloadDataOnReplacingViewModel: false 34 | } 35 | """ 36 | ) 37 | 38 | let options2 = CollectionViewDriverOptions( 39 | diffOnBackgroundQueue: true, 40 | reloadDataOnReplacingViewModel: true 41 | ) 42 | XCTAssertEqual( 43 | options2.debugDescription, 44 | """ 45 | CollectionViewDriverOptions { 46 | diffOnBackgroundQueue: true 47 | reloadDataOnReplacingViewModel: true 48 | } 49 | """ 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/TestCollectionViewDriverReconfigure.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | final class TestCollectionViewDriverReconfigure: UnitTestCase, @unchecked Sendable { 19 | 20 | @MainActor 21 | func test_reconfigure_item() async { 22 | var uniqueCell = MyStaticCellViewModel(name: "initial") 23 | uniqueCell.expectation = self.expectation(field: .configure, id: uniqueCell.name) 24 | 25 | let numberCells = (1...5).map { _ in 26 | var viewModel = FakeNumberCellViewModel() 27 | viewModel.expectationConfigureCell = self.expectation(field: .configure, id: viewModel.id) 28 | return viewModel 29 | } 30 | 31 | let section1 = SectionViewModel(id: "one", cells: numberCells) 32 | let section2 = SectionViewModel(id: "two", cells: [uniqueCell]) 33 | let section3 = self.fakeSectionViewModel(id: "three") 34 | let model = CollectionViewModel(id: "id", sections: [section1, section2, section3]) 35 | 36 | let viewController = FakeCollectionViewController() 37 | let driver = CollectionViewDriver(view: viewController.collectionView, viewModel: model) 38 | self.simulateAppearance(viewController: viewController) 39 | self.waitForExpectations() 40 | 41 | // Update one cell to be reconfigured 42 | uniqueCell = MyStaticCellViewModel(name: "updated") 43 | uniqueCell.expectation = self.expectation(field: .configure, id: uniqueCell.name) 44 | let updatedSection = SectionViewModel(id: "two", cells: [uniqueCell]) 45 | let updatedModel = CollectionViewModel(id: "id", sections: [section1, updatedSection]) 46 | await driver.update(viewModel: updatedModel) 47 | self.waitForExpectations() 48 | 49 | self.keepDriverAlive(driver) 50 | } 51 | 52 | @MainActor 53 | func test_reconfigure_header_footer() { 54 | let viewController = FakeCollectionViewController() 55 | viewController.collectionView.setCollectionViewLayout( 56 | UICollectionViewCompositionalLayout.fakeLayout(addSupplementaryViews: false), 57 | animated: false 58 | ) 59 | 60 | let driver = CollectionViewDriver(view: viewController.collectionView, options: .test()) 61 | 62 | // Initial header and footer 63 | var header = FakeHeaderViewModel() 64 | header.expectationConfigureView = self.expectation(field: .configure, id: "initial_header") 65 | var footer = FakeFooterViewModel() 66 | footer.expectationConfigureView = self.expectation(field: .configure, id: "initial_footer") 67 | let cells = [FakeNumberCellViewModel()] 68 | let section = SectionViewModel(id: "id", cells: cells, header: header, footer: footer) 69 | let model = CollectionViewModel(id: "id", sections: [section]) 70 | 71 | driver.update(viewModel: model) 72 | self.simulateAppearance(viewController: viewController) 73 | self.waitForExpectations() 74 | 75 | // Update header and footer to be reconfigured 76 | var updatedHeader = FakeHeaderViewModel() 77 | updatedHeader.expectationConfigureView = self.expectation(field: .configure, id: "updated_header") 78 | var updatedFooter = FakeFooterViewModel() 79 | updatedFooter.expectationConfigureView = self.expectation(field: .configure, id: "updated_footer") 80 | let updatedSection = SectionViewModel(id: "id", cells: cells, header: updatedHeader, footer: updatedFooter) 81 | let updatedModel = CollectionViewModel(id: "id", sections: [updatedSection]) 82 | 83 | driver.update(viewModel: updatedModel) 84 | self.waitForExpectations() 85 | 86 | self.keepDriverAlive(driver) 87 | } 88 | } 89 | 90 | private struct MyStaticCellViewModel: CellViewModel { 91 | let id: UniqueIdentifier = "MyCellViewModel" 92 | let name: String 93 | 94 | var expectation: XCTestExpectation? 95 | func configure(cell: FakeCollectionCell) { 96 | expectation?.fulfillAndLog() 97 | } 98 | 99 | static func == (left: Self, right: Self) -> Bool { 100 | left.name == right.name 101 | } 102 | 103 | func hash(into hasher: inout Hasher) { 104 | hasher.combine(self.name) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Tests/TestDiffableSnapshot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | final class TestDiffableSnapshot: UnitTestCase, @unchecked Sendable { 19 | 20 | @MainActor 21 | func test_init() { 22 | let model = self.fakeCollectionViewModel() 23 | let sectionIds = Set(model.allSectionsByIdentifier().keys) 24 | let itemIds = Set(model.allCellsByIdentifier().keys) 25 | 26 | let snapshot = DiffableSnapshot(viewModel: model) 27 | XCTAssertEqual(Set(snapshot.sectionIdentifiers), sectionIds) 28 | XCTAssertEqual(Set(snapshot.itemIdentifiers), itemIds) 29 | 30 | for sectionId in snapshot.sectionIdentifiers { 31 | let section = model.sectionViewModel(for: sectionId) 32 | XCTAssertNotNil(section) 33 | 34 | for itemId in snapshot.itemIdentifiers(inSection: sectionId) { 35 | let cell = section?.cellViewModel(for: itemId) 36 | XCTAssertNotNil(cell) 37 | } 38 | } 39 | } 40 | 41 | @MainActor 42 | func test_init_empty() { 43 | let snapshot = DiffableSnapshot(viewModel: .empty) 44 | XCTAssertTrue(snapshot.sectionIdentifiers.isEmpty) 45 | XCTAssertTrue(snapshot.itemIdentifiers.isEmpty) 46 | } 47 | 48 | @MainActor 49 | func test_init_perf() { 50 | let model = self.fakeCollectionViewModel(numSections: 10, numCells: 10_000) 51 | self.measure { 52 | let snapshot = DiffableSnapshot(viewModel: model) 53 | XCTAssertEqual(snapshot.numberOfItems, 100_000) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Tests/TestEmptyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | final class TestEmptyView: UnitTestCase, @unchecked Sendable { 19 | 20 | @MainActor 21 | func test_provider() { 22 | let view = FakeEmptyView() 23 | let provider = EmptyViewProvider { 24 | view 25 | } 26 | 27 | XCTAssertIdentical(provider.view, view) 28 | } 29 | 30 | @MainActor 31 | func test_driver_displaysEmptyView() { 32 | let emptyView = FakeEmptyView() 33 | let provider = EmptyViewProvider { 34 | emptyView 35 | } 36 | 37 | let viewController = FakeCollectionViewController() 38 | let driver = CollectionViewDriver( 39 | view: viewController.collectionView, 40 | emptyViewProvider: provider 41 | ) 42 | 43 | XCTAssertTrue(driver.viewModel.isEmpty) 44 | 45 | self.simulateAppearance(viewController: viewController) 46 | 47 | // Begin in empty state 48 | XCTAssertTrue(driver.view.subviews.contains(where: { $0 === emptyView })) 49 | 50 | // Update to non-empty model 51 | let nonEmptyExpectation = self.expectation(name: "non_empty") 52 | let model = self.fakeCollectionViewModel() 53 | driver.update(viewModel: model, animated: true) { _ in 54 | nonEmptyExpectation.fulfillAndLog() 55 | } 56 | self.waitForExpectations() 57 | XCTAssertTrue(driver.viewModel.isNotEmpty) 58 | XCTAssertFalse(driver.view.subviews.contains(where: { $0 === emptyView })) 59 | 60 | // Update to empty model 61 | let animationExpectation = self.expectation(name: "animation") 62 | driver.update(viewModel: .empty, animated: true) { _ in 63 | animationExpectation.fulfillAndLog() 64 | } 65 | self.waitForExpectations() 66 | XCTAssertTrue(driver.viewModel.isEmpty) 67 | XCTAssertTrue(driver.view.subviews.contains(where: { $0 === emptyView })) 68 | 69 | // Update to empty model "again" 70 | // already displaying empty view, should return early 71 | // also test completion block 72 | let completionExpectation = self.expectation(name: "completion") 73 | driver.update(viewModel: .empty, animated: false) { _ in 74 | completionExpectation.fulfillAndLog() 75 | } 76 | self.waitForExpectations() 77 | XCTAssertTrue(driver.viewModel.isEmpty) 78 | let emptyViews = driver.view.subviews.filter { $0 is FakeEmptyView } 79 | XCTAssertEqual(emptyViews.count, 1) 80 | 81 | self.keepDriverAlive(driver) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Tests/TestFlowLayoutDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | final class TestFlowLayoutDelegate: UnitTestCase, @unchecked Sendable { 19 | 20 | @MainActor 21 | func test_forwardsEvents_to_flowLayoutDelegate() { 22 | let model = self.fakeCollectionViewModel() 23 | let driver = CollectionViewDriver( 24 | view: self.collectionView, 25 | viewModel: model, 26 | options: .test() 27 | ) 28 | 29 | let flowLayoutDelegate = FakeFlowLayoutDelegate() 30 | driver.flowLayoutDelegate = flowLayoutDelegate 31 | 32 | // Setup all expectations 33 | flowLayoutDelegate.expectationSizeForItem = self.expectation(name: "size_for_item") 34 | flowLayoutDelegate.expectationInsetForSection = self.expectation(name: "inset_for_section") 35 | flowLayoutDelegate.expectationMinimumLineSpacing = self.expectation(name: "line_spacing") 36 | flowLayoutDelegate.expectationMinimumInteritemSpacing = self.expectation(name: "inter_item_spacing") 37 | flowLayoutDelegate.expectationSizeForHeader = self.expectation(name: "size_for_header") 38 | flowLayoutDelegate.expectationSizeForFooter = self.expectation(name: "size_for_footer") 39 | 40 | // Call all delegate methods 41 | let collectionView = self.collectionView 42 | let layout = self.layout 43 | let indexPath = IndexPath(item: 0, section: 0) 44 | 45 | _ = driver.collectionView(collectionView, layout: layout, sizeForItemAt: indexPath) 46 | _ = driver.collectionView(collectionView, layout: layout, insetForSectionAt: 0) 47 | _ = driver.collectionView(collectionView, layout: layout, minimumLineSpacingForSectionAt: 0) 48 | _ = driver.collectionView(collectionView, layout: layout, minimumInteritemSpacingForSectionAt: 0) 49 | _ = driver.collectionView(collectionView, layout: layout, referenceSizeForHeaderInSection: 0) 50 | _ = driver.collectionView(collectionView, layout: layout, referenceSizeForFooterInSection: 0) 51 | 52 | // Verify expectations 53 | self.waitForExpectations() 54 | } 55 | 56 | @MainActor 57 | func test_delegateMethods_returnLayoutProperties_whenNoDelegateIsSet() { 58 | let model = self.fakeCollectionViewModel() 59 | let driver = CollectionViewDriver( 60 | view: self.collectionView, 61 | viewModel: model, 62 | options: .test() 63 | ) 64 | 65 | self.layout.itemSize = CGSize(width: 100, height: 100) 66 | self.layout.sectionInset = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) 67 | self.layout.minimumLineSpacing = 42 68 | self.layout.minimumInteritemSpacing = 42 69 | self.layout.headerReferenceSize = CGSize(width: 400, height: 200) 70 | self.layout.footerReferenceSize = CGSize(width: 400, height: 100) 71 | 72 | let collectionView = self.collectionView 73 | let layout = collectionView.collectionViewLayout 74 | let indexPath = IndexPath(item: 0, section: 0) 75 | 76 | let size = driver.collectionView(collectionView, layout: layout, sizeForItemAt: indexPath) 77 | XCTAssertEqual(size, self.layout.itemSize) 78 | 79 | let inset = driver.collectionView(collectionView, layout: layout, insetForSectionAt: 0) 80 | XCTAssertEqual(inset, self.layout.sectionInset) 81 | 82 | let lineSpacing = driver.collectionView(collectionView, layout: layout, minimumLineSpacingForSectionAt: 0) 83 | XCTAssertEqual(lineSpacing, self.layout.minimumLineSpacing) 84 | 85 | let itemSpacing = driver.collectionView(collectionView, layout: layout, minimumInteritemSpacingForSectionAt: 0) 86 | XCTAssertEqual(itemSpacing, self.layout.minimumInteritemSpacing) 87 | 88 | let headerSize = driver.collectionView(collectionView, layout: layout, referenceSizeForHeaderInSection: 0) 89 | XCTAssertEqual(headerSize, self.layout.headerReferenceSize) 90 | 91 | let footerSize = driver.collectionView(collectionView, layout: layout, referenceSizeForFooterInSection: 0) 92 | XCTAssertEqual(footerSize, self.layout.footerReferenceSize) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Tests/TestPlans/UnitTests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "C9B16F27-B6EF-4110-8C39-2889F7D56870", 5 | "name" : "Default", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "codeCoverage" : { 13 | "targets" : [ 14 | { 15 | "containerPath" : "container:ReactiveCollectionsKit.xcodeproj", 16 | "identifier" : "88FA48D92363A6160061F8B2", 17 | "name" : "ReactiveCollectionsKit" 18 | } 19 | ] 20 | }, 21 | "testExecutionOrdering" : "random", 22 | "testRepetitionMode" : "retryOnFailure", 23 | "testTimeoutsEnabled" : true, 24 | "threadSanitizerEnabled" : true, 25 | "undefinedBehaviorSanitizerEnabled" : true 26 | }, 27 | "testTargets" : [ 28 | { 29 | "target" : { 30 | "containerPath" : "container:ReactiveCollectionsKit.xcodeproj", 31 | "identifier" : "88FA48E22363A6160061F8B2", 32 | "name" : "ReactiveCollectionsKitTests" 33 | } 34 | } 35 | ], 36 | "version" : 1 37 | } 38 | -------------------------------------------------------------------------------- /Tests/TestScrollViewDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | final class TestScrollViewDelegate: UnitTestCase, @unchecked Sendable { 19 | 20 | @MainActor 21 | func test_forwardsEvents_to_scrollViewDelegate() { 22 | let model = self.fakeCollectionViewModel() 23 | let driver = CollectionViewDriver( 24 | view: self.collectionView, 25 | viewModel: model, 26 | options: .test() 27 | ) 28 | 29 | let scrollViewDelegate = FakeScrollViewDelegate() 30 | driver.scrollViewDelegate = scrollViewDelegate 31 | 32 | // Setup all expectations 33 | scrollViewDelegate.expectationDidScroll = self.expectation(name: "did_scroll") 34 | scrollViewDelegate.expectationWillBeginDrag = self.expectation(name: "will_begin_drag") 35 | scrollViewDelegate.expectationWillEndDrag = self.expectation(name: "will_end_drag") 36 | scrollViewDelegate.expectationDidEndDrag = self.expectation(name: "did_end_drag") 37 | scrollViewDelegate.expectationShouldScrollToTop = self.expectation(name: "should_scroll_top") 38 | scrollViewDelegate.expectationDidScrollToTop = self.expectation(name: "did_scroll_top") 39 | scrollViewDelegate.expectationWillBeginDecelerate = self.expectation(name: "will_begin_decelerate") 40 | scrollViewDelegate.expectationDidEndDecelerate = self.expectation(name: "did_end_decelerate") 41 | scrollViewDelegate.expectationViewForZoom = self.expectation(name: "view_for_zoom") 42 | scrollViewDelegate.expectationWillBeginZoom = self.expectation(name: "will_begin_zoom") 43 | scrollViewDelegate.expectationDidEndZoom = self.expectation(name: "did_end_zoom") 44 | scrollViewDelegate.expectationDidZoom = self.expectation(name: "did_zoom") 45 | scrollViewDelegate.expectationDidEndScrollAnimation = self.expectation(name: "did_end_scroll_animation") 46 | scrollViewDelegate.expectationDidChangeInset = self.expectation(name: "did_change_inset") 47 | 48 | // Call all delegate methods 49 | let scrollView = self.collectionView 50 | driver.scrollViewDidScroll(scrollView) 51 | driver.scrollViewWillBeginDragging(scrollView) 52 | 53 | var offset = CGPoint.zero 54 | withUnsafeMutablePointer(to: &offset) { pointer in 55 | driver.scrollViewWillEndDragging(scrollView, withVelocity: .zero, targetContentOffset: pointer) 56 | } 57 | 58 | driver.scrollViewDidEndDragging(scrollView, willDecelerate: true) 59 | _ = driver.scrollViewShouldScrollToTop(scrollView) 60 | driver.scrollViewDidScrollToTop(scrollView) 61 | driver.scrollViewWillBeginDecelerating(scrollView) 62 | driver.scrollViewDidEndDecelerating(scrollView) 63 | _ = driver.viewForZooming(in: scrollView) 64 | driver.scrollViewWillBeginZooming(scrollView, with: nil) 65 | driver.scrollViewDidEndZooming(scrollView, with: nil, atScale: 1) 66 | driver.scrollViewDidZoom(scrollView) 67 | driver.scrollViewDidEndScrollingAnimation(scrollView) 68 | driver.scrollViewDidChangeAdjustedContentInset(scrollView) 69 | 70 | // Verify expectations 71 | self.waitForExpectations() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Tests/TestSupplementaryViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | final class TestSupplementaryViewModel: XCTestCase { 19 | 20 | @MainActor 21 | func test_SupplementaryViewModel_protocol_extension() { 22 | let viewModel = FakeSupplementaryViewModel() 23 | XCTAssert(viewModel.viewClass == FakeSupplementaryView.self) 24 | XCTAssertEqual(viewModel.reuseIdentifier, "FakeSupplementaryViewModel") 25 | } 26 | 27 | // swiftlint:disable xct_specific_matcher 28 | @MainActor 29 | func test_eraseToAnyViewModel() { 30 | var viewModel = FakeSupplementaryViewModel() 31 | viewModel.expectationConfigureView = self.expectation(field: .configure, id: viewModel.id) 32 | viewModel.expectationConfigureView?.expectedFulfillmentCount = 2 33 | 34 | viewModel.expectationWillDisplay = self.expectation(field: .willDisplay, id: viewModel.id) 35 | viewModel.expectationWillDisplay?.expectedFulfillmentCount = 2 36 | 37 | viewModel.expectationDidEndDisplaying = self.expectation(field: .didEndDisplaying, id: viewModel.id) 38 | viewModel.expectationDidEndDisplaying?.expectedFulfillmentCount = 2 39 | 40 | let erased = viewModel.eraseToAnyViewModel() 41 | XCTAssertEqual(erased.id, viewModel.id) 42 | XCTAssertEqual(erased.hashValue, viewModel.hashValue) 43 | XCTAssertEqual(erased.registration, viewModel.registration) 44 | XCTAssertTrue(erased.viewClass == viewModel.viewClass) 45 | XCTAssertEqual(erased.reuseIdentifier, viewModel.reuseIdentifier) 46 | 47 | viewModel.configure(view: FakeSupplementaryView()) 48 | viewModel.willDisplay() 49 | viewModel.didEndDisplaying() 50 | 51 | erased.configure(view: FakeSupplementaryView()) 52 | erased.willDisplay() 53 | erased.didEndDisplaying() 54 | 55 | self.waitForExpectations() 56 | 57 | let erased2 = viewModel.eraseToAnyViewModel() 58 | XCTAssertEqual(erased, erased2) 59 | XCTAssertEqual(erased.hashValue, erased2.hashValue) 60 | 61 | XCTAssertNotEqual(erased, FakeSupplementaryViewModel().eraseToAnyViewModel()) 62 | XCTAssertNotEqual(erased.hashValue, FakeSupplementaryViewModel().eraseToAnyViewModel().hashValue) 63 | 64 | let erased3 = viewModel.eraseToAnyViewModel().eraseToAnyViewModel() 65 | XCTAssertEqual(erased3, erased) 66 | XCTAssertEqual(erased3.hashValue, erased.hashValue) 67 | XCTAssertEqual(erased3.id, viewModel.id) 68 | XCTAssertEqual(erased3.hashValue, viewModel.hashValue) 69 | XCTAssertEqual(erased3.registration, viewModel.registration) 70 | XCTAssertTrue(erased3.viewClass == viewModel.viewClass) 71 | XCTAssertEqual(erased3.reuseIdentifier, viewModel.reuseIdentifier) 72 | 73 | let erased4 = (viewModel.eraseToAnyViewModel() as (any SupplementaryViewModel)).eraseToAnyViewModel() 74 | XCTAssertEqual(erased4, erased) 75 | XCTAssertEqual(erased4.hashValue, erased.hashValue) 76 | XCTAssertEqual(erased4.id, viewModel.id) 77 | XCTAssertEqual(erased4.hashValue, viewModel.hashValue) 78 | XCTAssertEqual(erased4.registration, viewModel.registration) 79 | XCTAssertTrue(erased4.viewClass == viewModel.viewClass) 80 | XCTAssertEqual(erased4.reuseIdentifier, viewModel.reuseIdentifier) 81 | 82 | let anyViewModel5 = AnySupplementaryViewModel(erased2) 83 | XCTAssertEqual(erased, anyViewModel5) 84 | XCTAssertEqual(erased.hashValue, anyViewModel5.hashValue) 85 | 86 | let anyViewModel6 = AnySupplementaryViewModel(erased3) 87 | XCTAssertEqual(erased, anyViewModel6) 88 | XCTAssertEqual(erased.hashValue, anyViewModel6.hashValue) 89 | 90 | let anyViewModel7 = AnySupplementaryViewModel(erased4) 91 | XCTAssertEqual(erased, anyViewModel7) 92 | XCTAssertEqual(erased.hashValue, anyViewModel7.hashValue) 93 | } 94 | // swiftlint:enable xct_specific_matcher 95 | 96 | @MainActor 97 | func test_header() { 98 | XCTAssertEqual(FakeHeaderViewModel.kind, UICollectionView.elementKindSectionHeader) 99 | 100 | let viewModel = FakeHeaderViewModel() 101 | XCTAssertEqual(viewModel._kind, UICollectionView.elementKindSectionHeader) 102 | 103 | let expected = ViewRegistration( 104 | reuseIdentifier: "FakeHeaderViewModel", 105 | supplementaryViewClass: FakeCollectionHeaderView.self, 106 | kind: UICollectionView.elementKindSectionHeader 107 | ) 108 | XCTAssertEqual(viewModel.registration, expected) 109 | } 110 | 111 | @MainActor 112 | func test_footer() { 113 | XCTAssertEqual(FakeFooterViewModel.kind, UICollectionView.elementKindSectionFooter) 114 | 115 | let viewModel = FakeFooterViewModel() 116 | XCTAssertEqual(viewModel._kind, UICollectionView.elementKindSectionFooter) 117 | 118 | let expected = ViewRegistration( 119 | reuseIdentifier: "FakeFooterViewModel", 120 | supplementaryViewClass: FakeCollectionFooterView.self, 121 | kind: UICollectionView.elementKindSectionFooter 122 | ) 123 | XCTAssertEqual(viewModel.registration, expected) 124 | } 125 | 126 | @MainActor 127 | func test_debugDescription() { 128 | let cell = FakeSupplementaryViewModel().eraseToAnyViewModel() 129 | print(cell.debugDescription) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Tests/TestViewRegistration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | final class TestViewRegistration: XCTestCase { 19 | 20 | private class TestView: UICollectionReusableView { } 21 | 22 | @MainActor 23 | func test_convenience_init_class_cell() { 24 | let id = "test" 25 | let registration = ViewRegistration(reuseIdentifier: id, cellClass: TestView.self) 26 | 27 | XCTAssertEqual(registration.reuseIdentifier, id) 28 | XCTAssertEqual(registration.viewType, .cell) 29 | XCTAssertEqual(registration.method, .viewClass(TestView.self)) 30 | } 31 | 32 | @MainActor 33 | func test_convenience_init_nib_cell() { 34 | let id = "test" 35 | let nib = "nib" 36 | let registration = ViewRegistration(reuseIdentifier: id, cellNibName: nib) 37 | 38 | XCTAssertEqual(registration.reuseIdentifier, id) 39 | XCTAssertEqual(registration.viewType, .cell) 40 | XCTAssertEqual(registration.method, .nib(name: nib, bundle: nil)) 41 | } 42 | 43 | @MainActor 44 | func test_convenience_init_class_supplementary() { 45 | let id = "test" 46 | let kind = "kind" 47 | let registration = ViewRegistration(reuseIdentifier: id, supplementaryViewClass: TestView.self, kind: kind) 48 | 49 | XCTAssertEqual(registration.reuseIdentifier, id) 50 | XCTAssertEqual(registration.viewType, .supplementary(kind: kind)) 51 | XCTAssertEqual(registration.method, .viewClass(TestView.self)) 52 | } 53 | 54 | @MainActor 55 | func test_convenience_init_nib_supplementary() { 56 | let id = "test" 57 | let nib = "nib" 58 | let kind = "kind" 59 | let registration = ViewRegistration(reuseIdentifier: id, supplementaryViewNibName: nib, kind: kind) 60 | 61 | XCTAssertEqual(registration.reuseIdentifier, id) 62 | XCTAssertEqual(registration.viewType, .supplementary(kind: kind)) 63 | XCTAssertEqual(registration.method, .nib(name: nib, bundle: nil)) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Tests/TestViewRegistrationMethod.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | final class TestViewRegistrationMethod: XCTestCase { 19 | 20 | func test_viewClassName_nibName_nibBundle() { 21 | let method1 = ViewRegistrationMethod.viewClass(UICollectionViewListCell.self) 22 | 23 | XCTAssertEqual(method1._viewClassName, "UICollectionViewListCell") 24 | XCTAssertNil(method1._nibName) 25 | XCTAssertNil(method1._nibBundle) 26 | 27 | let method2 = ViewRegistrationMethod.nib(name: "name", bundle: .main) 28 | XCTAssertNil(method2._viewClassName) 29 | XCTAssertEqual(method2._nibName, "name") 30 | XCTAssertEqual(method2._nibBundle, .main) 31 | } 32 | 33 | func test_equatable_viewClass() { 34 | let method1 = ViewRegistrationMethod.viewClass(UICollectionViewListCell.self) 35 | let method2 = ViewRegistrationMethod.viewClass(UICollectionViewListCell.self) 36 | let method3 = ViewRegistrationMethod.viewClass(UIView.self) 37 | 38 | XCTAssertEqual(method1, method1) 39 | XCTAssertEqual(method1, method2) 40 | XCTAssertNotEqual(method1, method3) 41 | 42 | let set = Set([method1, method2, method3]) 43 | XCTAssertEqual(set.count, 2) 44 | XCTAssertEqual(set, [method1, method3]) 45 | } 46 | 47 | func test_equatable_nib() { 48 | let method1 = ViewRegistrationMethod.nib(name: "one", bundle: .main) 49 | let method2 = ViewRegistrationMethod.nib(name: "one", bundle: .main) 50 | let method3 = ViewRegistrationMethod.nib(name: "one", bundle: nil) 51 | let method4 = ViewRegistrationMethod.nib(name: "two", bundle: .main) 52 | let method5 = ViewRegistrationMethod.nib(name: "two", bundle: nil) 53 | let method6 = ViewRegistrationMethod.nib(name: "two", bundle: nil) 54 | 55 | XCTAssertEqual(method1, method1) 56 | XCTAssertEqual(method1, method2) 57 | XCTAssertNotEqual(method1, method3) 58 | XCTAssertNotEqual(method4, method5) 59 | XCTAssertEqual(method5, method6) 60 | 61 | let set = Set([method1, method2, method3, method4, method5, method6]) 62 | XCTAssertEqual(set.count, 4) 63 | XCTAssertEqual(set, [method1, method3, method4, method5]) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Tests/TestViewRegistrationViewType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | final class TestViewRegistrationViewType: XCTestCase { 19 | 20 | func test_kind() { 21 | let cell = ViewRegistrationViewType.cell 22 | XCTAssertEqual(cell.kind, "cell") 23 | 24 | let view = ViewRegistrationViewType.supplementary(kind: "kind") 25 | XCTAssertEqual(view.kind, "kind") 26 | } 27 | 28 | func test_isCell() { 29 | let cell = ViewRegistrationViewType.cell 30 | XCTAssertTrue(cell.isCell) 31 | XCTAssertFalse(cell.isSupplementary) 32 | XCTAssertFalse(cell.isHeader) 33 | XCTAssertFalse(cell.isFooter) 34 | 35 | let view = ViewRegistrationViewType.supplementary(kind: "kind") 36 | XCTAssertFalse(view.isCell) 37 | } 38 | 39 | func test_isSupplementary() { 40 | let view = ViewRegistrationViewType.supplementary(kind: "kind") 41 | XCTAssertTrue(view.isSupplementary) 42 | XCTAssertFalse(view.isHeader) 43 | XCTAssertFalse(view.isFooter) 44 | XCTAssertFalse(view.isCell) 45 | 46 | let cell = ViewRegistrationViewType.cell 47 | XCTAssertFalse(cell.isSupplementary) 48 | } 49 | 50 | func test_isHeader() { 51 | let view = ViewRegistrationViewType.supplementary(kind: CollectionViewConstants.headerKind) 52 | 53 | XCTAssertTrue(view.isHeader) 54 | XCTAssertTrue(view.isSupplementary) 55 | 56 | XCTAssertFalse(view.isFooter) 57 | XCTAssertFalse(view.isCell) 58 | } 59 | 60 | func test_isFooter() { 61 | let view = ViewRegistrationViewType.supplementary(kind: CollectionViewConstants.footerKind) 62 | XCTAssertTrue(view.isFooter) 63 | XCTAssertTrue(view.isSupplementary) 64 | 65 | XCTAssertFalse(view.isHeader) 66 | XCTAssertFalse(view.isCell) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Tests/Utils/CollectionViewDriverOptions+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import ReactiveCollectionsKit 16 | 17 | extension CollectionViewDriverOptions { 18 | static func test() -> Self { 19 | .init(diffOnBackgroundQueue: false, reloadDataOnReplacingViewModel: true) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/Utils/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | 16 | extension String { 17 | static var random: String { 18 | String(UUID().uuidString) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/Utils/TestBundle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | 16 | extension Bundle { 17 | static var testBundle: Bundle { 18 | let bundle = Bundle(for: BundleFinder.self) 19 | 20 | // building and testing via Package.swift 21 | let swiftPackageBundleName = "ReactiveCollectionsKit_ReactiveCollectionsKitTests.bundle" 22 | if let swiftPackageBundleURL = bundle.resourceURL?.appendingPathComponent(swiftPackageBundleName), 23 | let swiftPackageBundle = Bundle(url: swiftPackageBundleURL) { 24 | return swiftPackageBundle 25 | } 26 | 27 | // building and testing via Xcode Project 28 | return bundle 29 | } 30 | } 31 | 32 | private final class BundleFinder { } 33 | -------------------------------------------------------------------------------- /Tests/Utils/TestExpectationField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | 16 | enum TestExpectationField: String, Hashable { 17 | case configure 18 | case didSelect 19 | case didDeselect 20 | case willDisplay 21 | case didEndDisplaying 22 | case didHighlight 23 | case didUnhighlight 24 | } 25 | -------------------------------------------------------------------------------- /Tests/Utils/UnitTestCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | @testable import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | // swiftlint:disable:next final_test_case 19 | class UnitTestCase: XCTestCase, @unchecked Sendable { 20 | 21 | private static let frame = CGRect(x: 0, y: 0, width: 320, height: 600) 22 | 23 | @MainActor var collectionView: FakeCollectionView { 24 | FakeCollectionView( 25 | frame: Self.frame, 26 | collectionViewLayout: self.layout 27 | ) 28 | } 29 | 30 | @MainActor let layout = FakeCollectionLayout() 31 | 32 | var keepAliveDrivers = [CollectionViewDriver]() 33 | 34 | var keepAliveWindows = [UIWindow]() 35 | 36 | override func setUp() async throws { 37 | try await super.setUp() 38 | await self.collectionView.layoutSubviews() 39 | await self.collectionView.reloadData() 40 | 41 | self.keepAliveDrivers.removeAll() 42 | self.keepAliveWindows.removeAll() 43 | } 44 | 45 | @MainActor 46 | func simulateAppearance(viewController: UIViewController) { 47 | viewController.beginAppearanceTransition(true, animated: false) 48 | viewController.endAppearanceTransition() 49 | 50 | let window = UIWindow() 51 | window.frame = Self.frame 52 | window.rootViewController = viewController 53 | window.makeKeyAndVisible() 54 | 55 | self.keepAliveWindows.append(window) 56 | } 57 | 58 | @MainActor 59 | func keepDriverAlive(_ driver: CollectionViewDriver) { 60 | self.keepAliveDrivers.append(driver) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Tests/Utils/XCTestCase+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import ReactiveCollectionsKit 16 | import XCTest 17 | 18 | extension XCTestCase { 19 | var defaultTimeout: TimeInterval { 5 } 20 | 21 | @MainActor 22 | func waitForExpectations() { 23 | self.waitForExpectations(timeout: self.defaultTimeout, handler: nil) 24 | } 25 | 26 | func expectation(function: String = #function, name: String? = nil) -> XCTestExpectation { 27 | self.expectation(description: [function, name].compactMap { $0 }.joined(separator: "-")) 28 | } 29 | 30 | func expectation(field: TestExpectationField, id: UniqueIdentifier, function: String = #function) -> XCTestExpectation { 31 | self.expectation(function: function, name: "\(field.rawValue)_\(id)") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/Utils/XCTestExpectation+Expectations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Jesse Squires 3 | // https://www.jessesquires.com 4 | // 5 | // Documentation 6 | // https://jessesquires.github.io/ReactiveCollectionsKit 7 | // 8 | // GitHub 9 | // https://github.com/jessesquires/ReactiveCollectionsKit 10 | // 11 | // Copyright © 2019-present Jesse Squires 12 | // 13 | 14 | import Foundation 15 | import XCTest 16 | 17 | extension XCTestExpectation { 18 | func setInvertedAndLog() { 19 | self.isInverted = true 20 | print("Inverted expectation: \(self.expectationDescription)") 21 | } 22 | 23 | func fulfillAndLog() { 24 | self.fulfill() 25 | print("Fulfilled expectation: \(self.expectationDescription)") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docs/badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | documentation 17 | 18 | 19 | documentation 20 | 21 | 22 | 100% 23 | 24 | 25 | 100% 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /docs/css/highlight.css: -------------------------------------------------------------------------------- 1 | /*! Jazzy - https://github.com/realm/jazzy 2 | * Copyright Realm Inc. 3 | * SPDX-License-Identifier: MIT 4 | */ 5 | /* Credit to https://gist.github.com/wataru420/2048287 */ 6 | .highlight .c { 7 | color: #999988; 8 | font-style: italic; } 9 | 10 | .highlight .err { 11 | color: #a61717; 12 | background-color: #e3d2d2; } 13 | 14 | .highlight .k { 15 | color: #000000; 16 | font-weight: bold; } 17 | 18 | .highlight .o { 19 | color: #000000; 20 | font-weight: bold; } 21 | 22 | .highlight .cm { 23 | color: #999988; 24 | font-style: italic; } 25 | 26 | .highlight .cp { 27 | color: #999999; 28 | font-weight: bold; } 29 | 30 | .highlight .c1 { 31 | color: #999988; 32 | font-style: italic; } 33 | 34 | .highlight .cs { 35 | color: #999999; 36 | font-weight: bold; 37 | font-style: italic; } 38 | 39 | .highlight .gd { 40 | color: #000000; 41 | background-color: #ffdddd; } 42 | 43 | .highlight .gd .x { 44 | color: #000000; 45 | background-color: #ffaaaa; } 46 | 47 | .highlight .ge { 48 | color: #000000; 49 | font-style: italic; } 50 | 51 | .highlight .gr { 52 | color: #aa0000; } 53 | 54 | .highlight .gh { 55 | color: #999999; } 56 | 57 | .highlight .gi { 58 | color: #000000; 59 | background-color: #ddffdd; } 60 | 61 | .highlight .gi .x { 62 | color: #000000; 63 | background-color: #aaffaa; } 64 | 65 | .highlight .go { 66 | color: #888888; } 67 | 68 | .highlight .gp { 69 | color: #555555; } 70 | 71 | .highlight .gs { 72 | font-weight: bold; } 73 | 74 | .highlight .gu { 75 | color: #aaaaaa; } 76 | 77 | .highlight .gt { 78 | color: #aa0000; } 79 | 80 | .highlight .kc { 81 | color: #000000; 82 | font-weight: bold; } 83 | 84 | .highlight .kd { 85 | color: #000000; 86 | font-weight: bold; } 87 | 88 | .highlight .kp { 89 | color: #000000; 90 | font-weight: bold; } 91 | 92 | .highlight .kr { 93 | color: #000000; 94 | font-weight: bold; } 95 | 96 | .highlight .kt { 97 | color: #445588; } 98 | 99 | .highlight .m { 100 | color: #009999; } 101 | 102 | .highlight .s { 103 | color: #d14; } 104 | 105 | .highlight .na { 106 | color: #008080; } 107 | 108 | .highlight .nb { 109 | color: #0086B3; } 110 | 111 | .highlight .nc { 112 | color: #445588; 113 | font-weight: bold; } 114 | 115 | .highlight .no { 116 | color: #008080; } 117 | 118 | .highlight .ni { 119 | color: #800080; } 120 | 121 | .highlight .ne { 122 | color: #990000; 123 | font-weight: bold; } 124 | 125 | .highlight .nf { 126 | color: #990000; } 127 | 128 | .highlight .nn { 129 | color: #555555; } 130 | 131 | .highlight .nt { 132 | color: #000080; } 133 | 134 | .highlight .nv { 135 | color: #008080; } 136 | 137 | .highlight .ow { 138 | color: #000000; 139 | font-weight: bold; } 140 | 141 | .highlight .w { 142 | color: #bbbbbb; } 143 | 144 | .highlight .mf { 145 | color: #009999; } 146 | 147 | .highlight .mh { 148 | color: #009999; } 149 | 150 | .highlight .mi { 151 | color: #009999; } 152 | 153 | .highlight .mo { 154 | color: #009999; } 155 | 156 | .highlight .sb { 157 | color: #d14; } 158 | 159 | .highlight .sc { 160 | color: #d14; } 161 | 162 | .highlight .sd { 163 | color: #d14; } 164 | 165 | .highlight .s2 { 166 | color: #d14; } 167 | 168 | .highlight .se { 169 | color: #d14; } 170 | 171 | .highlight .sh { 172 | color: #d14; } 173 | 174 | .highlight .si { 175 | color: #d14; } 176 | 177 | .highlight .sx { 178 | color: #d14; } 179 | 180 | .highlight .sr { 181 | color: #009926; } 182 | 183 | .highlight .s1 { 184 | color: #d14; } 185 | 186 | .highlight .ss { 187 | color: #990073; } 188 | 189 | .highlight .bp { 190 | color: #999999; } 191 | 192 | .highlight .vc { 193 | color: #008080; } 194 | 195 | .highlight .vg { 196 | color: #008080; } 197 | 198 | .highlight .vi { 199 | color: #008080; } 200 | 201 | .highlight .il { 202 | color: #009999; } 203 | -------------------------------------------------------------------------------- /docs/img/carat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessesquires/ReactiveCollectionsKit/afde76889320f3c7dcbf53156cde214f1a585000/docs/img/carat.png -------------------------------------------------------------------------------- /docs/img/dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessesquires/ReactiveCollectionsKit/afde76889320f3c7dcbf53156cde214f1a585000/docs/img/dash.png -------------------------------------------------------------------------------- /docs/img/gh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessesquires/ReactiveCollectionsKit/afde76889320f3c7dcbf53156cde214f1a585000/docs/img/gh.png -------------------------------------------------------------------------------- /docs/img/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jessesquires/ReactiveCollectionsKit/afde76889320f3c7dcbf53156cde214f1a585000/docs/img/spinner.gif -------------------------------------------------------------------------------- /docs/js/jazzy.js: -------------------------------------------------------------------------------- 1 | // Jazzy - https://github.com/realm/jazzy 2 | // Copyright Realm Inc. 3 | // SPDX-License-Identifier: MIT 4 | 5 | window.jazzy = {'docset': false} 6 | if (typeof window.dash != 'undefined') { 7 | document.documentElement.className += ' dash' 8 | window.jazzy.docset = true 9 | } 10 | if (navigator.userAgent.match(/xcode/i)) { 11 | document.documentElement.className += ' xcode' 12 | window.jazzy.docset = true 13 | } 14 | 15 | function toggleItem($link, $content) { 16 | var animationDuration = 300; 17 | $link.toggleClass('token-open'); 18 | $content.slideToggle(animationDuration); 19 | } 20 | 21 | function itemLinkToContent($link) { 22 | return $link.parent().parent().next(); 23 | } 24 | 25 | // On doc load + hash-change, open any targeted item 26 | function openCurrentItemIfClosed() { 27 | if (window.jazzy.docset) { 28 | return; 29 | } 30 | var $link = $(`a[name="${location.hash.substring(1)}"]`).nextAll('.token'); 31 | $content = itemLinkToContent($link); 32 | if ($content.is(':hidden')) { 33 | toggleItem($link, $content); 34 | } 35 | } 36 | 37 | $(openCurrentItemIfClosed); 38 | $(window).on('hashchange', openCurrentItemIfClosed); 39 | 40 | // On item link ('token') click, toggle its discussion 41 | $('.token').on('click', function(event) { 42 | if (window.jazzy.docset) { 43 | return; 44 | } 45 | var $link = $(this); 46 | toggleItem($link, itemLinkToContent($link)); 47 | 48 | // Keeps the document from jumping to the hash. 49 | var href = $link.attr('href'); 50 | if (history.pushState) { 51 | history.pushState({}, '', href); 52 | } else { 53 | location.hash = href; 54 | } 55 | event.preventDefault(); 56 | }); 57 | 58 | // Clicks on links to the current, closed, item need to open the item 59 | $("a:not('.token')").on('click', function() { 60 | if (location == this.href) { 61 | openCurrentItemIfClosed(); 62 | } 63 | }); 64 | 65 | // KaTeX rendering 66 | if ("katex" in window) { 67 | $($('.math').each( (_, element) => { 68 | katex.render(element.textContent, element, { 69 | displayMode: $(element).hasClass('m-block'), 70 | throwOnError: false, 71 | trust: true 72 | }); 73 | })) 74 | } 75 | -------------------------------------------------------------------------------- /docs/js/jazzy.search.js: -------------------------------------------------------------------------------- 1 | // Jazzy - https://github.com/realm/jazzy 2 | // Copyright Realm Inc. 3 | // SPDX-License-Identifier: MIT 4 | 5 | $(function(){ 6 | var $typeahead = $('[data-typeahead]'); 7 | var $form = $typeahead.parents('form'); 8 | var searchURL = $form.attr('action'); 9 | 10 | function displayTemplate(result) { 11 | return result.name; 12 | } 13 | 14 | function suggestionTemplate(result) { 15 | var t = '
'; 16 | t += '' + result.name + ''; 17 | if (result.parent_name) { 18 | t += '' + result.parent_name + ''; 19 | } 20 | t += '
'; 21 | return t; 22 | } 23 | 24 | $typeahead.one('focus', function() { 25 | $form.addClass('loading'); 26 | 27 | $.getJSON(searchURL).then(function(searchData) { 28 | const searchIndex = lunr(function() { 29 | this.ref('url'); 30 | this.field('name'); 31 | this.field('abstract'); 32 | for (const [url, doc] of Object.entries(searchData)) { 33 | this.add({url: url, name: doc.name, abstract: doc.abstract}); 34 | } 35 | }); 36 | 37 | $typeahead.typeahead( 38 | { 39 | highlight: true, 40 | minLength: 3, 41 | autoselect: true 42 | }, 43 | { 44 | limit: 10, 45 | display: displayTemplate, 46 | templates: { suggestion: suggestionTemplate }, 47 | source: function(query, sync) { 48 | const lcSearch = query.toLowerCase(); 49 | const results = searchIndex.query(function(q) { 50 | q.term(lcSearch, { boost: 100 }); 51 | q.term(lcSearch, { 52 | boost: 10, 53 | wildcard: lunr.Query.wildcard.TRAILING 54 | }); 55 | }).map(function(result) { 56 | var doc = searchData[result.ref]; 57 | doc.url = result.ref; 58 | return doc; 59 | }); 60 | sync(results); 61 | } 62 | } 63 | ); 64 | $form.removeClass('loading'); 65 | $typeahead.trigger('focus'); 66 | }); 67 | }); 68 | 69 | var baseURL = searchURL.slice(0, -"search.json".length); 70 | 71 | $typeahead.on('typeahead:select', function(e, result) { 72 | window.location = baseURL + result.url; 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /docs/undocumented.json: -------------------------------------------------------------------------------- 1 | { 2 | "warnings": [ 3 | 4 | ], 5 | "source_directory": "/Users/jsq/Developer/GitHub/ReactiveCollectionsKit" 6 | } -------------------------------------------------------------------------------- /scripts/build_docs.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | # Created by Jesse Squires 4 | # https://www.jessesquires.com 5 | # 6 | # Copyright © 2020-present Jesse Squires 7 | # 8 | # Jazzy: https://github.com/realm/jazzy/releases/latest 9 | # Generates documentation using jazzy and checks for installation. 10 | 11 | VERSION="0.15.2" 12 | 13 | FOUND=$(jazzy --version) 14 | LINK="https://github.com/realm/jazzy" 15 | INSTALL="gem install jazzy" 16 | 17 | PROJECT="ReactiveCollectionsKit" 18 | 19 | if which jazzy >/dev/null; then 20 | jazzy \ 21 | --clean \ 22 | --author "Jesse Squires" \ 23 | --author_url "https://jessesquires.com" \ 24 | --github_url "https://github.com/jessesquires/$PROJECT" \ 25 | --module "$PROJECT" \ 26 | --source-directory . \ 27 | --readme "README.md" \ 28 | --documentation "Guides/*.md" \ 29 | --output docs/ 30 | else 31 | echo " 32 | Error: Jazzy not installed! 33 | 34 | Download: $LINK 35 | Install: $INSTALL 36 | " 37 | exit 1 38 | fi 39 | 40 | if [ "$FOUND" != "jazzy version: $VERSION" ]; then 41 | echo " 42 | Warning: incorrect Jazzy installed! Please upgrade. 43 | Expected: $VERSION 44 | Found: $FOUND 45 | 46 | Download: $LINK 47 | Install: $INSTALL 48 | " 49 | fi 50 | 51 | exit 52 | -------------------------------------------------------------------------------- /scripts/lint.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | # Created by Jesse Squires 4 | # https://www.jessesquires.com 5 | # 6 | # Copyright © 2020-present Jesse Squires 7 | # 8 | # SwiftLint: https://github.com/realm/SwiftLint/releases/latest 9 | # 10 | # Runs SwiftLint and checks for installation of correct version. 11 | 12 | set -e 13 | export PATH="$PATH:/opt/homebrew/bin" 14 | 15 | if [[ "${GITHUB_ACTIONS}" ]]; then 16 | # ignore on GitHub Actions 17 | exit 0 18 | fi 19 | 20 | LINK="https://github.com/realm/SwiftLint" 21 | INSTALL="brew install swiftlint" 22 | 23 | if ! which swiftlint >/dev/null; then 24 | echo " 25 | Error: SwiftLint not installed! 26 | 27 | Download: $LINK 28 | Install: $INSTALL 29 | " 30 | exit 0 31 | fi 32 | 33 | PROJECT="ReactiveCollectionsKit.xcodeproj" 34 | SCHEME="ReactiveCollectionsKit" 35 | 36 | VERSION="0.57.0" 37 | FOUND=$(swiftlint version) 38 | CONFIG="./.swiftlint.yml" 39 | 40 | echo "Running swiftlint..." 41 | echo "" 42 | 43 | # no arguments, just lint without fixing 44 | if [[ $# -eq 0 ]]; then 45 | swiftlint --config $CONFIG 46 | echo "" 47 | fi 48 | 49 | for argval in "$@" 50 | do 51 | # run --fix 52 | if [[ "$argval" == "fix" ]]; then 53 | echo "Auto-correcting lint errors..." 54 | echo "" 55 | swiftlint --fix --progress --config $CONFIG && swiftlint --config $CONFIG 56 | echo "" 57 | # run analyze 58 | elif [[ "$argval" == "analyze" ]]; then 59 | LOG="xcodebuild.log" 60 | echo "Running anaylze..." 61 | echo "" 62 | xcodebuild -scheme $SCHEME -project $PROJECT clean build-for-testing > $LOG 63 | swiftlint analyze --fix --progress --format --strict --config $CONFIG --compiler-log-path $LOG 64 | rm $LOG 65 | echo "" 66 | else 67 | echo "Error: invalid arguments." 68 | echo "Usage: $0 [fix] [analyze]" 69 | echo "" 70 | fi 71 | done 72 | 73 | if [ $FOUND != $VERSION ]; then 74 | echo " 75 | Warning: incorrect SwiftLint installed! Please upgrade. 76 | Expected: $VERSION 77 | Found: $FOUND 78 | 79 | Download: $LINK 80 | Install: $INSTALL 81 | " 82 | fi 83 | 84 | exit 0 85 | --------------------------------------------------------------------------------