Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release 1.0.0 #1

Draft
wants to merge 43 commits into
base: main
Choose a base branch
from
Draft

Release 1.0.0 #1

wants to merge 43 commits into from

Conversation

maximkrouk
Copy link
Member

@maximkrouk maximkrouk commented Nov 15, 2023

Reactive navigation in UIKit has always been challenging, but not anymore (hopefully). With the help of swift macros and property wrappers this release introduces a set of tools that make setting up reactive navigation a breeze.

For a better understanding of stack and tree navigation, refer to TCA documentation.

Usage

Stack navigation

For the stacknavigation there is a UIViewController.navigationStack method, which accepts a collection of destination states, maps them to controllers and provides you with a dismiss handler.

Use @StackDestination property wrapper to declare a factory for each of available destination types, @RoutingController macro will generate RoutingController conformance for your controller.

The RoutingController protocol provides convenience destinations methods for mapping states to controllers. To provide those methods RoutingController protocol requires the Destinations type, which is generated by the @RoutingController macro. The destinations method uses the _makeDestinations() method of the protocol (which is also generated) internally to create a Destinations instance. This ensures that your factories are safely captured without accidentally capturing self.

@StackDestination weakly captures all presented controllers and is able to return existing controller if present or create a new one conditionally on demand.

Lets take a look at the example for

class FeatureXViewModel {
  struct State {
    enum Destination {
      case featureA(FeatureAViewModel)
      case featureB(FeatureBViewModel)

      enum Tag: Hashable {
        case featureA
        case featureB
      }
      
      var tag: Tag {
        switch self {
        case a: return .a
        case b: return .b
        }
      }

      var path: [Destination] = []
      // ...
    }

    @Published
    var state: State

    func dismiss(at indices: Set<Int>) {
      state.path.remove(atOffsets: IndexSet(indices))
    }
  }
}

it's not required to create Tag enum for DestinationState, but this way we'll be able to reduce the amount of updates with removeDuplicates() publisher.

@RoutingController
class FeatureXController: UIViewController {
  private var viewModelCancellables: Set<AnyCancellable> = []
  private var viewModel: FeatureXViewModel?

  // MARK: Declare destinations

  @StackDestination
  var featureAControllers: [Int: FeatureAController]

  @StackDestination
  var featureBControllers: [Int: FeatureBController]
  
  // MARK: Set viewModel

  func setViewModel(_ viewModel: FeatureXViewModel?) {
    self.viewModelCancellables = []
    self.viewModel = viewModel
    scopeViewModel(viewModel)
    bindViewModel(viewModel, into: &self.viewModelCancellables)
  }
  
  // MARK: Scope viewModel to destinations

  // Scoping is independent from navigation, so you can set
  // new viewModel to existing controller, it doesn't even has to be synchronous
  private func scopeViewModel(_ viewModel: FeatureXViewModel?) {
    _featureAControllers.setConfiguration(viewModel.map { viewModel in 
      return { controller, id in 
        guard case let .featureA(viewModel) = viewModel.path[id] else { return }
        controller.viewModel = viewModel
      }
    })
    
    _featureBControllers.setConfiguration(viewModel.map { viewModel in 
      return { controller, id in 
        guard case let .featureB(viewModel) = viewModel.path[id] else { return }
        controller.viewModel = viewModel
      }
    })
  )

  // MARK: Bind viewModel

  private func bindViewModel(
    _ viewModel: FeatureXViewModel,
    into cancellables: inout Set<AnyCancellable>
  ) {
    navigationStack(
      viewModel.$state.map(\.path).map { $0.map(\.tag) }.removeDuplicates(),
      switch: destinations { destination, route, index in
        switch route {
        case .featureA:
          destination.$featureAControllers[index]
        case .featureB:
          destination.$featureBControllers[index]
        }
      },
      onDismiss: { [weak self] indices in
        self?.viewModel.dismiss(at: indices)
      }
    ).store(in: &cancellables)
  }
}

Tree navigation

For the tree navigation there is a UIViewController.navigationDestination method, which accepts a publisher of destination state (or boolean isPresented publisher), maps route to controller (or nil for dismissed state) and provides you with a dismiss handler.

Use @TreeDestination property wrapper to declare a factory for each of available destination types, @RoutingController macro will generate RoutingController conformance for your controller.

The RoutingController protocol provides convenience destinations methods for mapping states to controllers for tree destinations as well.

@TreeDestination weakly captures presented controller and is able to return existing controller if present or create a new one conditionally on demand.

Lets take a look at the example for

class FeatureYViewModel {
  struct State {
    enum Destination {
      case featureA(FeatureAViewModel)
      case featureB(FeatureBViewModel)

      enum Tag: Hashable {
        case featureA
        case featureB
      }
      
      var tag: Tag {
        switch self {
        case a: return .a
        case b: return .b
        }
      }

      var destination: Destination?
      // ...
    }

    @Published
    var state: State

    func dismissDestination() {
      state.destination = nil
    }
  }
}

it's not required to create Tag enum for Destination, but this way we'll be able to reduce the amount of updates with removeDuplicates() publisher as well as for the stack navigation.

@RoutingController
class FeatureYController: UIViewController {
  private var viewModelCancellables: Set<AnyCancellable> = []
  private var viewModel: FeatureYViewModel?

  // MARK: Declare destinations

  @TreeDestination
  var featureAController: FeatureAController?

  @TreeDestination
  var featureBControllers: [Int: FeatureBController]
  
  // MARK: Set viewModel

  func setViewModel(_ viewModel: FeatureYViewModel?) {
    self.viewModelCancellables = []
    self.viewModel = viewModel
    scopeViewModel(viewModel)
    bindViewModel(viewModel, into: &self.viewModelCancellables)
  }
  
  // MARK: Scope viewModel to destinations

  // Scoping is independent from navigation, so you can set
  // new viewModel to existing controller, it doesn't even has to be synchronous
  private func scopeViewModel(_ viewModel: FeatureYViewModel?) {
    _featureAControllers.setConfiguration(viewModel.map { viewModel in 
      return { controller, id in 
        guard case let .featureA(viewModel) = viewModel.path[id] else { return }
        controller.viewModel = viewModel
      }
    })
    
    _featureBControllers.setConfiguration(viewModel.map { viewModel in 
      return { controller, id in 
        guard case let .featureB(viewModel) = viewModel.path[id] else { return }
        controller.viewModel = viewModel
      }
    })
  )

  // MARK: Bind viewModel

  private func bindViewModel(
    _ viewModel: FeatureYViewModel,
    into cancellables: inout Set<AnyCancellable>
  ) {
    navigationStack(
      viewModel.$state.map(\.destination?.tag).removeDuplicates(),
      switch: destinations { destination, route in
        switch route {
        case .featureA:
          destination.$featureAController()
        case .featureB:
          destination.$featureBController()
        }
      },
      onDismiss: { [weak self] in
        self?.viewModel.dismissDestination()
      }
    ).store(in: &cancellables)
  }
}

NavigationAnimation

You can disable animations for state updates by wrapping them into withoutNavigationAnimation { ... } functions or if those updates are published like it's done in TCA you can use .withoutNavigationAnimation() operator for publisher, just make sure that handler will be called on the main thread (in TCA you simply should use this operator at the end of the Effect chain and erase it back to Effect)

- Major API improvements
  - Replace `configureRoutes` with `navigationDestination`
  - Implement `navigationStack`
  - Get rid of `RouteConfiguration` API
  - Draft README.md update
- Add navigationAnimation API
- Improve routes mapping API
  - Add Destination wrapper for child controllers
  - Add RoutingController macro and RoutingControllerProtocol
  - Make mapping optional for navigationDestinations for state-based dismissal
  - Generalize configuration API by removing labels for publishers
- Add tests
  - Destination Wrapper
  - RoutingController macro
  - RoutingControllerProtocol
- Add todo warnings
- Add Publisher.withNavigationAnimation()

- Fix navigationStack todo
  - Add StackDestination wrapper
  - Add destinations mapper for stacks

- API Improvements:
  - Add withoutNavigationAnimation functions
  - Rename RoutingControllerProtocol to RoutingController

- Tests
  - Add StackDestinationTests
  - Update TreeDestinationTests
  - Add RoutingControllerTests.testAnimation
  - Add RoutingControllerTests.testAnimationPublisher
  - Add RoutingControllerTests.testNavigationStackDestinations
  - Update RoutingControllerTests
  - Update RoutingControllerMacroTests

- Infrastructure improvements
  - Setup CI
    - Add workflow
    - Add Makefile
  - Add SPI docs
  - Cleanup project structure
  - Minor Readme update
  - Remove resolved todos and unused code

[ci skip]
@maximkrouk maximkrouk marked this pull request as draft November 15, 2023 03:04
maximkrouk and others added 12 commits December 16, 2023 02:24
- Optimizations:
  - Destinations now use strong references to controllers and explicitly removes those references when controllers are popped from the navigation stack, no more just-in-case cleanups in StackDestination on each wrappedValue access
- API Improvements
  - With new navigationStack/Destination methods controllers can be implicitly instantiated from Destination wrappers, all users have to do is provide mappings from Route to a corresponding destination
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant