diff --git a/CHANGELOG.md b/CHANGELOG.md index a489080..2cca413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,9 @@ NEXT 0.1.7 ----- -- Upgrade to Xcode 16. ([@jessesquires](https://github.com/jessesquires), [#116](https://github.com/jessesquires/ReactiveCollectionsKit/pull/116)) +- Upgraded to Xcode 16. ([@jessesquires](https://github.com/jessesquires), [#116](https://github.com/jessesquires/ReactiveCollectionsKit/pull/116)) - 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)) +- Implemented additional selection APIs for `CellViewModel`: `shouldSelect`, `shouldDeselect`, `didDeselect()`. ([@nuomi1](https://github.com/nuomi1), [#127](https://github.com/jessesquires/ReactiveCollectionsKit/pull/127)) 0.1.6 ----- diff --git a/Example/Sources/Grid/GridViewController.swift b/Example/Sources/Grid/GridViewController.swift index 1d58f10..076285b 100644 --- a/Example/Sources/Grid/GridViewController.swift +++ b/Example/Sources/Grid/GridViewController.swift @@ -68,6 +68,10 @@ final class GridViewController: ExampleViewController, CellEventCoordinator { assertionFailure("unhandled cell selection") } + func didDeselectCell(viewModel: any CellViewModel) { + print("\(#function): \(viewModel.id)") + } + // MARK: Private private static func makeLayout() -> UICollectionViewCompositionalLayout { @@ -129,7 +133,8 @@ final class GridViewController: ExampleViewController, CellEventCoordinator { return PersonCellViewModelGrid( person: $0, - contextMenuConfiguration: menuConfig + contextMenuConfiguration: menuConfig, + shouldSelect: false ).eraseToAnyViewModel() } let peopleHeader = HeaderViewModel(title: "People", style: .large) diff --git a/Example/Sources/Grid/PersonCellViewModelGrid.swift b/Example/Sources/Grid/PersonCellViewModelGrid.swift index 338eddb..b83d485 100644 --- a/Example/Sources/Grid/PersonCellViewModelGrid.swift +++ b/Example/Sources/Grid/PersonCellViewModelGrid.swift @@ -30,19 +30,24 @@ struct PersonCellViewModelGrid: CellViewModel { let contextMenuConfiguration: UIContextMenuConfiguration? + let shouldSelect: Bool + func configure(cell: GridPersonCell) { cell.titleLabel.text = self.person.name cell.subtitleLabel.text = self.person.birthDateText cell.flagLabel.text = self.person.nationality + cell.contentView.alpha = self.shouldSelect ? 1 : 0.3 } // MARK: Hashable nonisolated func hash(into hasher: inout Hasher) { hasher.combine(self.person) + hasher.combine(self.shouldSelect) } nonisolated static func == (left: Self, right: Self) -> Bool { left.person == right.person + && left.shouldSelect == right.shouldSelect } } diff --git a/Sources/CellEventCoordinator.swift b/Sources/CellEventCoordinator.swift index 3457d73..6465fd2 100644 --- a/Sources/CellEventCoordinator.swift +++ b/Sources/CellEventCoordinator.swift @@ -22,6 +22,10 @@ public protocol CellEventCoordinator: AnyObject { /// - Parameter viewModel: The cell view model that corresponds to the cell. func didSelectCell(viewModel: any CellViewModel) + /// Called when a cell is deselected. + /// - Parameter viewModel: The cell view model that corresponds to the cell. + func didDeselectCell(viewModel: any CellViewModel) + /// Returns the underlying view controller that owns the collection view for the cell. /// /// You may use this to optionally handle navigation within your cell view model. @@ -33,6 +37,9 @@ extension CellEventCoordinator { /// Default implementation. Does nothing. public func didSelectCell(viewModel: any CellViewModel) { } + /// Default implementation. Does nothing. + public func didDeselectCell(viewModel: any CellViewModel) { } + /// Default implementation. Returns `nil`. public var underlyingViewController: UIViewController? { nil } } diff --git a/Sources/CellViewModel.swift b/Sources/CellViewModel.swift index 41dfe36..320b373 100644 --- a/Sources/CellViewModel.swift +++ b/Sources/CellViewModel.swift @@ -20,6 +20,16 @@ public protocol CellViewModel: DiffableViewModel, ViewRegistrationProvider { /// The type of cell that this view model represents and configures. associatedtype CellType: UICollectionViewCell + /// Returns whether or not the cell should get selected. + /// This corresponds to the delegate method `collectionView(_:shouldSelectItemAt:)`. + /// The default implementation returns `true`. + var shouldSelect: Bool { get } + + /// Returns whether or not the cell should get deselected. + /// This corresponds to the delegate method `collectionView(_:shouldDeselectItemAt:)`. + /// The default implementation returns `true`. + var shouldDeselect: Bool { get } + /// Returns whether or not the cell should get highlighted. /// This corresponds to the delegate method `collectionView(_:shouldHighlightItemAt:)`. /// The default implementation returns `true`. @@ -33,10 +43,20 @@ public protocol CellViewModel: DiffableViewModel, ViewRegistrationProvider { /// - Parameter cell: The cell to configure. func configure(cell: CellType) - /// Handles the selection event for this cell, optionally using the provided `coordinator`. + /// Tells the view model that its cell was selected. + /// + /// Implement this method to handle this event, optionally using the provided `coordinator`. + /// /// - Parameter coordinator: An event coordinator object, if one was provided to the `CollectionViewDriver`. func didSelect(with coordinator: CellEventCoordinator?) + /// Tells the view model that its cell was deselected. + /// + /// Implement this method to handle this event, optionally using the provided `coordinator`. + /// + /// - Parameter coordinator: An event coordinator object, if one was provided to the `CollectionViewDriver`. + func didDeselect(with coordinator: CellEventCoordinator?) + /// Tells the view model that its cell is about to be displayed in the collection view. /// This corresponds to the delegate method `collectionView(_:willDisplay:forItemAt:)`. func willDisplay() @@ -55,6 +75,12 @@ public protocol CellViewModel: DiffableViewModel, ViewRegistrationProvider { } extension CellViewModel { + /// Default implementation. Returns `true`. + public var shouldSelect: Bool { true } + + /// Default implementation. Returns `true`. + public var shouldDeselect: Bool { true } + /// Default implementation. Returns `true`. public var shouldHighlight: Bool { true } @@ -68,6 +94,13 @@ extension CellViewModel { coordinator?.didSelectCell(viewModel: self) } + /// Default implementation. + /// Calls `didDeselectCell(viewModel:)` on the `coordinator`, + /// passing `self` to the `viewModel` parameter. + public func didDeselect(with coordinator: CellEventCoordinator?) { + coordinator?.didDeselectCell(viewModel: self) + } + /// Default implementation. Does nothing. public func willDisplay() { } @@ -136,6 +169,12 @@ public struct AnyCellViewModel: CellViewModel { /// :nodoc: public typealias CellType = UICollectionViewCell + /// :nodoc: + public var shouldSelect: Bool { self._shouldSelect } + + /// :nodoc: + public var shouldDeselect: Bool { self._shouldDeselect } + /// :nodoc: public var shouldHighlight: Bool { self._shouldHighlight } @@ -152,6 +191,11 @@ public struct AnyCellViewModel: CellViewModel { self._didSelect(coordinator) } + /// :nodoc: + public func didDeselect(with coordinator: CellEventCoordinator?) { + self._didDeselect(coordinator) + } + /// :nodoc: public func willDisplay() { self._willDisplay() @@ -183,10 +227,13 @@ public struct AnyCellViewModel: CellViewModel { private let _viewModel: AnyHashable private let _id: UniqueIdentifier private let _registration: ViewRegistration + private let _shouldSelect: Bool + private let _shouldDeselect: Bool private let _shouldHighlight: Bool private let _contextMenuConfiguration: UIContextMenuConfiguration? private let _configure: (CellType) -> Void private let _didSelect: (CellEventCoordinator?) -> Void + private let _didDeselect: (CellEventCoordinator?) -> Void private let _willDisplay: () -> Void private let _didEndDisplaying: () -> Void private let _didHighlight: () -> Void @@ -205,6 +252,8 @@ public struct AnyCellViewModel: CellViewModel { self._viewModel = viewModel self._id = viewModel.id self._registration = viewModel.registration + self._shouldSelect = viewModel.shouldSelect + self._shouldDeselect = viewModel.shouldDeselect self._shouldHighlight = viewModel.shouldHighlight self._contextMenuConfiguration = viewModel.contextMenuConfiguration self._configure = { cell in @@ -214,6 +263,7 @@ public struct AnyCellViewModel: CellViewModel { self._didSelect = { coordinator in viewModel.didSelect(with: coordinator) } + self._didDeselect = viewModel.didDeselect self._willDisplay = viewModel.willDisplay self._didEndDisplaying = viewModel.didEndDisplaying self._didHighlight = viewModel.didHighlight diff --git a/Sources/CollectionViewDriver.swift b/Sources/CollectionViewDriver.swift index 913f925..5d57ece 100644 --- a/Sources/CollectionViewDriver.swift +++ b/Sources/CollectionViewDriver.swift @@ -288,12 +288,30 @@ public final class CollectionViewDriver: NSObject { extension CollectionViewDriver: UICollectionViewDelegate { // MARK: Managing the selected cells + /// :nodoc: + public func collectionView(_ collectionView: UICollectionView, + shouldSelectItemAt indexPath: IndexPath) -> Bool { + self.viewModel.cellViewModel(at: indexPath).shouldSelect + } + /// :nodoc: public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { self.viewModel.cellViewModel(at: indexPath).didSelect(with: self._cellEventCoordinator) } + /// :nodoc: + public func collectionView(_ collectionView: UICollectionView, + shouldDeselectItemAt indexPath: IndexPath) -> Bool { + self.viewModel.cellViewModel(at: indexPath).shouldDeselect + } + + /// :nodoc: + public func collectionView(_ collectionView: UICollectionView, + didDeselectItemAt indexPath: IndexPath) { + self.viewModel.cellViewModel(at: indexPath).didDeselect(with: self._cellEventCoordinator) + } + // MARK: Managing cell highlighting /// :nodoc: diff --git a/Tests/Fakes/FakeCellEventCoordinator.swift b/Tests/Fakes/FakeCellEventCoordinator.swift index 2c1b94a..a50e171 100644 --- a/Tests/Fakes/FakeCellEventCoordinator.swift +++ b/Tests/Fakes/FakeCellEventCoordinator.swift @@ -23,4 +23,11 @@ final class FakeCellEventCoordinator: CellEventCoordinator { self.selectedCell = viewModel self.expectationDidSelect?.fulfillAndLog() } + + var deselectedCell: (any CellViewModel)? + var expectationDidDeselect: XCTestExpectation? + func didDeselectCell(viewModel: any CellViewModel) { + self.deselectedCell = viewModel + self.expectationDidDeselect?.fulfillAndLog() + } } diff --git a/Tests/Fakes/FakeCellNibView.swift b/Tests/Fakes/FakeCellNibView.swift index 12ad217..089f62d 100644 --- a/Tests/Fakes/FakeCellNibView.swift +++ b/Tests/Fakes/FakeCellNibView.swift @@ -44,6 +44,11 @@ struct FakeCellNibViewModel: CellViewModel { self.expectationDidSelect?.fulfillAndLog() } + var expectationDidDeselect: XCTestExpectation? + func didDeelect(with coordinator: (any CellEventCoordinator)?) { + self.expectationDidDeselect?.fulfillAndLog() + } + var expectationWillDisplay: XCTestExpectation? func willDisplay() { self.expectationWillDisplay?.fulfillAndLog() diff --git a/Tests/Fakes/FakeCollectionViewModel.swift b/Tests/Fakes/FakeCollectionViewModel.swift index a2b63da..9c7a162 100644 --- a/Tests/Fakes/FakeCollectionViewModel.swift +++ b/Tests/Fakes/FakeCollectionViewModel.swift @@ -140,6 +140,7 @@ extension XCTestCase { if useNibs { var viewModel = FakeCellNibViewModel(id: id) viewModel.expectationDidSelect = self._expectation(expectationFields, target: .didSelect, id: viewModel.id, function: function) + viewModel.expectationDidDeselect = self._expectation(expectationFields, target: .didDeselect, id: viewModel.id, function: function) viewModel.expectationConfigureCell = self._expectation(expectationFields, target: .configure, id: viewModel.id, function: function) viewModel.expectationWillDisplay = self._expectation(expectationFields, target: .willDisplay, id: viewModel.id, function: function) viewModel.expectationDidEndDisplaying = self._expectation(expectationFields, target: .didEndDisplaying, id: viewModel.id, function: function) @@ -151,6 +152,7 @@ extension XCTestCase { if cellIndex.isMultiple(of: 2) { var viewModel = FakeNumberCellViewModel(model: .init(id: id)) viewModel.expectationDidSelect = self._expectation(expectationFields, target: .didSelect, id: viewModel.id, function: function) + viewModel.expectationDidDeselect = self._expectation(expectationFields, target: .didDeselect, id: viewModel.id, function: function) viewModel.expectationConfigureCell = self._expectation(expectationFields, target: .configure, id: viewModel.id, function: function) viewModel.expectationWillDisplay = self._expectation(expectationFields, target: .willDisplay, id: viewModel.id, function: function) viewModel.expectationDidEndDisplaying = self._expectation(expectationFields, target: .didEndDisplaying, id: viewModel.id, function: function) @@ -161,6 +163,7 @@ extension XCTestCase { var viewModel = FakeTextCellViewModel(model: .init(text: id)) viewModel.expectationDidSelect = self._expectation(expectationFields, target: .didSelect, id: viewModel.id, function: function) + viewModel.expectationDidDeselect = self._expectation(expectationFields, target: .didDeselect, id: viewModel.id, function: function) viewModel.expectationConfigureCell = self._expectation(expectationFields, target: .configure, id: viewModel.id, function: function) viewModel.expectationWillDisplay = self._expectation(expectationFields, target: .willDisplay, id: viewModel.id, function: function) viewModel.expectationDidEndDisplaying = self._expectation(expectationFields, target: .didEndDisplaying, id: viewModel.id, function: function) diff --git a/Tests/Fakes/FakeNumberModel.swift b/Tests/Fakes/FakeNumberModel.swift index 31de7b3..3a7e0be 100644 --- a/Tests/Fakes/FakeNumberModel.swift +++ b/Tests/Fakes/FakeNumberModel.swift @@ -32,6 +32,10 @@ struct FakeNumberCellViewModel: CellViewModel { self.model.id } + var shouldSelect = true + + var shouldDeselect = true + var shouldHighlight = true var contextMenuConfiguration: UIContextMenuConfiguration? @@ -46,6 +50,11 @@ struct FakeNumberCellViewModel: CellViewModel { self.expectationDidSelect?.fulfillAndLog() } + var expectationDidDeselect: XCTestExpectation? + func didDeselect(with coordinator: (any CellEventCoordinator)?) { + self.expectationDidDeselect?.fulfillAndLog() + } + var expectationWillDisplay: XCTestExpectation? func willDisplay() { self.expectationWillDisplay?.fulfillAndLog() @@ -66,8 +75,18 @@ struct FakeNumberCellViewModel: CellViewModel { self.expectationDidUnhighlight?.fulfillAndLog() } - init(model: FakeNumberModel = FakeNumberModel()) { + init( + model: FakeNumberModel = FakeNumberModel(), + shouldSelect: Bool = true, + shouldDeselect: Bool = true, + shouldHighlight: Bool = true, + contextMenuConfiguration: UIContextMenuConfiguration? = nil + ) { self.model = model + self.shouldSelect = shouldSelect + self.shouldDeselect = shouldDeselect + self.shouldHighlight = shouldHighlight + self.contextMenuConfiguration = contextMenuConfiguration } nonisolated static func == (left: Self, right: Self) -> Bool { diff --git a/Tests/Fakes/FakeTextModel.swift b/Tests/Fakes/FakeTextModel.swift index 3173c37..956fdba 100644 --- a/Tests/Fakes/FakeTextModel.swift +++ b/Tests/Fakes/FakeTextModel.swift @@ -31,6 +31,10 @@ struct FakeTextCellViewModel: CellViewModel { self.model.text } + var shouldSelect = true + + var shouldDeselect = true + var shouldHighlight = true var contextMenuConfiguration: UIContextMenuConfiguration? @@ -45,6 +49,11 @@ struct FakeTextCellViewModel: CellViewModel { self.expectationDidSelect?.fulfillAndLog() } + var expectationDidDeselect: XCTestExpectation? + func didDeselect(with coordinator: (any CellEventCoordinator)?) { + self.expectationDidDeselect?.fulfillAndLog() + } + var expectationWillDisplay: XCTestExpectation? func willDisplay() { self.expectationWillDisplay?.fulfillAndLog() @@ -67,10 +76,14 @@ struct FakeTextCellViewModel: CellViewModel { init( model: FakeTextModel = FakeTextModel(), + shouldSelect: Bool = true, + shouldDeselect: Bool = true, shouldHighlight: Bool = true, contextMenuConfiguration: UIContextMenuConfiguration? = nil ) { self.model = model + self.shouldSelect = shouldSelect + self.shouldDeselect = shouldDeselect self.shouldHighlight = shouldHighlight self.contextMenuConfiguration = contextMenuConfiguration } diff --git a/Tests/TestCellEventCoordinator.swift b/Tests/TestCellEventCoordinator.swift index da15885..91af508 100644 --- a/Tests/TestCellEventCoordinator.swift +++ b/Tests/TestCellEventCoordinator.swift @@ -50,4 +50,28 @@ final class TestCellEventCoordinator: UnitTestCase, @unchecked Sendable { self.keepDriverAlive(driver) } + + @MainActor + func test_didDeselectCell_getsCalled() { + let cell = FakeCellViewModel() + let section = SectionViewModel(id: "id", cells: [cell]) + let model = CollectionViewModel(id: "id", sections: [section]) + + let coordinator = FakeCellEventCoordinator() + coordinator.expectationDidDeselect = self.expectation() + + let driver = CollectionViewDriver( + view: self.collectionView, + viewModel: model, + options: .test(), + cellEventCoordinator: coordinator + ) + + let indexPath = IndexPath(item: 0, section: 0) + driver.collectionView(self.collectionView, didDeselectItemAt: indexPath) + + self.waitForExpectations() + + self.keepDriverAlive(driver) + } } diff --git a/Tests/TestCellViewModel.swift b/Tests/TestCellViewModel.swift index 371ab73..5a9921d 100644 --- a/Tests/TestCellViewModel.swift +++ b/Tests/TestCellViewModel.swift @@ -20,6 +20,8 @@ final class TestCellViewModel: XCTestCase { @MainActor func test_CellViewModel_protocol_default_values() { let viewModel = FakeCellViewModel() + XCTAssertTrue(viewModel.shouldSelect) + XCTAssertTrue(viewModel.shouldDeselect) XCTAssertTrue(viewModel.shouldHighlight) XCTAssertNil(viewModel.contextMenuConfiguration) @@ -49,6 +51,9 @@ final class TestCellViewModel: XCTestCase { viewModel.expectationDidSelect = self.expectation(field: .didSelect, id: viewModel.id) viewModel.expectationDidSelect?.expectedFulfillmentCount = 2 + viewModel.expectationDidDeselect = self.expectation(field: .didDeselect, id: viewModel.id) + viewModel.expectationDidDeselect?.expectedFulfillmentCount = 2 + viewModel.expectationWillDisplay = self.expectation(field: .willDisplay, id: viewModel.id) viewModel.expectationWillDisplay?.expectedFulfillmentCount = 2 @@ -66,6 +71,8 @@ final class TestCellViewModel: XCTestCase { XCTAssertEqual(erased.id, viewModel.id) XCTAssertEqual(erased.registration, viewModel.registration) + XCTAssertEqual(erased.shouldSelect, viewModel.shouldSelect) + XCTAssertEqual(erased.shouldDeselect, viewModel.shouldDeselect) XCTAssertEqual(erased.shouldHighlight, viewModel.shouldHighlight) XCTAssertIdentical(erased.contextMenuConfiguration, viewModel.contextMenuConfiguration) XCTAssertTrue(erased.cellClass == viewModel.cellClass) @@ -73,6 +80,7 @@ final class TestCellViewModel: XCTestCase { viewModel.configure(cell: FakeTextCollectionCell()) viewModel.didSelect(with: nil) + viewModel.didDeselect(with: nil) viewModel.willDisplay() viewModel.didEndDisplaying() viewModel.didHighlight() @@ -80,6 +88,7 @@ final class TestCellViewModel: XCTestCase { erased.configure(cell: FakeTextCollectionCell()) erased.didSelect(with: nil) + erased.didDeselect(with: nil) erased.willDisplay() erased.didEndDisplaying() erased.didHighlight() @@ -102,6 +111,8 @@ final class TestCellViewModel: XCTestCase { XCTAssertEqual(erased3.hashValue, viewModel.hashValue) XCTAssertEqual(erased3.id, viewModel.id) XCTAssertEqual(erased3.registration, viewModel.registration) + XCTAssertEqual(erased3.shouldSelect, viewModel.shouldSelect) + XCTAssertEqual(erased3.shouldDeselect, viewModel.shouldDeselect) XCTAssertEqual(erased3.shouldHighlight, viewModel.shouldHighlight) XCTAssertIdentical(erased3.contextMenuConfiguration, viewModel.contextMenuConfiguration) XCTAssertTrue(erased3.cellClass == viewModel.cellClass) @@ -115,6 +126,8 @@ final class TestCellViewModel: XCTestCase { XCTAssertEqual(erased4.hashValue, viewModel.hashValue) XCTAssertEqual(erased4.id, viewModel.id) XCTAssertEqual(erased4.registration, viewModel.registration) + XCTAssertEqual(erased4.shouldSelect, viewModel.shouldSelect) + XCTAssertEqual(erased4.shouldDeselect, viewModel.shouldDeselect) XCTAssertEqual(erased4.shouldHighlight, viewModel.shouldHighlight) XCTAssertIdentical(erased4.contextMenuConfiguration, viewModel.contextMenuConfiguration) XCTAssertTrue(erased4.cellClass == viewModel.cellClass) diff --git a/Tests/TestCollectionViewDriver.swift b/Tests/TestCollectionViewDriver.swift index 80d86d7..0bc9eb7 100644 --- a/Tests/TestCollectionViewDriver.swift +++ b/Tests/TestCollectionViewDriver.swift @@ -50,10 +50,10 @@ final class TestCollectionViewDriver: UnitTestCase, @unchecked Sendable { } @MainActor - func test_delegate_didSelectItemAt_calls_cellViewModel() { + func test_delegate_didSelect_didDeselect_calls_cellViewModel() { let sections = 2 let cells = 5 - let model = self.fakeCollectionViewModel(numSections: sections, numCells: cells, expectationFields: [.didSelect]) + let model = self.fakeCollectionViewModel(numSections: sections, numCells: cells, expectationFields: [.didSelect, .didDeselect]) let driver = CollectionViewDriver( view: self.collectionView, viewModel: model, @@ -64,6 +64,7 @@ final class TestCollectionViewDriver: UnitTestCase, @unchecked Sendable { for cell in 0..