johnpatrickmorgan / TCACoordinators

Powerful navigation in the Composable Architecture via the coordinator pattern
MIT License
443 stars 43 forks source link

TCARouter doesn't work with ViewStore.publisher? #19

Closed josephktcheung closed 2 years ago

josephktcheung commented 2 years ago

Hi,

We recently faced a weird issue using TCARouter with SwiftUI view that wraps a UIKit view. Somehow it stops working when we include the view as a screen inside TCARouter (new value sent by viewStore.publisher doesn't reflect on UI).

Here's a modified example app of TCACoordinators project:

TCACoorindatorExampleApp.swift

// TCACoorindatorExampleApp.swift
import SwiftUI
import ComposableArchitecture
import TCACoordinators

@main
struct TCACoordinatorsExampleApp: App {

  var body: some Scene {
    WindowGroup {
      MainTabCoordinatorView(
        store: .init(
          initialState: .initialState,
          reducer: mainTabCoordinatorReducer,
          environment: .init()
        )
      )
    }
  }
}

// MainTabCoordinator

struct MainTabCoordinatorView: View {

  let store: Store<MainTabCoordinatorState, MainTabCoordinatorAction>

  var body: some View {
    WithViewStore(store) { viewStore in
      TabView {
        GameCoordinatorView(
          store: store.scope(
            state: \MainTabCoordinatorState.gameTCARouter,
            action: MainTabCoordinatorAction.gameTCARouter
          )
        ).tabItem { Text("Game with TCARouter") }
        GameView(
          store: store.scope(
            state: \MainTabCoordinatorState.game,
            action: MainTabCoordinatorAction.game
          )
        ).tabItem { Text("Game without TCARouter") }
      }
    }
  }
}

enum MainTabCoordinatorAction {
  case gameTCARouter(GameCoordinatorAction)
  case game(GameAction)
}

struct MainTabCoordinatorState: Equatable {

  static let initialState = MainTabCoordinatorState(
    gameTCARouter: .initialState,
    game: .init(oPlayerName: "John", xPlayerName: "Peter")
  )

  var gameTCARouter: GameCoordinatorState
  var game: GameState
}

struct MainTabCoordinatorEnvironment {}

typealias MainTabCoordinatorReducer = Reducer<
  MainTabCoordinatorState, MainTabCoordinatorAction, MainTabCoordinatorEnvironment
>

let mainTabCoordinatorReducer: MainTabCoordinatorReducer = .combine(
  gameCoordinatorReducer
    .pullback(
      state: \MainTabCoordinatorState.gameTCARouter,
      action: /MainTabCoordinatorAction.gameTCARouter,
      environment: { _ in .init() }
    ),
  gameReducer
    .pullback(
      state: \MainTabCoordinatorState.game,
      action: /MainTabCoordinatorAction.game,
      environment: { _ in .init() }
    )
)

GameCoordinator.swift

import SwiftUI
import ComposableArchitecture
import TCACoordinators

enum GameScreenAction {
    case game(GameAction)
}

enum GameScreenState: Equatable, Identifiable {
    case game(GameState)

    var id: UUID {
        switch self {
        case .game(let state):
            return state.id
        }
    }
}

struct GameScreenEnvironment {}

let gameScreenReducer = Reducer<GameScreenState, GameScreenAction, GameScreenEnvironment>.combine(
    gameReducer
        .pullback(
            state: /GameScreenState.game,
            action: /GameScreenAction.game,
            environment: { _ in GameEnvironment() }
        )
)

struct GameCoordinatorState: Equatable, IndexedRouterState {
    static let initialState = GameCoordinatorState(
        routes: [.root(.game(.init(oPlayerName: "John", xPlayerName: "Peter")))]
    )

    var routes: [Route<GameScreenState>]
}

enum GameCoordinatorAction: IndexedRouterAction {
    case routeAction(Int, action: GameScreenAction)
    case updateRoutes([Route<GameScreenState>])
}

struct GameCoordinatorEnvironment {}

typealias GameCoordinatorReducer = Reducer<GameCoordinatorState, GameCoordinatorAction, GameCoordinatorEnvironment>

let gameCoordinatorReducer: GameCoordinatorReducer = gameScreenReducer
    .forEachIndexedRoute(environment: { _ in GameScreenEnvironment() })
    .withRouteReducer(Reducer { state, action, environment in
        switch action {
        default:
            break
        }

        return .none
    })

struct GameCoordinatorView: View {
    let store: Store<GameCoordinatorState, GameCoordinatorAction>

    var body: some View {
        TCARouter(store) { screen in
            SwitchStore(screen) {
                CaseLet(
                    state: /GameScreenState.game,
                    action: GameScreenAction.game,
                    then: GameView.init
                )
            }
        }
    }
}

GameView.swift (copied from https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/TicTacToe/tic-tac-toe/Sources/GameUIKit/GameViewController.swift, https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/GameCore.swift and https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/Three.swift)

import Combine
import ComposableArchitecture
import UIKit
import SwiftUI

public struct GameView: UIViewControllerRepresentable {
    let store: Store<GameState, GameAction>

    public typealias UIViewControllerType = GameViewController

    public func makeUIViewController(context: Context) -> GameViewController {
        GameViewController(store: store)
    }

    public func updateUIViewController(_ uiViewController: GameViewController, context: Context) {
    }
}

public final class GameViewController: UIViewController {
  let store: Store<GameState, GameAction>
  let viewStore: ViewStore<ViewState, GameAction>
  private var cancellables: Set<AnyCancellable> = []

  struct ViewState: Equatable {
    let board: Three<Three<String>>
    let isGameEnabled: Bool
    let isPlayAgainButtonHidden: Bool
    let title: String?

    init(state: GameState) {
      self.board = state.board.map { $0.map { $0?.label ?? "" } }
      self.isGameEnabled = !state.board.hasWinner && !state.board.isFilled
      self.isPlayAgainButtonHidden = !state.board.hasWinner && !state.board.isFilled
      self.title =
        state.board.hasWinner
        ? "Winner! Congrats \(state.currentPlayerName)!"
        : state.board.isFilled
          ? "Tied game!"
          : "\(state.currentPlayerName), place your \(state.currentPlayer.label)"
    }
  }

  public init(store: Store<GameState, GameAction>) {
    self.store = store
    self.viewStore = ViewStore(store.scope(state: ViewState.init))
    super.init(nibName: nil, bundle: nil)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  public override func viewDidLoad() {
    super.viewDidLoad()

    self.navigationItem.title = "Tic-Tac-Toe"
    self.view.backgroundColor = .systemBackground

    self.navigationItem.leftBarButtonItem = UIBarButtonItem(
      title: "Quit",
      style: .done,
      target: self,
      action: #selector(quitButtonTapped)
    )

    let titleLabel = UILabel()
    titleLabel.textAlignment = .center

    let playAgainButton = UIButton(type: .system)
    playAgainButton.setTitle("Play again?", for: .normal)
    playAgainButton.addTarget(self, action: #selector(playAgainButtonTapped), for: .touchUpInside)

    let titleStackView = UIStackView(arrangedSubviews: [titleLabel, playAgainButton])
    titleStackView.axis = .vertical
    titleStackView.spacing = 12

    let gridCell11 = UIButton()
    gridCell11.addTarget(self, action: #selector(gridCell11Tapped), for: .touchUpInside)
    let gridCell21 = UIButton()
    gridCell21.addTarget(self, action: #selector(gridCell21Tapped), for: .touchUpInside)
    let gridCell31 = UIButton()
    gridCell31.addTarget(self, action: #selector(gridCell31Tapped), for: .touchUpInside)
    let gridCell12 = UIButton()
    gridCell12.addTarget(self, action: #selector(gridCell12Tapped), for: .touchUpInside)
    let gridCell22 = UIButton()
    gridCell22.addTarget(self, action: #selector(gridCell22Tapped), for: .touchUpInside)
    let gridCell32 = UIButton()
    gridCell32.addTarget(self, action: #selector(gridCell32Tapped), for: .touchUpInside)
    let gridCell13 = UIButton()
    gridCell13.addTarget(self, action: #selector(gridCell13Tapped), for: .touchUpInside)
    let gridCell23 = UIButton()
    gridCell23.addTarget(self, action: #selector(gridCell23Tapped), for: .touchUpInside)
    let gridCell33 = UIButton()
    gridCell33.addTarget(self, action: #selector(gridCell33Tapped), for: .touchUpInside)

    let cells = [
      [gridCell11, gridCell12, gridCell13],
      [gridCell21, gridCell22, gridCell23],
      [gridCell31, gridCell32, gridCell33],
    ]

    let gameRow1StackView = UIStackView(arrangedSubviews: cells[0])
    gameRow1StackView.spacing = 6
    let gameRow2StackView = UIStackView(arrangedSubviews: cells[1])
    gameRow2StackView.spacing = 6
    let gameRow3StackView = UIStackView(arrangedSubviews: cells[2])
    gameRow3StackView.spacing = 6

    let gameStackView = UIStackView(arrangedSubviews: [
      gameRow1StackView,
      gameRow2StackView,
      gameRow3StackView,
    ])
    gameStackView.axis = .vertical
    gameStackView.spacing = 6

    let rootStackView = UIStackView(arrangedSubviews: [
      titleStackView,
      gameStackView,
    ])
    rootStackView.isLayoutMarginsRelativeArrangement = true
    rootStackView.layoutMargins = .init(top: 0, left: 32, bottom: 0, right: 32)
    rootStackView.translatesAutoresizingMaskIntoConstraints = false
    rootStackView.axis = .vertical
    rootStackView.spacing = 100

    self.view.addSubview(rootStackView)

    NSLayoutConstraint.activate([
      rootStackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
      rootStackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
      rootStackView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor),
    ])

    gameStackView.arrangedSubviews
      .flatMap { view in (view as? UIStackView)?.arrangedSubviews ?? [] }
      .enumerated()
      .forEach { idx, cellView in
        cellView.backgroundColor = idx % 2 == 0 ? .darkGray : .lightGray
        NSLayoutConstraint.activate([
          cellView.widthAnchor.constraint(equalTo: cellView.heightAnchor)
        ])
      }

    self.viewStore.publisher.title
      .assign(to: \.text, on: titleLabel)
      .store(in: &self.cancellables)

    self.viewStore.publisher.isPlayAgainButtonHidden
      .assign(to: \.isHidden, on: playAgainButton)
      .store(in: &self.cancellables)

    self.viewStore.publisher.map(\.board, \.isGameEnabled)
      .removeDuplicates(by: ==)
      .sink { board, isGameEnabled in
        board.enumerated().forEach { rowIdx, row in
          row.enumerated().forEach { colIdx, label in
            let button = cells[rowIdx][colIdx]
            button.setTitle(label, for: .normal)
            button.isEnabled = isGameEnabled
          }
        }
      }
      .store(in: &self.cancellables)
  }

  @objc private func gridCell11Tapped() { self.viewStore.send(.cellTapped(row: 0, column: 0)) }
  @objc private func gridCell12Tapped() { self.viewStore.send(.cellTapped(row: 0, column: 1)) }
  @objc private func gridCell13Tapped() { self.viewStore.send(.cellTapped(row: 0, column: 2)) }
  @objc private func gridCell21Tapped() { self.viewStore.send(.cellTapped(row: 1, column: 0)) }
  @objc private func gridCell22Tapped() { self.viewStore.send(.cellTapped(row: 1, column: 1)) }
  @objc private func gridCell23Tapped() { self.viewStore.send(.cellTapped(row: 1, column: 2)) }
  @objc private func gridCell31Tapped() { self.viewStore.send(.cellTapped(row: 2, column: 0)) }
  @objc private func gridCell32Tapped() { self.viewStore.send(.cellTapped(row: 2, column: 1)) }
  @objc private func gridCell33Tapped() { self.viewStore.send(.cellTapped(row: 2, column: 2)) }

  @objc private func quitButtonTapped() {
    self.viewStore.send(.quitButtonTapped)
  }

  @objc private func playAgainButtonTapped() {
    self.viewStore.send(.playAgainButtonTapped)
  }
}

/// A collection of three elements.
public struct Three<Element> {
  public var first: Element
  public var second: Element
  public var third: Element

  public init(_ first: Element, _ second: Element, _ third: Element) {
    self.first = first
    self.second = second
    self.third = third
  }

  public func map<T>(_ transform: (Element) -> T) -> Three<T> {
    .init(transform(self.first), transform(self.second), transform(self.third))
  }
}

extension Three: MutableCollection {
  public subscript(offset: Int) -> Element {
    _read {
      switch offset {
      case 0: yield self.first
      case 1: yield self.second
      case 2: yield self.third
      default: fatalError()
      }
    }
    _modify {
      switch offset {
      case 0: yield &self.first
      case 1: yield &self.second
      case 2: yield &self.third
      default: fatalError()
      }
    }
  }

  public var startIndex: Int { 0 }
  public var endIndex: Int { 3 }
  public func index(after i: Int) -> Int { i + 1 }
}

extension Three: RandomAccessCollection {}

extension Three: Equatable where Element: Equatable {}
extension Three: Hashable where Element: Hashable {}

public enum Player: Equatable {
  case o
  case x

  public mutating func toggle() {
    switch self {
    case .o: self = .x
    case .x: self = .o
    }
  }

  public var label: String {
    switch self {
    case .o: return "⭕️"
    case .x: return "❌"
    }
  }
}

public struct GameState: Equatable {
    let id = UUID()
  public var board: Three<Three<Player?>> = .empty
  public var currentPlayer: Player = .x
  public var oPlayerName: String
  public var xPlayerName: String

  public init(oPlayerName: String, xPlayerName: String) {
    self.oPlayerName = oPlayerName
    self.xPlayerName = xPlayerName
  }

  public var currentPlayerName: String {
    switch self.currentPlayer {
    case .o: return self.oPlayerName
    case .x: return self.xPlayerName
    }
  }
}

public enum GameAction: Equatable {
  case cellTapped(row: Int, column: Int)
  case playAgainButtonTapped
  case quitButtonTapped
}

public struct GameEnvironment {
  public init() {}
}

public let gameReducer = Reducer<GameState, GameAction, GameEnvironment> { state, action, _ in
  switch action {
  case let .cellTapped(row, column):
    guard
      state.board[row][column] == nil,
      !state.board.hasWinner
    else { return .none }

    state.board[row][column] = state.currentPlayer

    if !state.board.hasWinner {
      state.currentPlayer.toggle()
    }

    return .none

  case .playAgainButtonTapped:
    state = GameState(oPlayerName: state.oPlayerName, xPlayerName: state.xPlayerName)
    return .none

  case .quitButtonTapped:
    return .none
  }
}

extension Three where Element == Three<Player?> {
  public static let empty = Self(
    .init(nil, nil, nil),
    .init(nil, nil, nil),
    .init(nil, nil, nil)
  )

  public var isFilled: Bool {
    self.allSatisfy { $0.allSatisfy { $0 != nil } }
  }

  func hasWin(_ player: Player) -> Bool {
    let winConditions = [
      [0, 1, 2], [3, 4, 5], [6, 7, 8],
      [0, 3, 6], [1, 4, 7], [2, 5, 8],
      [0, 4, 8], [6, 4, 2],
    ]

    for condition in winConditions {
      let matches =
        condition
        .map { self[$0 % 3][$0 / 3] }
      let matchCount =
        matches
        .filter { $0 == player }
        .count

      if matchCount == 3 {
        return true
      }
    }
    return false
  }

  public var hasWinner: Bool {
    hasWin(.x) || hasWin(.o)
  }
}

And here's a video recording showing that GameCoordinatorView doesn't work but ordinary GameView works:

https://user-images.githubusercontent.com/4270232/166885482-920eae2e-5990-40a6-a7ce-99a9d90127ca.mp4

We don't have any clue on how TCACoordinators interferes with viewStore's publisher, would like to get some help here!

Thanks. Joseph

johnpatrickmorgan commented 2 years ago

Thanks for raising this issue @josephktcheung. I think the issue was due to using potentially stale data to build the view. I've done a quick fix but I think it will need to be finessed to cover IdentifiedRouter setups too.

johnpatrickmorgan commented 2 years ago

This should now be resolved in v0.1.2. Thanks a lot for your input.