Replies: 6 comments 20 replies
-
This idea from @stephencelis seems promising - rather than give the cell a store, give it a publisher of stores directly:
|
Beta Was this translation helpful? Give feedback.
-
I'm not 100% convinced readonly State is a given. What I've found is that something like for example async image loading is pretty nice to do in the scope of a cell. It also means that cancellation when a cell moves off screen can be achieved by cancelling an effect loading the image via an action sent from I usually do the image loading and scaling/cropping via an "ImageClient" on the environment that stores a ready to use image at a given URL and then returns the URL, which updates the state that triggers a This could of course be achieved in the parent reducer as well. But makes sense to me to keep it local.
I wonder if this is desirable? Say you have a collection view with thousands of items and the user scrolls through them all. Would that mean thousands of active subscriptions that can potentially trigger store updates even when the cell is off-screen? I guess the way I do it the scoped stores are all sticking around, but no one is observing them.
What I tend to do is to have a computed property on my view state that is basically just a list of ids representing the items in the collection view. Sometimes ordered by sections in a multi-dimensional array. I then use a typealias DataSource = UICollectionViewDiffableDataSource<ListSectionType, ListItem.Id>
typealias Snapshot = NSDiffableDataSourceSnapshot<ListSectionType, ListItem.Id> In the cell configuration step I then scope the store to a given id: self.store.scope(
state: { $0.cellStates[id: itemId] },
action: { ListAction.listCellAction(id: item, action: $0) }
).ifLet { store in
cell.configure(with: store)
}.store(in: &self.cellStores, for: item) Where in this case One of the things I've been struggling the most with though is how to handle cell state vs. model. I find I often want to "augment" the state of the cell with additional data — not only the model being displayed. Like for example the above mentioned Deleted my previous comment to keep things clean. |
Beta Was this translation helpful? Give feedback.
-
Has anyone found a rather clean way to utilize both TCA and UICollectionViewDiffableDataSource in their projects since this ticket was last updated almost a year ago? Unfortunately, I can't find any examples that are widely embraced by the community :( @heyltsjay are you still utilizing the same pattern? Or is there something you're now doing that is not reflected in this discussion? Edit: does anyone see any problems with this tutorial: https://www.iamsim.me/composable-architecture-and-uikit-collection-views/ ? Seems like the only example out there that can be used as a guide. |
Beta Was this translation helpful? Give feedback.
-
This is a sketch for my current recommendation for best practice, which should cover 90% of use cases.
// State, Action, Reducer definitions
extension Parent {
struct State {
let data: [Model]
}
enum Action {
case cell(id: String, action: Cell.Action)
}
}
// Snapshot is a derived property of state
extension Parent.State {
typealias Section = Parent.Section
typealias Item = Parent.Item
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>
var snapshot: Snapshot {
var snapshot = Snapshot()
if !data.isEmpty {
snapshot.appendSections([.data])
snapshot.appendItems([data.map(Item.data)])
}
}
}
// Parent sets up diffable datasource, and populates cell
class Parent: UIViewController {
init() {
super.init(nibName: nil, bundle: nil)
// set-up view
// ...
//
observeDataSource()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension Parent: UITableViewDelegate {
enum Section: Hashable, CaseIterable {
case data
}
enum Item: Hashable, Equatable {
case data(Model)
}
func observeDataSource() {
viewStore.publisher.snapshot
.receive(on: DispatchQueue.main)
.sink { [weak self] snapshot in
self?.tableViewDataSource.apply(snapshot)
}
.store(in: &cancellables)
}
func cell(tableView: UITableView, indexPath: IndexPath, item: Item) -> UITableViewCell {
switch item {
case .data(let model):
let cell: Cell = tableView.dequeueReusableCell(withIdentifier: "\(Cell.self)")
cell.state = .init(
id: model.id,
title: model.title
)
cell.send = { [weak self] in
self?.viewStore.send(.cell(id: model.id, action: $0))
}
}
}
}
// Cell has readonly state, and sends actions to a `send` closure
class Cell: UITableViewCell {
struct State: Hashable, Equatable {
var id: String = ""
var title: String?
}
enum Action {
case ctaTapped
}
@Published var state = State()
var send: ((Action) -> Void)?
func onCTATapped() {
send?(.ctaTapped)
}
} |
Beta Was this translation helpful? Give feedback.
-
Doesn’t have to be UIKit would be similar in SwiftUI.On 22 Sep 2022, at 15:00, Jay Clark ***@***.***> wrote:
This is a sketch for my current recommendation for best practice, which should cover 90% of use cases.
Cell has read-only state, no environment, no reducer, and makes no decisions
Actions are forwarded using a send closure, similar to UIKit alerts + TCA
extension Parent {
struct State {
let data: [Model]
}
enum Action {
case cell(id: String, action: Cell.Action)
}
}
extension Parent.State {
typealias Section = Parent.Section
typealias Item = Parent.Item
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>
var snapshot: Snapshot {
var snapshot = Snapshot()
if !data.isEmpty {
snapshot.appendSections([.data])
snapshot.appendItems([data.map(Item.data)])
}
}
}
class Parent: UIViewController {
init() {
super.init(nibName: nil, bundle: nil)
// set-up view
// ...
//
observeDataSource()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension Parent: UITableViewDelegate {
enum Section: Hashable, CaseIterable {
case data
}
enum Item: Hashable, Equatable {
case data(Model)
}
func observeDataSource() {
viewStore.publisher.snapshot
.receive(on: DispatchQueue.main)
.sink { [weak self] snapshot in
self?.tableViewDataSource.apply(snapshot)
}
.store(in: &cancellables)
}
func cell(tableView: UITableView, indexPath: IndexPath, item: Item) -> UITableViewCell {
switch item {
case .data(let model):
let cell: Cell = tableView.dequeueReusableCell(withIdentifier: "\(Cell.self)")
cell.state = .init(
id: model.id,
title: model.title
)
cell.send = { [weak self] in
self?.viewStore.send(.cell(id: model.id, action: $0))
}
}
}
}
class Cell: UITableViewCell {
struct State: Hashable, Equatable {
var id: String = ""
var title: String?
}
enum Action {
case ctaTapped
}
@published var state = State()
var send: ((Action) -> Void)?
func onCTATapped() {
send?(.ctaTapped)
}
}
—Reply to this email directly, view it on GitHub, or unsubscribe.You are receiving this because you were mentioned.Message ID: ***@***.***>
|
Beta Was this translation helpful? Give feedback.
-
Has anyone been able to figure out how to handle cells comprising a toggle switch? I'm stuck on trying to figure out the most appropriate way to handle this from a conceptual level. For example, suppose we have a view controller that displays one section with a navigation cell and a toggle switch cell: let viewController = TableViewController(
store: Store(
initialState: .init(
section: .init(
header: "Options",
items: [
TableViewItem(
title: "Switch Cell",
subtitle: "Toggles a switch and does something useful afterwards",
isOn: ??????
)
]
)
),
reducer: TableViewExample()
)
) What I can't wrap my head around is what I pass to TableViewItem(
isOn: { toggleValue in
@Dependency(\.userDefaults) var userDefaults: UserDefaultsClient
userDefaults.userInterfaceStyle = toggleValue ? .dark : .light
}
) This is a no-no in TCA-world because an external dependency has been used to modify state in some manner but there is no way the Reducer would be aware of this, making it untestable. Perhaps what the parameters should be is an action, so it'd look like this: TableViewItem(
isOn: viewStore.send(.darkModeToggleSwitchTapped(nil))
)
// or
TableViewItem(
isOn: /Action.darkModeToggleSwitchTapped(nil)
) Then, in the reducer I could do something like this: switch action {
case let .darkModeToggleSwitchTapped(toggleValue):
if let toggleValue {
state.userSettings.userInterfaceStyle = toggleValue ? .dark : .light
}
} Or maybe I could even utilize TableViewItem.ID in a way that makes it possible to know which cell received the action: enum TableViewItemID {
static let darkModeToggleCell = UUID()
}
TableViewItem(
id: TableViewItemID.darkModeToggleCell
)
Reducer { state, action in
switch action {
case let tableViewCellTapped(cellId):
switch cellId {
case is TableViewItemID.darkModeToggleCell:
state.userSettings.userInterfaceStyle = toggleValue ? .dark : .light
}
}
} The second option actually seems the most practical, with the least amount of boilerplate code. Is this the way I should go about it or does anyone have any better suggestions? |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
The DataSource pattern in UIKit is an awkward seam for TCA to transcend - The usual pattern of "subscribe to a scoped store, re-render", can be dangerous if a collectionView.dataSource isn't updated in-step.
One very feasible solution is to not model cell state with TCA - I'd wager that cells should never manipulate their own state directly. But the
Action
handling of TCA is too compelling to ignore.I'd love to drive towards an example implementation that is a bit more robust than what exists in the CaseStudies today.
One approach I explored in #472 is exposing some synchronous unwrapping of stores. This removes some of the optional-but-not-really babysitting that a few other TCA/UIKit developers are using right now. Now moving the discussion over here.
Some "how might we's" around this domain:
State
is readonly, but it can emitAction
sViewStore.dataSource
andCollectionView.datasource
in sync, so that mismatching indices fail fast and hard.Beta Was this translation helpful? Give feedback.
All reactions