A holistic approach to iOS development, inspired by Redux and MVVM

Overview

Tempura by Bending Spoons

Build Status CocoaPods PRs Welcome Licence

Tempura is a holistic approach to iOS development, it borrows concepts from Redux (through Katana) and MVVM.

🎯 Installation

Tempura is available through CocoaPods.

Requirements

  • iOS 11+
  • Xcode 11.0+
  • Swift 5.0+

CocoaPods

CocoaPods is a dependency manager for Cocoa projects. You can install it with the following command:

$ sudo gem install cocoapods

To integrate Tempura in your Xcode project using CocoaPods you need to create a Podfile with this content:

use_frameworks!
source 'https://cdn.cocoapods.org/'
platform :ios, '11.0'

target 'MyApp' do
  pod 'Tempura'
end

Now you just need to run:

$ pod install

Swift Package Manager

Since version 9.0.0, Tempura also supports Swift Package Manager (SPM).

🤔 Why should I use this?

Tempura allows you to:

  1. Model your app state
  2. Define the actions that can change it
  3. Create the UI
  4. Enjoy automatic sync between state and UI
  5. Ship, iterate

We started using Tempura in a small team inside Bending Spoons. It worked so well for us, that we ended up developing and maintaining more than twenty high quality apps, with more than 10 million active users in the last year using this approach. Crash rates and development time went down, user engagement and quality went up. We are so satisfied that we wanted to share this with the iOS community, hoping that you will be as excited as we are. ❤️

Splice Thirty Day Fitness Pic Jointer Yoga Wave

👩‍💻 Show me the code

Tempura uses Katana to handle the logic of your app. Your app state is defined in a single struct.

struct AppState: State {

  var items: [Todo] = [
    Todo(text: "Pet my unicorn"),
    Todo(text: "Become a doctor.\nChange last name to Acula"),
    Todo(text: "Hire two private investigators.\nGet them to follow each other"),
    Todo(text: "Visit mars")
  ]
}

You can only manipulate state through State Updaters.

struct CompleteItem: StateUpdater {
  var index: Int

  func updateState(_ state: inout AppState) {
    state.items[index].completed = true
  }
}

The part of the state needed to render the UI of a screen is selected by a ViewModelWithState.

struct ListViewModel: ViewModelWithState {
  var todos: [Todo]

  init(state: AppState) {
    self.todos = state.todos
  }
}

The UI of each screen of your app is composed in a ViewControllerModellableView. It exposes callbacks (we call them interactions) to signal that a user action occurred. It renders itself based on the ViewModelWithState.

class ListView: UIView, ViewControllerModellableView {
  // subviews
  var todoButton: UIButton = UIButton(type: .custom)
  var list: CollectionView<TodoCell, SimpleSource<TodoCellViewModel>>

  // interactions
  var didTapAddItem: ((String) -> ())?
  var didCompleteItem: ((String) -> ())?

  // update based on ViewModel
  func update(oldModel: ListViewModel?) {
    guard let model = self.model else { return }
    let todos = model.todos
    self.list.source = SimpleSource<TodoCellViewModel>(todos)
  }
}

Each screen of your app is managed by a ViewController. Out of the box it will automatically listen for state updates and keep the UI in sync. The only other responsibility of a ViewController is to listen for interactions from the UI and dispatch actions to change the state.

class ListViewController: ViewController<ListView> {
  // listen for interactions from the view
  override func setupInteraction() {
    self.rootView.didCompleteItem = { [unowned self] index in
      self.dispatch(CompleteItem(index: index))
    }
  }
}

Note that the dispatch method of view controllers is a bit different than the one exposed by the Katana store: it accepts a simple Dispatchable and does not return anything. This is done to avoid implementing logic inside the view controller.

If your interaction handler needs to do more than one single thing, you should pack all that logic in a side effect and dispatch that.

For the rare cases when it's needed to have a bit of logic in a view controller (for example when updating an old app without wanting to completely refactor all the logic) you can use the following methods:

  • open func __unsafeDispatch<T: StateUpdater>(_ dispatchable: T) -> Promise<Void>
  • open func __unsafeDispatch<T: ReturningSideEffect>(_ dispatchable: T) -> Promise<T.ReturningValue>

Note however that usage of this methods is HIGHLY discouraged, and they will be removed in a future version.

Navigation

Real apps are made by more than one screen. If a screen needs to present another screen, its ViewController must conform to the RoutableWithConfiguration protocol.

extension ListViewController: RoutableWithConfiguration {
  var routeIdentifier: RouteElementIdentifier { return "list screen"}

  var navigationConfiguration: [NavigationRequest: NavigationInstruction] {
    return [
      .show("add item screen"): .presentModally({ [unowned self] _ in
        let aivc = AddItemViewController(store: self.store)
        return aivc
      })
    ]
  }
}

You can then trigger the presentation using one of the navigation actions from the ViewController.

self.dispatch(Show("add item screen"))

Learn more about the navigation here

ViewController containment

You can have ViewControllers inside other ViewControllers, this is useful if you want to reuse portions of UI including the logic. To do that, in the parent ViewController you need to provide a ContainerView that will receive the view of the child ViewController as subview.

class ParentView: UIView, ViewControllerModellableView {
    var titleView = UILabel()
    var childView = ContainerView()
    
    func update(oldModel: ParentViewModel?) {
      // update only the titleView, the childView is managed by another VC
    }
}

Then, in the parent ViewController you just need to add the child ViewController:

class ParentViewController: ViewController<ParentView> {
  let childVC: ChildViewController<ChildView>!
    
  override func setup() {
    self.childVC = ChildViewController(store: self.store)
    self.add(childVC, in: self.rootView.childView)  
  }
}

All the automation will work out of the box. You will now have a ChildViewController inside the ParentViewController, the ChildViewController's view will be hosted inside the childView.

📸 UI Snapshot Testing

Tempura has a Snapshot Testing system that can be used to take screenshots of your views in all possible states, with all devices and all supported languages.

Usage

You need to include the TempuraTesting pod in the test target of your app:

target 'MyAppTests' do
  pod 'TempuraTesting'
end

Specify where the screenshots will be placed inside your plist :

UI_TEST_DIR: $(SOURCE_ROOT)/Demo/UITests

In Xcode, create a new UI test case class:

File -> New -> File... -> UI Test Case Class

Here you can use the test function to take a snapshot of a ViewControllerModellableView with a specific ViewModel.

import TempuraTesting

class UITests: XCTestCase, ViewTestCase {
  
  func testAddItemScreen() {
    self.uiTest(testCases: [
      "addItem01": AddItemViewModel(editingText: "this is a test")
    ])
  }
}

The identifier will define the name of the snapshot image in the file system.

You can also personalize how the view is rendered (for instance you can embed the view in an instance of UITabBar) using the context parameter. Here is an example that embeds the view into a tabbar:

import TempuraTesting

class UITests: XCTestCase, ViewTestCase {
  
  func testAddItemScreen() {
    var context = UITests.Context<AddItemView>()
    context.container = .tabBarController


    self.uiTest(testCases: [
      "addItem01": AddItemViewModel(editingText: "this is a test")
    ], context: context)
  }
}

If some important content inside a UIScrollView is not fully visible, you can leverage the scrollViewsToTest(in view: V, identifier: String) method. This will produce an additional snapshot rendering the full content of each returned UIScrollView instance.

In this example we use scrollViewsToTest(in view: V, identifier: String) to take an extended snapshot of the mood picker at the bottom of the screen.

func scrollViewsToTest(in view: V, identifier: String) -> [String: UIScrollView] {
  return ["mood_collection_view": view.moodCollectionView]
}

In case you have to wait for asynchronous operations before rendering the UI and take the screenshot, you can leverage the isViewReady(view:identifier:) method. For instance, here we wait until an hypothetical view that shows an image from a remote URL is ready. When the image is shown (that is, the state is loaded, then the snapshot is taken)

import TempuraTesting

class UITests: XCTestCase, ViewTestCase {
  
  func testAddItemScreen() {
    self.uiTest(testCases: [
      "addItem01": AddItemViewModel(editingText: "this is a test")
    ])
  }

  func isViewReady(_ view: AddItemView, identifier: String) -> Bool {
    return view.remoteImage.state == .loaded
  }
}

The test will pass as soon as the snapshot is taken.

Context

You can enable a number of advanced features through the context object that you can pass to the uiTest method:

  • the container allows you to define a VC as a container of the view during the UITests. Basic navigationController and tabBarController are already provided, or you can define your own using the custom one
  • the hooks allows you to perform actions when some lifecycle events happen. Available hooks are viewDidLoad, viewWillAppear, viewDidAppear, viewDidLayoutSubviews, and navigationControllerHasBeenCreated
  • the screenSize and orientation properties allows you to define a custom screen size and orientation to be used during the test
  • the renderSafeArea allows you to define whether the safe area should be rendered as semitransparent gray overlay during the test
  • the keyboardVisibility allows you to define whether a gray overlay should be rendered as a placeholder for the keyboard

Multiple devices

By default, tests are run only in the device you have choose from xcode (or your device, or CI system). We can run the snapshotting in all the devices by using a script like the following one:

xcodebuild \
  -workspace <project>.xcworkspace \
  -scheme "<target name>" \
  -destination name="iPhone 5s" \
  -destination name="iPhone 6 Plus" \
  -destination name="iPhone 6" \
  -destination name="iPhone X" \
  -destination name="iPad Pro (12.9 inch)" \
  test

Tests will run in parallel on all the devices. If you want to change the behaviour, refer to the xcodebuild documentation

If you want to test a specific language in the ui test, you can replace the test command with the -testLanguage <iso code639-1>. The app will be launched in that language and the UITests will be executed with that locale. An example:

xcodebuild \
  -workspace <project>.xcworkspace \
  -scheme "<target name>" \
  -destination name="iPhone 5s" \
  -destination name="iPhone 6 Plus" \
  -destination name="iPhone 6" \
  -destination name="iPhone X" \
  -destination name="iPad Pro (12.9 inch)" \
  -testLanguage it

Remote Resources

It happens often that the UI needs to show remote content (that is, remote images, remote videos, ...). While executing UITests this could be a problem as:

  • tests may fail due to network or server issues
  • system should take care of tracking when remote resources are loaded, put them in the UI and only then take the screenshots

To fix this issue, Tempura offers a URLProtocol subclass named LocalFileURLProtocol that tries to load remote files from your local bundle.

The idea is to put in your (test) bundle all the resources that are needed to render the UI and LocalFileURLProtocol will try to load them instead of making the network request.

Given an url, LocalFileURLProtocol matches the file name using the following rules:

  • search a file that has the url as a name (e.g., http://example.com/image.png)
  • search a file that has the last path component as file name (e.g., image.png)
  • search a file that has the last path component without extension as file name (e.g., image)

if a matching file cannot be retrieved, then the network call is performed.

In order to register LocalFileURLProtocol in your application, you have to invoke the following API as soon as possible in your tests lifecycle:

URLProtocol.registerClass(LocalFileURLProtocol.self)

Note that if you are using Alamofire this won't work. Here you can find a related issue and a link on how to configure Alamofire to deal with URLProtocol classes.

UI Testing with ViewController containment

ViewTestCase is centred about the use case of testing ViewControllerModellableViews with the automatic injection of ViewModels representing testing conditions for that View.

In case you are using ViewController containment (like in our ParentView example above) there is part of the View that will not be updated when injecting the ViewModel, as there is another ViewController responsible for that.

In that case you need to scale up and test at the ViewController's level using the ViewControllerTestCase protocol:

class ParentViewControllerUITest: XCTestCase, ViewControllerTestCase {
  /// provide the instance of the ViewController to test
  var viewController: ParentViewController {
    let fakeStore = Store<AppState, EmptySideEffectDependencyContainer>()
    let vc = ParentViewController(store: testStore)
    return vc
  }
  
  /// define the ViewModels
  let viewModel = ParentViewModel(title: "A test title")
  let childVM = ChildViewModel(value: 12)
  
  /// define the tests we want to perform
  let tests: [String: ParentViewModel] = [
    "first_test_vc": viewModel
  ]
    
  /// configure the ViewController with ViewModels, also for the children VCs
  func configure(vc: ParentViewController, for testCase: String, model: ParentViewModel) {
    vc.viewModel = model
    vc.childVC.viewModel = childVM
  }
    
  /// execute the UI tests
  func test() {
    let context = UITests.VCContext<ParentViewController>(container: .none)
    self.uiTest(testCases: self.tests, context: context)  
  }
}

In case you don't have child ViewControllers to configure, it's even easier as you don't need to supply a configure(:::) method:

class ParentViewControllerUITest: XCTestCase, ViewControllerTestCase {
  /// provide the instance of the ViewController to test
  var viewController: ParentViewController {
    let fakeStore = Store<AppState, EmptySideEffectDependencyContainer>()
    let vc = ParentViewController(store: testStore)
    return vc
  }
  
  /// define the ViewModel
  let viewModel = ParentViewModel(title: "A test title")
  
  /// define the tests we want to perform
  let tests: [String: ParentViewModel] = [
    "first_test_vc": viewModel
  ]
    
  /// execute the UI tests
  func test() {
    let context = UITests.VCContext<ParentViewController>(container: .tabbarController)
    self.uiTest(testCases: self.tests, context: context)  
  }
}

🧭 Where to go from here

Example application

This repository contains a demo of a todo list application done with Tempura. To generate an Xcode project file you can use Tuist. Run tuist generate, open the project and run the Demo target.

Check out the documentation

Documentation

📄 Swift Version

Certain versions of Tempura only support certain versions of Swift. Depending on which version of Swift your project is using, you should use specific versions of Tempura. Use this table in order to check which version of Tempura you need.

Swift Version Tempura Version
Swift 5.0 Tempura 4.0+
Swift 4.2 Tempura 3.0
Swift 4 Tempura 1.12

📬 Get in touch

If you have any questions or feedback we'd love to hear from you at [email protected]

🙋‍♀️ Contribute

  • If you've found a bug, open an issue;
  • If you have a feature request, open an issue;
  • If you want to contribute, submit a pull request;
  • If you have an idea on how to improve the framework or how to spread the word, please get in touch;
  • If you want to try the framework for your project or to write a demo, please send us the link of the repo.

👩‍⚖️ License

Tempura is available under the MIT license.

About

Tempura is maintained by Bending Spoons. We create our own tech products, used and loved by millions all around the world. Sounds cool? Check us out

Comments
  • Misc fixes for Travis build and Swift 4.0 warnings...

    Misc fixes for Travis build and Swift 4.0 warnings...

    Why The latest release has numerous warnings on Swift 4.0. Those include protocol inheritance issues which should have used protocol composition to resolve. In addition, the flatMap use was deprecated in SDK 9.3. Lastly the travis build was not working.

    Changes Implemented protocol composition to resolve redundant associatedtype declarations. Changed flatMap to compactMap where appropriate. Instead of custom travis.yml for performing tests, switched to fastlane. Removed Podfile dependency listings since the project provides a Podfile.lock. Fixed README.md which was pointing to Katana instead of Tempura build. Bumped Pod version number to 1.1.13 -- may want to move to v 2.0 since this does break SDK 9.2 and lower support. Unfortunately there is no compile time check for SDK version in Swift. Boo Swift...

    Tasks

    • [ ] Need to decide on proper version number.
    • [ ] Support for Katana 1.1.11
    opened by cajurabi 7
  • Style standardisation

    Style standardisation

    Changes:

    • Added helpers for style standardisation
    • Updated README with conventions
    • Added bonmot helpers with related pod

    Please check the readme carefully as it contains most of the work.

    This PR doesn't provide any automation process from Sketch, but it creates a foundation for it. The idea is to start adopting this style and be ready for the automation step we will face in the (hopefully near) future

    opened by bolismauro 6
  • Support for UI test in ViewController containment

    Support for UI test in ViewController containment

    The problem

    The current implementation of UI Tests is centred about Views and the injection of ViewModels representing testing conditions for that View.

    Let's take an example View to test:

    class ViewToTest: UIView, ViewControllerModellableView {
      let title = UILabel()
      let preview = Preview()
        
      ...
        
      func update(oldModel: ViewToTestViewModel?) {
        // handle the update
      }
    }
    

    A possible UI Test is written in the form:

    class ViewToTestUITest: XCTestCase, UITestCase {
      typealias V = ViewToTest
        
      let vm1 = ViewToTestViewModel(title: "A test title")
      let vm2 = ViewToTestViewModel(title: "A very very very long test title")
       
      let context = UITests.Context<ViewToTest>()
      
      func test() {
        self.uiTest(testCases: ["first_test": vm1,
                                "second_test": vm2],
                   context: context)
        
      }
    }
    

    Extending the usual XCTestCase and conforming to UITestCase protocol, we have access to the uiTest function that is taking the testCases and the context of the tests in input. Running the test, for each test case the View will be instantiated and updated with the provided ViewModel.

    If you View is using VC containment this will not work.

    Take for instance the same View but using VC containment for the preview part:

    class ViewToTest: UIView, ViewControllerModellableView {
      let title = UILabel()
      /// The VC is responsible to inject a View managed by a different VC inside this ContainerView
      let preview = ContainerView()
      
        
        func update(oldModel: ViewToTestViewModel?) {
          /// handle the update only of the `title` part of the View
          /// the `preview` will receive updates through a different VC
        }
    }
    

    The ViewController has the responsibility of doing the setup of the contained ViewController:

    class ViewToTestViewController: ViewController<ViewToTest> {
      lazy var previewVC = PreviewViewController(store: self.store)
        
      override func setup() {
        /// link the `preview` to the VC that should manage it
        self.add(self.previewVC, in: self.rootView.preview)
      }
    }
    

    In this way, the preview now is an independent piece of UI not managed by this View (that will only manage the layout), thus when the UI test will set the testing ViewModel, the preview is not affected and we are not able to test the preview in this context.

    Proposed Solution

    The proposed solution is based on the fact that the ViewController is doing the configuration of all the children ViewControllers. We propose to create a different protocol that will handle the UI test at the ViewController level:

    protocol UIVCTestCase {
      associatedtype VC: AnyViewController
      
      /// perform the UI tests
      func uiTest(testCases: [String], context: UITests.VCContext<VC>)
       
      /// return true if the View is ready for the UI test
      func isViewReady(_ view: VC.V, identifier: String) -> Bool
        
      /// used to provide the VC
      var viewController: VC { get }
        
      /// configure the VC for the specified `testCase`
      /// here you should manually inject the ViewModels for all the children VCs
      func configure(vc: VC, for testCase: String)
    }
    

    Two things to notice here:

    1. the instantiation of the ViewController cannot happen automatically like for the View in the UITestCase as, requiring the ViewController to be instantiable with an empty init, means that we add a required init() in the ViewController class. This means that each subclass of ViewController in our apps must have an empty init that you need to write each time. This is super cumbersome and we want to avoid that. Hence, in UIVCTestCase you are responsible for the initialization of the ViewController returning it as the viewController variable.
    2. the injection of the ViewModels is done manually inside the configure(vc: VC, for testCase: String) method. An automatic injection like the one we are performing in the UITestCase is not possible here, given that ViewModels are of different types and also we don't know the structure of the children ViewControllers.

    Example of UI Test for VC containment

    class ViewToTestVCUITest: XCTestCase, UIVCTestCase {
      
      var viewController: ViewToTestViewController {
        let testStore = Store<AppState, EmptySideEffectDependencyContainer>()
        let vc = ViewToTestViewController(store: testStore)
        vc.shouldConnectWhenVisible = false
        return vc
      }
      
      let vm = ViewToTestViewModel(label: "A test title")
      let previewVM = PreviewViewModel(status: .stopped)
      
      let context = UITests.VCContext<ViewToTestViewController>()
      
      func configure(vc: ViewToTestViewController, for testCase: String) {
        vc.viewModel = vm
        vc.previewVC.viewModel = previewVM
      }
      
      func test() {
        self.uiTest(testCases: ["first_test"],
                   context: context)
        
      }
    }
    
    opened by smaramba 5
  • Make Code Injection Working For UI

    Make Code Injection Working For UI

    I have the strong feeling that if we manage to have a quick-to-setup injection when it comes to UI, we can drastically reduce the amount of time it takes to styles application, as we can live reloads tweaks without recompiling the swift project, which can be extremely slow for large applications.

    Unfortunately, the code injection doesn't work for generic classes, as these are not exposed to the objc runtime and therefore cannot be dynamically reloaded. Tempura heavily uses generic classes and in particular the ModellableView is a generic subclass of UIView

    Me and @smaramba decided to have two separated branches. Master will keep the generic class approach, while this branch will try the PAT path. Video Editor will use the master branch while VPN (if we decide to use tempura) will use this branch. After some time we will evaluate pros/cons of the two approaches and we will decide which is the best approach.

    There are 3 steps I want to follow to try this approach: 1 - Refactor the project so that ModellableView is a PAT (DONE) 2 - Reduce the boilerplate introduced by the fact that we use a PAT and not a subclass (DONE) 3 - Find a method to make live reload works out of the box in the UI (DONE)

    One of the biggest challenges will be to keep this branch in sync feature-wise with master.

    opened by bolismauro 5
  • Remove init() requirement from ViewModel

    Remove init() requirement from ViewModel

    Changes:

    • the ViewModel protocol does not require the empty init(), this means that you are no longer forced to provide defaults for all the values (even if it's strongly suggested)

    • ViewController is not creating an empty instance of the ViewModel, this means that the first time your update(oldModel: VM?) method will be called, the oldModel will actually be nil giving you the ability to distinguish the first update cycle (for instance to avoid animations)

    • the old model: VM var in the ModellableView is now a model: VM! so that nothing changes from the usage perspective. In the typical use case (that is when you ask for the model inside the update method) the model is always instantiated so it's safe to use the forced unwrapped. If you need to access the model in an unsafe context you can still prepend your code with a guard let model = self.model else { return } to be safe.

    no API changes are needed at the app level (*) (edit: actually the change is breaking as the update method signature is changed)

    opened by smaramba 4
  • EXC_BAD_ACCESS: Attempted to dereference garbage pointer

    EXC_BAD_ACCESS: Attempted to dereference garbage pointer

    The issue is a dereferenced pointer crash that happens occasionally when a navigation transition (occurring from a button tap that triggers .hide(): .pop happens at the same time as a state update (originating from a side effect).

    Xcode version: 12.1 Target: iOS 12.2 Stack Trace:

    OS Version: iOS 13.5.1 (17F80)
    Report Version: 104
    
    Exception Type: EXC_BAD_ACCESS (SIGBUS)
    Exception Codes: BUS_NOOP at 0x0000000115200008
    Crashed Thread: 0
    
    Application Specific Information:
    18837 >
    Attempted to dereference garbage pointer 0x115200008.
    
    Thread 0 Crashed:
    0   libswiftCore.dylib              0x3517d6b44         swift_release
    1   OurApp                          0x200cbf0f0         AppState
    2   Tempura                         0x1020e3d28         ViewController.storeDidChange (ViewController.swift:280)
    3   Tempura                         0x1020d891c         mainThread (MainThread.swift:16)
    4   Tempura                         0x1020e4c08         [inlined] ViewController.storeDidChange (ViewController.swift:279)
    5   Tempura                         0x1020e4c08         [inlined] ViewController.subscribe (ViewController.swift:267)
    6   Tempura                         0x1020e4c08         ViewController.subscribe
    7   Katana                          0x286a37efc         [inlined] @callee_guaranteed
    8   Katana                          0x286a37efc         @callee_guaranteed
    9   Katana                          0x286a36540         [inlined] @callee_guaranteed
    10  Katana                          0x286a36540         [inlined] Store.manageAction (Store.swift:537)
    11  Katana                          0x286a36540         [inlined] $sIeg_s5Error_pIggzo_xlyytIsegr_sAA_pIegnzo_TR
    12  Katana                          0x286a36540         [inlined] Sequence.forEach
    13  Katana                          0x286a36540         Store.manageAction
    14  Katana                          0x286a336d8         @callee_guaranteed
    15  libdispatch.dylib               0x314eb49a4         _dispatch_call_block_and_release
    16  libdispatch.dylib               0x314eb5520         _dispatch_client_callout
    17  libdispatch.dylib               0x314e675b0         _dispatch_main_queue_callback_4CF$VARIANT$mp
    18  CoreFoundation                  0x338e167f8         __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__
    19  CoreFoundation                  0x338e116cc         __CFRunLoopRun
    20  CoreFoundation                  0x338e10ce4         CFRunLoopRunSpecific
    21  GraphicsServices                0x31b45f388         GSEventRunModal
    22  UIKitCore                       0x33c057440         UIApplicationMain
    23  OurApp                          0x200b457b0         main (AppDelegate.swift:11)
    24  libdyld.dylib                   0x18e1bb8ec         start
    

    We suspect in ViewController: line 280 self.update(with: self.state) the ViewController (self) has been deallocated (we confirmeddeinitis called from a background thread), but afterstoreDidChangeis called (and queues up the update on the mainThread). Hence, whenself.update` is actually run on the mainThread, self has been deallocated.

    We've tried to disconnect before dispatching a Hide action (see below), but that doesn't seem to resolve the issue. (possibly because the disconnect still triggers a state update that creates the same race condition with the deallocation?)

    self.connected = false
    self.dispatch(Hide(animated: true))
    

    Would this be resolved by adding a [weak self] to the mainThread call? A state update shouldn't be required if the ViewController is deallocated at any point.

    ┆Issue is synchronized with this Asana task by Unito

    opened by andeeliao 3
  • [Fix] Katana Dispatch Helpers

    [Fix] Katana Dispatch Helpers

    Why This PR fixes an issue for which Apps dispatching NavigationSideEffects relied on Tempura's duplicate interface instead of Katana ones. It also deprecates a potentially insecure and UI Locking helper to await a Promise dispatch on ViewControllers.

    Changes

    • Deprecated all dispatch helpers to AnyStore and AnySideEffectContext for NavigationSideEffects
    • Deprecated __unsafeAwaitDispatch from ViewController for NavigationSideEffects
    • Added __unsafeDispatch for non-returning sideeffects in ViewController

    Tasks

    • [x] Add relevant tests, if needed
    • [x] Add documentation, if needed
    • [x] Update README, if needed
    • [x] Ensure that the demo project works properly
    opened by fila95 3
  • In ViewControllerTestCase is not possible to wait after configure(vc:testCase:)method

    In ViewControllerTestCase is not possible to wait after configure(vc:testCase:)method

    Current Implementation

    In the current implementation of ViewControllerTestCase, the VM of the view must be set in the configure(vc:testCase:) method. The method is called only after the isViewReady(view:identifier:) returns true, and after that the snapshot is immediately taken.

    Problem

    In case a UITest needs it, there is no way for the test to wait for some condition after that the configure method is called.

    Possible Solution

    Call the configure(vc:testCase:) method at the beginning of the test case, and then wait for isViewReady(view:identifier:) to return true to actually take the snapshot.

    This requires some refactoring of the ViewControllerTestCase:

    • at the moment all the test cases are run together as asynchronous tasks on the main thread
    • each task checks if the view is ready:
      • if it is ready, it invokes the configure method and takes the screenshot in stack
      • if not, it reschedules itself on the main thread

    In case we want to configure the test case and then wait for it, we cannot schedule all the test cases together, otherwise they will conflict with each other trying to configure the view. Instead we should execute the test case sequentially so that only one test case is in progress at any single moment and it can leave the main thread and wait until the view is ready to take the snapshot

    opened by danyf90 3
  • [Feature] Public Access Level to Route Inspectables

    [Feature] Public Access Level to Route Inspectables

    Why This PR is needed to let custom ViewControllers that use the containment UIKit's APIs expose their Childs in order to propagate routing and presentation sources. This way they can be recognized from Tempura as Routable View Controllers.

    Changes Made CustomRouteInspectables and RouteInspectable public

    Tasks

    • [x] Add relevant tests, if needed
    • [x] Add documentation, if needed
    • [x] Update README, if needed
    • [x] Ensure that the demo project works properly
    opened by fila95 3
  • [Feature] Swift Package Manager (SPM) Integration

    [Feature] Swift Package Manager (SPM) Integration

    Why This PR is needed to provide Swift Package Manager compatibility for Tempura

    Changes

    • Added Package.swift
    • Moved TempuraTesting related sources to an outer folder
    • Updated Cakefile and Podfile for TempuraTesting
    • Updated README.md and Changelog

    Tasks

    • [x] Add relevant tests, if needed
    • [x] Add documentation, if needed
    • [x] Update README, if needed
    • [x] Ensure that the demo project works properly
    opened by fila95 3
  • [Fix][TempuraTesting] Set device orientation with XCode 13

    [Fix][TempuraTesting] Set device orientation with XCode 13

    Fix https://github.com/BendingSpoons/tempura-swift/issues/128

    I don't know how to test this change of TempuraTesting. I guess some tests should be added?

    opened by carlomartinucci 2
  • [BUG] `.dismissModally(.soft)` does not behave as documented.

    [BUG] `.dismissModally(.soft)` does not behave as documented.

    Below is a copy of Tempura's ModalDismissBehaviour documentation.

      /// Define one of the two possible behaviours when dismissing a modal ViewController:
      ///
      /// `.soft`: dismiss the ViewController but keep all the presented ViewControllers
      ///
      /// `.hard`: the usual UIKit behaviour, dismiss the ViewController and all the ViewControllers that is presenting
      public enum ModalDismissBehaviour {
        /// If the targeted modal is presenting other modals, keep them alive.
        case soft
        /// While removing the targeted modal, remove also all the modals that it is presenting.
        case hard
      }
    

    In other words, the expected behavior when using the .soft modal dismiss is to hide only the intended view controller without hiding the stack on top of it.

    However, as shown in the attached project, that is not the actual behavior of Tempura.

    Regardless if .soft or .hard is passed to the .dismissModally function, the latter is applied.

    My best guess this is due to the following code:

      func hide(_ elementToHide: RouteElementIdentifier, animated: Bool, context: Any?, atomic: Bool = false) -> Promise<Void> {
        let promise = Promise<Void>(in: .background) { resolve, reject, _ in
          let oldRoutables = UIApplication.shared.currentRoutables
          let oldRoute = oldRoutables.map { $0.routeIdentifier }
          
          guard let start = oldRoute.indices.reversed().first(where: { oldRoute[$0] == elementToHide }) else {
            resolve(())
            return
          }
          
          let newRoute = Array(oldRoute[0..<start])
          
          let routeChanges = Navigator.routingChanges(from: oldRoutables, new: newRoute, atomic: atomic)
          
          self.routeDidChange(changes: routeChanges, isAnimated: animated, context: context) {
            resolve(())
          }
        }
        return promise
      }
    

    By trimming the array of routes to generate the new route and using that latter to generate the diff between old and new routes, before checking the modal dismiss behavior, the .soft keyword becomes obsolete.

    So in a scenario where:

    A -> B -> C, where -> is the equivalent of a modal presentation, by wanting to hide B, C is hidden as well regardless of the behavior.

    More so, if no view controller manages the .hide of C, the app will crash regardless if the modal dismiss behavior is .hard or .soft.

    • Xcode version 12.0
    • Swift Version 5.3
    • Tempura, version 4.4.0

    Snippet project that reproduces the bug. It consists of a navigation controller that presents a view controller which in its turn presents another view controller. A button is attached to the third view controller that tries to hide the second one, which should dismiss softly, however it hides the topmost view controller as well.

    TempuraBug.zip

    ┆Issue is synchronized with this Asana task by Unito

    opened by TheInkedEngineer 0
  • Navigation actions dispatched twice

    Navigation actions dispatched twice

    Imagine a common scenario, where a navigation action is dispatched on a button tap.

    @objc func didTapProfile() {
      self.dispatch(Show(Screen.profile), animated: true)
    }
    

    The flow will leave the main thread, without performing the actual navigation (that will be performed later, coming back to the main thread). Note that there are no timing guarantees here. Very rarely, it happens that the user is able to tap the button twice. The duplicated navigation request is often unexpected and unhandled, leading to a crash. This can be simulated adding an artificial delay (NSThread.sleep(0.5)) in the navigation action.

    ┆Issue is synchronized with this Asana task by Unito

    opened by fonesti 0
  • Local State nested updates can cause inconsistent UI

    Local State nested updates can cause inconsistent UI

    The issue is about LocalState updates performed inside a ModellableView's update() method.

    Example

    This is an example Tempura UI

    ViewControllerWithLocalState
               |
               |
           RootView
           |      |
           |      |
        View_A   View_B
    

    View_A and View_B inherently depends on LocalState.

    View_A has an interaction that updates the LocalState

    class AppViewController: ViewControllerWithLocalState<RootView> {
      override func setupInteraction() {
        self.rootView.view_A.increment = { [unowned self] in 
          self.localState.counter += 1
        }
      }
    }
    

    As local state updates are synchronous, an interaction call inside View_A.update(oldModel:) can cause unordered nested updates and unexpected bugs :scream:.

    Let's say a generic state change has started an update cycle (0): state_0 and localState_0 are used to compute rootViewModel_0, and inherently viewModel_A_0 and viewModel_B_0

    • rootView.model = rootViewModel_0 and rootView.update() begins
    • view_A.model = viewModel_A_0
    • view_A.update() begins
    • During view_A.update, the increment interaction is called, causing a nested update cycle (1)
      • state_0 and localState_1 are used to compute rootViewModel_1
      • rootView.model = rootViewModel_1 and rootView.update() begins
      • view_A.model = viewModel_A_1 and viewA.update() is executed
      • view_B.model = viewModel_A_1 and viewB.update() is executed
      • `rootView.update() ends
    • Update cycle 1 ends, update cycle 0 continues
    • viewA.update() is resumed (**)
    • viewA.update() ends
    • view_B.model = viewModel_B_0 and viewB.update() is executed (*)
    • `rootView.update() is resumed (***)
    • `rootView.update() ends
    • Update cycle 0 ends

    Problems

    At the end:

    1. View_B is one view model behind (*)
    2. If we use a copy of the view model inside view_A.update() (e.g. we use guard let model = self.model), a part of the old model can be applied on top of the latest model. (**)
    3. The same considerations of 2 apply to RootView (***)
    • While debugging, this behaviour is counter-intuitive in respect to the normal update cycle
    • Usually view hierarchies are more complex
    • There could be more levels of nested updates

    Finding and fixing these kinds of bugs can be just difficult and really time consuming :face_with_head_bandage:

    Possible Solutions

    I see two way to address this issue:

    • consider changing the local state during a model update an anti-pattern or a programming error and perform an assertion.
      • Is this kind of setup ever useful (maybe to interact with some UI related framework with observers/callbacks/delegates) or it is just the consequence of a bad design?
    • enqueue incoming LocalState to avoid nested update cycle

    I'm not keen on a particular solution, both seems easy to implement.

    ┆Issue is synchronized with this Asana task by Unito

    opened by fonesti 2
This is an example of clean architecture and MVVM pattern written in swift

Swift Clean Architecture MVVM This is an example of clean architecture and MVVM pattern written in swift First of all thanks to all of those who made

null 19 Oct 12, 2022
Techcareer.net Bootcamp graduation project written with VIPER, highly inspired by Getir

götür Techcareer.net iOS Bootcamp'i bitirme projesi, Getir'den yüksek miktarda i

Kemal Sanlı 9 Dec 3, 2022
MoneySafe - Application for tracking income and expenses and analyzing expenses. VIPER architecture, CoreData, Charts

?? MoneySafe ?? Application for tracking income and expenses and analyzing expen

Vladislav 5 Dec 27, 2022
Stateful view controller containment for iOS and tvOS

StateViewController When creating rich view controllers, a single view controller class is often tasked with managing the appearance of many other vie

David Ask 309 Nov 29, 2022
This repository contains a detailed sample app that implements VIPER architecture in iOS using libraries and frameworks like Alamofire, AlamofireImage, PKHUD, CoreData etc.

iOS Viper Architecture: Sample App This repository contains a detailed sample app that implements VIPER architecture using libraries and frameworks li

MindOrks 653 Jan 2, 2023
Spin aims to provide a versatile Feedback Loop implementation working with the three main reactive frameworks available in the Swift community (RxSwift, ReactiveSwift and Combine)

With the introduction of Combine and SwiftUI, we will face some transition periods in our code base. Our applications will use both Combine and a thir

Spinners 119 Dec 29, 2022
A Swift 4.2 VIPER Module Boilerplate Generator with predefined functions and a BaseViewProtocol.

Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away. Are you new to VIPER Design Pattern? Want

Mohamad Kaakati 68 Sep 29, 2022
SwiftUI sample app using Clean Architecture. Examples of working with CoreData persistence, networking, dependency injection, unit testing, and more.

Articles related to this project Clean Architecture for SwiftUI Programmatic navigation in SwiftUI project Separation of Concerns in Software Design C

Alexey Naumov 4k Dec 31, 2022
Example of Clean Architecture of iOS app using RxSwift

Clean architecture with RxSwift Contributions are welcome and highly appreciated!! You can do this by: opening an issue to discuss the current solutio

null 3.6k Dec 29, 2022
Reactant is a reactive architecture for iOS

Reactant Reactant is a foundation for rapid and safe iOS development. It allows you to cut down your development costs by improving reusability, testa

Brightify 374 Nov 22, 2022
Viper Framework for iOS using Swift

Write an iOS app following VIPER architecture. But in an easy way. Viper the easy way We all know Viper is cool. But we also know that it's hard to se

Ferran Abelló 504 Dec 31, 2022
YARCH iOS Architecture

YARCH is an architecture pattern developed primarly for iOS applications. You can ask any questions in our telegram channel. Russian version of the re

Alfa Digital 196 Jan 3, 2023
Sample applications of iOS Design patterns written using swift.

ios-design-patterns This repo contains all my Sample applications of iOS Design patterns written using swift. Link for my Design patterns Blog : https

ShreeThaanu Raveendran 3 Nov 2, 2022
A holistic approach to iOS development, inspired by Redux and MVVM

Tempura is a holistic approach to iOS development, it borrows concepts from Redux (through Katana) and MVVM. ?? Installation Requirements CocoaPods Sw

Bending Spoons 693 Jan 4, 2023
A simple and predictable state management library inspired by Flux + Elm + Redux.

A simple and predictable state management library inspired by Flux + Elm + Redux. Flywheel is built on top of Corotuines using the concepts of structured concurrency. At the core, lies the State Machine which is based on actor model.

Abhi Muktheeswarar 35 Dec 29, 2022
🤖 RxSwift + State Machine, inspired by Redux and Elm.

RxAutomaton RxSwift port of ReactiveAutomaton (State Machine). Terminology Whenever the word "signal" or "(signal) producer" appears (derived from Rea

Yasuhiro Inami 719 Nov 19, 2022
Swift Apps in a Swoosh! A modern framework for creating iOS apps, inspired by Redux.

Katana is a modern Swift framework for writing iOS applications' business logic that are testable and easy to reason about. Katana is strongly inspire

Bending Spoons 2.2k Jan 1, 2023
Swift Apps in a Swoosh! A modern framework for creating iOS apps, inspired by Redux.

Katana is a modern Swift framework for writing iOS applications' business logic that are testable and easy to reason about. Katana is strongly inspire

Bending Spoons 2.2k Dec 17, 2022
Unidirectional Data Flow in Swift - Inspired by Redux

ReSwift Supported Swift Versions: Swift 4.2, 5.x For Swift 3.2 or 4.0 Support use Release 5.0.0 or earlier. For Swift 2.2 Support use Release 2.0.0 or

null 7.3k Dec 25, 2022
Unidirectional Data Flow in Swift - Inspired by Redux

ReSwift Supported Swift Versions: Swift 4.2, 5.x For Swift 3.2 or 4.0 Support use Release 5.0.0 or earlier. For Swift 2.2 Support use Release 2.0.0 or

null 7.3k Jan 3, 2023