Skip to content

Instantly share code, notes, and snippets.

@rjchatfield
Last active February 28, 2020 06:32
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rjchatfield/aab6d91a2c3e0880b3edb3f367dc161a to your computer and use it in GitHub Desktop.
Save rjchatfield/aab6d91a2c3e0880b3edb3f367dc161a to your computer and use it in GitHub Desktop.
UITableView DataSource/Delegate using State/Action/Reducer
import UIKit
extension UITableView {
struct State {
var sections: [Section]
var sectionIndexTitles: [String]?
struct Section {
var rows: [Row]
var titleForHeader: String?
var titleForFooter: String?
var heightForHeader: Height
var heightForFooter: Height
}
struct Row {
var canEdit: Bool
var canMove: Bool
var height: Height
var shouldHighlight: Bool
var editingStyle: UITableViewCell.EditingStyle
var titleForDeleteConfirmationButton: String?
var leadingSwipeActionsConfiguration: UISwipeActionsConfiguration?
var trailingSwipeActionsConfiguration: UISwipeActionsConfiguration?
var shouldIndentWhileEditing: Bool
var indentationLevel: Int
var shouldShowMenu: Bool
var shouldBeginMultipleSelectionInteraction: Bool
}
enum Height {
case fixed(CGFloat)
case estimated(CGFloat)
}
}
}
extension UITableView.State.Height {
var fixedHeight: CGFloat {
switch self {
case .fixed(let value):
return value
case .estimated:
return UITableView.automaticDimension
}
}
var estimatedHeight: CGFloat {
switch self {
case .fixed(let value),
.estimated(let value):
return value
}
}
}
extension UITableView.State {
var rows: [[Row]] { sections.map { $0.rows } }
}
extension RandomAccessCollection where Index == Int, Element: RandomAccessCollection, Element.Index == Int {
subscript(indexPath: IndexPath) -> Element.Element {
self[indexPath.section][indexPath.row]
}
}
extension UITableView {
enum Action {
case section(Int, SectionAction)
case row(IndexPath, RowAction)
case didEndEditing(IndexPath?)
case didEndMultipleSelectionInteraction
case moveRow(source: IndexPath, destination: IndexPath)
case prefetch(PrefetchAction)
enum SectionAction {
case willDisplayHeader(UIView)
case willDisplayFooter(UIView)
case didEndDisplayingHeader(UIView)
case didEndDisplayingFooter(UIView)
}
enum RowAction {
case willDisplay(UITableViewCell)
case didEndDisplaying(UITableViewCell)
case accessoryButtonTapped
case didHighlight
case didUnhighlight
// case willSelect // -> IndexPath?
// case willDeselect // -> IndexPath?
case didSelect
case didDeselect
case willBeginEditing
case didEndEditing
case commitEditingStyle(UITableViewCell.EditingStyle)
case didBeginMultipleSelectionInteraction
}
enum PrefetchAction {
case start(rows: [IndexPath])
case cancel(rows: [IndexPath])
}
}
}
class TableController: NSObject {
var store: Store<UITableView.State, UITableView.Action>!
}
extension TableController: UITableViewDataSource {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { fatalError("Storing view's in State isn't okay") }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { store.state.sections[section].rows.count }
func numberOfSections(in tableView: UITableView) -> Int { store.state.sections.count }
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { store.state.sections[section].titleForHeader }
func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { store.state.sections[section].titleForFooter }
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { store.state.rows[indexPath].canEdit }
func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { store.state.rows[indexPath].canMove }
func sectionIndexTitles(for tableView: UITableView) -> [String]? { store.state.sectionIndexTitles }
func tableView(_ tableView: UITableView, sectionForSectionIndexTitle title: String, at index: Int) -> Int { store.state.sections.firstIndex(where: { $0.titleForFooter == title }) ?? 0 }
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { store.send(.row(indexPath, .commitEditingStyle(editingStyle))) }
func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { store.send(.moveRow(source: sourceIndexPath, destination: destinationIndexPath)) }
}
extension TableController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { store.send(.prefetch(.start(rows: indexPaths))) }
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { store.send(.prefetch(.cancel(rows: indexPaths))) }
}
extension TableController: UITableViewDelegate {
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { store.send(.row(indexPath, .willDisplay(cell))) }
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { store.send(.section(section, .willDisplayHeader(view))) }
func tableView(_ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int) { store.send(.section(section, .willDisplayFooter(view))) }
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { store.send(.row(indexPath, .didEndDisplaying(cell))) }
func tableView(_ tableView: UITableView, didEndDisplayingHeaderView view: UIView, forSection section: Int) { store.send(.section(section, .didEndDisplayingHeader(view))) }
func tableView(_ tableView: UITableView, didEndDisplayingFooterView view: UIView, forSection section: Int) { store.send(.section(section, .didEndDisplayingFooter(view))) }
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { store.state.rows[indexPath].height.fixedHeight }
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { store.state.sections[section].heightForHeader.fixedHeight }
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { store.state.sections[section].heightForFooter.fixedHeight }
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { store.state.rows[indexPath].height.estimatedHeight }
func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat { store.state.sections[section].heightForHeader.estimatedHeight }
func tableView(_ tableView: UITableView, estimatedHeightForFooterInSection section: Int) -> CGFloat { store.state.sections[section].heightForFooter.estimatedHeight }
// func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView // custom view for header. will be adjusted to default or specified header height
// func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView // custom view for footer. will be adjusted to default or specified footer height
func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) { store.send(.row(indexPath, .accessoryButtonTapped)) }
func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { store.state.rows[indexPath].shouldHighlight }
func tableView(_ tableView: UITableView, didHighlightRowAt indexPath: IndexPath) { store.send(.row(indexPath, .didHighlight)) }
func tableView(_ tableView: UITableView, didUnhighlightRowAt indexPath: IndexPath) { store.send(.row(indexPath, .didUnhighlight)) }
// func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { nil }
// func tableView(_ tableView: UITableView, willDeselectRowAt indexPath: IndexPath) -> IndexPath? { nil }
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { store.send(.row(indexPath, .didSelect)) }
func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { store.send(.row(indexPath, .didDeselect)) }
func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { store.state.rows[indexPath].editingStyle }
func tableView(_ tableView: UITableView, titleForDeleteConfirmationButtonForRowAt indexPath: IndexPath) -> String? { store.state.rows[indexPath].titleForDeleteConfirmationButton }
func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { store.state.rows[indexPath].leadingSwipeActionsConfiguration }
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { store.state.rows[indexPath].trailingSwipeActionsConfiguration }
func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { store.state.rows[indexPath].shouldIndentWhileEditing }
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) { store.send(.row(indexPath, .willBeginEditing)) }
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) { store.send(.didEndEditing(indexPath)) }
// func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath
func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int { store.state.rows[indexPath].indentationLevel }
func tableView(_ tableView: UITableView, shouldShowMenuForRowAt indexPath: IndexPath) -> Bool { store.state.rows[indexPath].shouldShowMenu }
// func tableView(_ tableView: UITableView, shouldSpringLoadRowAt indexPath: IndexPath, with context: UISpringLoadedInteractionContext) -> Bool {}
func tableView(_ tableView: UITableView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool { store.state.rows[indexPath].shouldBeginMultipleSelectionInteraction }
func tableView(_ tableView: UITableView, didBeginMultipleSelectionInteractionAt indexPath: IndexPath) { store.send(.row(indexPath, .didBeginMultipleSelectionInteraction)) }
func tableViewDidEndMultipleSelectionInteraction(_ tableView: UITableView) { store.send(.didEndMultipleSelectionInteraction) }
// func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {}
// func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {}
// func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {}
// func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {}
}
//@available(iOS 11.0, *)
//extension TableController: UITableViewDragDelegate {
// func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem]
// func tableView(_ tableView: UITableView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem]
// func tableView(_ tableView: UITableView, dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters?
// func tableView(_ tableView: UITableView, dragSessionWillBegin session: UIDragSession)
// func tableView(_ tableView: UITableView, dragSessionDidEnd session: UIDragSession)
// func tableView(_ tableView: UITableView, dragSessionAllowsMoveOperation session: UIDragSession) -> Bool
// func tableView(_ tableView: UITableView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool
//}
//@available(iOS 11.0, *)
//public protocol UITableViewDropDelegate : NSObjectProtocol {
// func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator)
// func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool
// func tableView(_ tableView: UITableView, dropSessionDidEnter session: UIDropSession)
// func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal
// func tableView(_ tableView: UITableView, dropSessionDidExit session: UIDropSession)
// func tableView(_ tableView: UITableView, dropSessionDidEnd session: UIDropSession)
// func tableView(_ tableView: UITableView, dropPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters?
//}
func reduce(_ state: inout UITableView.State, _ action: UITableView.Action) {
switch action {
case .section(let section, .willDisplayHeader(let view)): break
case .section(let section, .willDisplayFooter(let view)): break
case .section(let section, .didEndDisplayingHeader(let view)): break
case .section(let section, .didEndDisplayingFooter(let view)): break
case .row(let indexPath, .willDisplay(let cell)): break
case .row(let indexPath, .didEndDisplaying(let cell)): break
case .row(let indexPath, .accessoryButtonTapped): break
case .row(let indexPath, .didHighlight): break
case .row(let indexPath, .didUnhighlight): break
case .row(let indexPath, .didSelect): break
case .row(let indexPath, .didDeselect): break
case .row(let indexPath, .willBeginEditing): break
case .row(let indexPath, .didEndEditing): break
case .row(let indexPath, .commitEditingStyle(let editingStyle)): break
case .row(let indexPath, .didBeginMultipleSelectionInteraction): break
case .didEndEditing(let optionalIndexPath): break
case .didEndMultipleSelectionInteraction: break
case .moveRow(let source, let destination): break
case .prefetch(.start(let rows)): break
case .prefetch(.cancel(let rows)): break
}
}
@rjchatfield
Copy link
Author

I'm experimenting with converting an existing Delegate pattern implementation to Actions & State, and visa versa.

  • Is it just notifying it's parent? Send an Action.
  • Does it return something? Pull it from the State.

This is an example of migrating UITableViewDataSource & UITableViewDelegate to UITableView.State & UITableView.Action.

Interesting thing is that UIKit could expose a public API of these Structs and Enums without needing to define the Reducer or Effects.
That logic is traditionally done by the integrator by implementing the protocols. All UITableView's delegate can be replaced by Store<State, Action>.

Discussion:

What if we need to return a UIView?

SwiftUI would take the state and render it. But we'd need to hold information in the State for SwiftUI to know what to render. I would expect UITableView.State to be generic of Section and Row and allow the integrator to use any data they like. That data would need to be Identifiable and Equatable (probably Hashable).

What if the getter is where we check state before returning?

We should know everything up front and be able to hold it in the State.

Expect...

What if the delegate method gives us some outside context (dragSession, menuContextConfiguration, CGPoint, etc.) and expects us to give it a value back (ie. a bool for "did handle")?

This is tricky, but let's think this through. Let's take UITableViewDragDelegate for example.

We have the state. And we have the context. We could pass in an implementation of UITableViewDragDelegate that just processes it inline. Like a delegate. And that just sucks.

If we need to do some logic, we need to do it in the Reducer so it is testable. So we should send an Action back to the Reducer so we can make a decision about the latest state. Then we need to store that decision in State. If we send the Action, are we able to pull out the decision immediately? That seems like a smell... but let's try it!

extension UITableView.Action {
    enum DragAction {
        case itemsForBeginning(UITableView, UIDragSession, IndexPath)// -> [UIDragItem]
        case itemsForAddingTo(UITableView, UIDragSession, IndexPath, CGPoint) // -> [UIDragItem]
        case dragPreviewParametersForRowAt(UITableView, IndexPath) // -> UIDragPreviewParameters?
        case dragSessionWillBegin(UITableView, UIDragSession)
        case dragSessionDidEnd(UITableView, UIDragSession)
        case dragSessionAllowsMoveOperation(UITableView, UIDragSession) // -> Bool
        case dragSessionIsRestrictedToDraggingApplication(UITableView, UIDragSession) // -> Bool
    }
    enum DropAction {
        case performDrop(UITableView, UITableViewDropCoordinator)
        case canHandle(UITableView, UIDropSession) // -> Bool
        case dropSessionDidEnter(UITableView, UIDropSession)
        case dropSessionDidUpdate(UITableView, UIDropSession, destinationIndexPath: IndexPath?) // -> UITableViewDropProposal
        case dropSessionDidExit(UITableView, UIDropSession)
        case dropSessionDidEnd(UITableView, UIDropSession)
        case dropPreviewParameters(UITableView, IndexPath) // -> UIDragPreviewParameters?
    }
}

extension UITableView.State {
    struct DragState {
        var itemsForBeginning: [IndexPath: [UIDragItem]]
        var itemsForAddingTo: [IndexPath: [UIDragItem]]
        var dragPreviewParameters: [IndexPath: UIDragPreviewParameters]
        var allowsMoveOperation: Bool
        var isRestrictedToDraggingApplication: Bool
    }
    
    struct DropState {
        var canHandle: Bool
        var dropProposal: [IndexPath?: UITableViewDropProposal]
        var dropPreviewParameters: [IndexPath: UIDragPreviewParameters]
    }
}

extension TableController: UITableViewDragDelegate {
    func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        store.send(.drag(.itemsForBeginning(tableView, session, indexPath)))
        return store.state.drag.itemsForBeginning[indexPath, default: []]
    }
    func tableView(_ tableView: UITableView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] {
        store.send(.drag(.itemsForAddingTo(tableView, session, indexPath, point)))
        return store.state.drag.itemsForAddingTo[indexPath, default: []]
    }
    func tableView(_ tableView: UITableView, dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? {
        store.send(.drag(.dragPreviewParametersForRowAt(tableView, indexPath)))
        return store.state.drag.dragPreviewParameters[indexPath]
    }
    func tableView(_ tableView: UITableView, dragSessionWillBegin session: UIDragSession) {
        store.send(.drag(.dragSessionWillBegin(tableView, session)))
    }
    func tableView(_ tableView: UITableView, dragSessionDidEnd session: UIDragSession) {
        store.send(.drag(.dragSessionDidEnd(tableView, session)))
    }
    func tableView(_ tableView: UITableView, dragSessionAllowsMoveOperation session: UIDragSession) -> Bool {
        store.send(.drag(.dragSessionAllowsMoveOperation(tableView, session)))
        return store.state.drag.allowsMoveOperation
    }
    func tableView(_ tableView: UITableView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool {
        store.send(.drag(.dragSessionIsRestrictedToDraggingApplication(tableView, session)))
        return store.state.drag.isRestrictedToDraggingApplication
    }
}

extension TableController: UITableViewDropDelegate {
    func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
        store.send(.drop(.performDrop(tableView, coordinator)))
    }
    func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool {
        store.send(.drop(.canHandle(tableView, session)))
        return store.state.drop.canHandle
    }
    func tableView(_ tableView: UITableView, dropSessionDidEnter session: UIDropSession) {
        store.send(.drop(.dropSessionDidEnter(tableView, session)))
    }
    func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
        store.send(.drop(.dropSessionDidUpdate(tableView, session, destinationIndexPath: destinationIndexPath)))
        return store.state.drop.dropProposal[destinationIndexPath, default: UITableViewDropProposal(operation: .cancel)]
    }
    func tableView(_ tableView: UITableView, dropSessionDidExit session: UIDropSession) {
        store.send(.drop(.dropSessionDidExit(tableView, session)))
    }
    func tableView(_ tableView: UITableView, dropSessionDidEnd session: UIDropSession) {
        store.send(.drop(.dropSessionDidEnd(tableView, session)))
    }
    func tableView(_ tableView: UITableView, dropPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? {
        store.send(.drop(.dropPreviewParameters(tableView, indexPath)))
        return store.state.drop.dropPreviewParameters[indexPath]
    }
}

// ...

func reduce(_ state: inout UITableView.State, _ action: UITableView.Action) {
    switch action {
    // ...
    case .drag(.itemsForBeginning(let tableView, let dragSession, let indexPath)): break// -> [UIDragItem])
    case .drag(.itemsForAddingTo(let tableView, let dragSession, let indexPath, let point)): break // -> [UIDragItem])
    case .drag(.dragPreviewParametersForRowAt(let tableView, let indexPath)): break // -> UIDragPreviewParameters?)
    case .drag(.dragSessionWillBegin(let tableView, let dragSession)): break
    case .drag(.dragSessionDidEnd(let tableView, let dragSession)): break
    case .drag(.dragSessionAllowsMoveOperation(let tableView, let dragSession)): break // -> Bool)
    case .drag(.dragSessionIsRestrictedToDraggingApplication(let tableView, let dragSession)): break // -> Bool)
    case .drop(.performDrop(let tableView, let tableViewDropCoordinator)): break
    case .drop(.canHandle(let tableView, let dropSession)): break // -> Bool
    case .drop(.dropSessionDidEnter(let tableView, let dropSession)): break
    case .drop(.dropSessionDidUpdate(let tableView, let dropSession, let destinationIndexPath)): break // -> UITableViewDropProposal
    case .drop(.dropSessionDidExit(let tableView, let dropSession)): break
    case .drop(.dropSessionDidEnd(let tableView, let dropSession)): break
    case .drop(.dropPreviewParameters(let tableView, let indexPath)): break // -> UIDragPreviewParameters?
    }
}

That's not too bad. It's workable. The TableController still doesn't know anything about the reducer, effects or have any custom logic or have any custom state. But we have now forced the integrator to handle state updates. Failing to do so could break this underlying implementation. And obviously that is always true for all code - but in contrast we used to inline this into a function that gave us values and needed a return value.

As a side not to answer this, while building simpler components, I'm going to aim not to do this. Actors shouldn't send messages back and forth and expect immediate results.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment