Last active
February 28, 2020 06:32
-
-
Save rjchatfield/aab6d91a2c3e0880b3edb3f367dc161a to your computer and use it in GitHub Desktop.
UITableView DataSource/Delegate using State/Action/Reducer
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]) | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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? | |
//} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I'm experimenting with converting an existing Delegate pattern implementation to Actions & State, and visa versa.
Action
.State
.This is an example of migrating
UITableViewDataSource
&UITableViewDelegate
toUITableView.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
'sdelegate
can be replaced byStore<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 ofSection
andRow
and allow the integrator to use any data they like. That data would need to beIdentifiable
andEquatable
(probablyHashable
).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!
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.