Skip to content

Commit

Permalink
Implement additional Single Selection APIs (#127)
Browse files Browse the repository at this point in the history
Issue #94

Add `shouldSelect`, `shouldDeselect`, `didDeselect()`
  • Loading branch information
nuomi1 authored Oct 2, 2024
1 parent 8083642 commit 64110ca
Show file tree
Hide file tree
Showing 15 changed files with 206 additions and 6 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
7 changes: 6 additions & 1 deletion Example/Sources/Grid/GridViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions Example/Sources/Grid/PersonCellViewModelGrid.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
7 changes: 7 additions & 0 deletions Sources/CellEventCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 }
}
Expand Down
52 changes: 51 additions & 1 deletion Sources/CellViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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()
Expand All @@ -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 }

Expand All @@ -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() { }

Expand Down Expand Up @@ -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 }

Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions Sources/CollectionViewDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions Tests/Fakes/FakeCellEventCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
5 changes: 5 additions & 0 deletions Tests/Fakes/FakeCellNibView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions Tests/Fakes/FakeCollectionViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
21 changes: 20 additions & 1 deletion Tests/Fakes/FakeNumberModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ struct FakeNumberCellViewModel: CellViewModel {
self.model.id
}

var shouldSelect = true

var shouldDeselect = true

var shouldHighlight = true

var contextMenuConfiguration: UIContextMenuConfiguration?
Expand All @@ -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()
Expand All @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions Tests/Fakes/FakeTextModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ struct FakeTextCellViewModel: CellViewModel {
self.model.text
}

var shouldSelect = true

var shouldDeselect = true

var shouldHighlight = true

var contextMenuConfiguration: UIContextMenuConfiguration?
Expand All @@ -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()
Expand All @@ -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
}
Expand Down
24 changes: 24 additions & 0 deletions Tests/TestCellEventCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading

0 comments on commit 64110ca

Please sign in to comment.