iosmvvmviewmodelrx-swiftrx-cocoa

How to handle user interactions in subviews with iOS MVVM


For implementing MVVM with a table view controller, it is often to have a single parent view model and a bunch of child view models for each cell. Let's say each cell has a like button, and now the user taps one of the like buttons.

After searching through stack overflow, I see three possible ways to handle the flow:

  1. The tapped action is sent to the child view model, the child view model handles the like action internally.

  2. The tapped action is sent to the child view model, the child view models pass the intent to the parent view model, which handles the like action.

  3. The tapped action is sent to the table view controller (using cell delegate or closure), the table view controller then pass the tapped action to the parent view model.

Personally, I prefer the 2nd and 3rd approach. What confuses me is that in the 3rd approach, the child view model is only responsible for the output (presenting data), but not for the input (handling interactions). Instead, the parent view model is responsible for handling the child view's interaction. Because normally a view model will handle both for its view.

Which approach is better? Or are there better ways to achieve the same goal?
Any advise would be helpful.


Solution

  • The first question I would ask myself in this situation is whether any state in the view controller has to change based on inputs into the cell. If so, then option 2 is the best choice (assuming RxSwift.) If you are using delegates/closures instead of Observables then option 3 is handy to reduce the number of delegates required.

    From your description, it doesn't sound like the view controller's state needs to be updated when the user taps the button, so option 1 sounds like the best.

    Here is how I would likely implement it using my CLE architecture... You can think of the connect functions as view models.

    extension ViewController {
        // The view controller doesn't have much to do. Just fetch the array of
        // likables and show them on the table view.
        func connect(api: API) {
            api.response(.fetchLikables)
                .bind(to: tableView.rx.items(cellIdentifier: "Cell", cellType: LikableCell.self)) { _, item, cell in
                    cell.connect(api: api, item: item)
                }
                .disposed(by: disposeBag)
    
            api.error
                .map { $0.localizedDescription }
                .bind(onNext: presentScene(animated: true) { message in
                    UIAlertController(title: "Error", message: message, preferredStyle: .alert).scene {
                        $0.connectOK()
                    }
                })
                .disposed(by: disposeBag)
        }
    }
    
    extension LikableCell {
        // The cell has the interesting behavior. If the thing is liked, then the
        // `likeButton` is selected. When the button is tapped, update the state
        // and send the network request. If the request fails, then reset the state.
        func connect(api: API, item: LikableThing) {
            enum Input {
                case tap
                case updateSucceeded
                case updateFailed
            }
            cycle(
                input: likeButton.rx.tap.map(to: Input.tap),
                initialState: (current: item.isLiked, reset: item.isLiked),
                reduce: { state, input in
                    switch input {
                    case .tap:
                        state.current.toggle() // toggle the like state right away.
                    case .updateSucceeded:
                        state.reset = state.current // if server success, update the reset
                    case .updateFailed:
                        state.current = state.reset // if server fail, update the current state.
                    }
                },
                reaction: { args in
                    args
                        .filter { $0.1 == .tap } // only make network request when user taps
                        .flatMapLatest { state, _ in
                            return api.successResponse(.setLikable(id: item.id, isLiked: !state.current))
                                .map { $0 ? Input.updateSucceeded : Input.updateFailed }
                        }
                }
            )
            .map { $0.current }
            .bind(to: likeButton.rx.isSelected)
            .disposed(by: disposeBag)
        }
    }
    
    struct LikableThing: Decodable, Identifiable {
        let id: Identifier<Int, LikableThing>
        let isLiked: Bool
    }
    
    extension Endpoint where Response == [LikableThing] {
        static let fetchLikables: Endpoint = Endpoint(
            request: apply(URLRequest(url: baseURL)) { request in
                // configure request
            },
            decoder: JSONDecoder()
        )
    }
    
    extension Endpoint where Response == Void {
        static func setLikable(id: LikableThing.ID, isLiked: Bool) -> Endpoint {
            let request = URLRequest(url: baseURL)
            // configure request
            return Endpoint(request: request)
        }
    }