diff --git a/README.md b/README.md index d27caef2..5520755d 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,34 @@ self.dispatch(Show("add item screen")) Learn more about the navigation [here](http://tempura.bendingspoons.com/Classes/Navigator.html) +### 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. + +```swift +class ParentView: UIView, ViewControllerModellableView { + var childView = ContainerView() +} +``` + +Then, in the parent ViewController you just need to add the child ViewController: + +```swift +class ParentViewController: ViewController { + let childVC: ChildViewController! + + override func setup() { + childVC = ChildViewController(store: self.store) + self.add(childVC, in: self.rootView.childView) + } +} +``` + +All of 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 Testing Tempura has a UI testing system that can be used to take screenshots of your views in all possible states, with all devices and all supported languages. diff --git a/Tempura.xcodeproj/project.pbxproj b/Tempura.xcodeproj/project.pbxproj index 18423998..82873359 100644 --- a/Tempura.xcodeproj/project.pbxproj +++ b/Tempura.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 646B03F7BDF4F9F857124D2B /* AddItemViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BD49BEEC5984A9FE893FB3 /* AddItemViewController.swift */; }; 6506175121820AA400D9C6EE /* Tempura.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8A6B80CA27551310DFE47630 /* Tempura.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 650617542182211B00D9C6EE /* ViewController+Containment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650617532182211B00D9C6EE /* ViewController+Containment.swift */; }; + 650617582182F6BF00D9C6EE /* ViewControlerContainmentSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650617572182F6BF00D9C6EE /* ViewControlerContainmentSpec.swift */; }; 655CFAC22181FCDF00602543 /* ChildViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 655CFAC12181FCDF00602543 /* ChildViewController.swift */; }; 6708BA66963C651491A3A686 /* String+Random.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57A5D20495241273A74CF0DD /* String+Random.swift */; }; 6AA037B540B41299BCF0874D /* Source.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC63AF968F64847B54F746AC /* Source.swift */; }; @@ -70,7 +71,7 @@ DBEEDC5070F1927CE2560282 /* ViewControllerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B1DFC03BB4FDA7BD20AC40 /* ViewControllerSpec.swift */; }; DD09610E6461E833B3FA6236 /* DependenciesContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DD4CFFDBDD14CB66C4794A /* DependenciesContainer.swift */; }; E004EA54447E1A911B2D9C2B /* DemoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86EE1E3D4E6D15D61928C89E /* DemoTests.swift */; }; - E1976753698708E504DE26EA /* ViewControllerWithLocalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2067F5E2669FA20801183DE0 /* ViewControllerWithLocalState.swift */; }; + E1976753698708E504DE26EA /* ViewControllerWithLocalStateSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2067F5E2669FA20801183DE0 /* ViewControllerWithLocalStateSpec.swift */; }; E38E38B57718A80FA1A632A6 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4E30FE8B5C021F357FE2C84A /* Foundation.framework */; }; E7A40310BF53C477558660EB /* Tempura.h in Headers */ = {isa = PBXBuildFile; fileRef = 07B1C39AEB59B5BE80D2A81B /* Tempura.h */; settings = {ATTRIBUTES = (Public, ); }; }; E7E2A15E2F43D5648D7F2380 /* ViewControllerModellableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24E6F5C44777CAC59EA5986C /* ViewControllerModellableView.swift */; }; @@ -131,7 +132,7 @@ 18736DA8E9077F04A3700C7D /* Pods-DemoTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DemoTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-DemoTests/Pods-DemoTests.release.xcconfig"; sourceTree = ""; }; 1A5A0B668E2DD5AAFA1FB47C /* CollectionView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CollectionView.swift; sourceTree = ""; }; 1BD94E381324B88145DAC193 /* ViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; - 2067F5E2669FA20801183DE0 /* ViewControllerWithLocalState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ViewControllerWithLocalState.swift; sourceTree = ""; }; + 2067F5E2669FA20801183DE0 /* ViewControllerWithLocalStateSpec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ViewControllerWithLocalStateSpec.swift; sourceTree = ""; }; 23A61E64E61A01C89A3F168A /* Models.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; 24A72485EA8186BC7C9116B1 /* Pods-Tempura-Demo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tempura-Demo.release.xcconfig"; path = "Pods/Target Support Files/Pods-Tempura-Demo/Pods-Tempura-Demo.release.xcconfig"; sourceTree = ""; }; 24E6F5C44777CAC59EA5986C /* ViewControllerModellableView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ViewControllerModellableView.swift; sourceTree = ""; }; @@ -152,6 +153,7 @@ 5DADC016CF7CDA8114A79501 /* TempuraTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TempuraTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 61372EC0A04886BC73F0385A /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 650617532182211B00D9C6EE /* ViewController+Containment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ViewController+Containment.swift"; sourceTree = ""; }; + 650617572182F6BF00D9C6EE /* ViewControlerContainmentSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControlerContainmentSpec.swift; sourceTree = ""; }; 655CFAC12181FCDF00602543 /* ChildViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChildViewController.swift; sourceTree = ""; }; 65BD49BEEC5984A9FE893FB3 /* AddItemViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AddItemViewController.swift; sourceTree = ""; }; 724D934C812B7C374C74714F /* Pods_Tempura_Demo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Tempura_Demo.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -467,7 +469,8 @@ isa = PBXGroup; children = ( 13B1DFC03BB4FDA7BD20AC40 /* ViewControllerSpec.swift */, - 2067F5E2669FA20801183DE0 /* ViewControllerWithLocalState.swift */, + 2067F5E2669FA20801183DE0 /* ViewControllerWithLocalStateSpec.swift */, + 650617572182F6BF00D9C6EE /* ViewControlerContainmentSpec.swift */, ); path = TempuraTests; sourceTree = ""; @@ -984,7 +987,8 @@ buildActionMask = 2147483647; files = ( DBEEDC5070F1927CE2560282 /* ViewControllerSpec.swift in Sources */, - E1976753698708E504DE26EA /* ViewControllerWithLocalState.swift in Sources */, + E1976753698708E504DE26EA /* ViewControllerWithLocalStateSpec.swift in Sources */, + 650617582182F6BF00D9C6EE /* ViewControlerContainmentSpec.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Tempura.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme b/Tempura.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme index 5bfca189..63e9dafa 100644 --- a/Tempura.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme +++ b/Tempura.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme @@ -7,56 +7,76 @@ buildImplicitDependencies = "YES"> + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + ReferencedContainer = "container:Tempura.xcodeproj"> + buildForArchiving = "NO" + buildForAnalyzing = "YES"> + ReferencedContainer = "container:Tempura.xcodeproj"> - - + shouldUseLaunchSchemeArgsEnv = "YES"> + ReferencedContainer = "container:Tempura.xcodeproj"> + + + + + + + + + + - - + ReferencedContainer = "container:Tempura.xcodeproj"> + + - + debugDocumentVersioning = "YES"> + + ReferencedContainer = "container:Tempura.xcodeproj"> diff --git a/Tempura.xcodeproj/xcshareddata/xcschemes/TempuraTesting.xcscheme b/Tempura.xcodeproj/xcshareddata/xcschemes/TempuraTesting.xcscheme index 24d920c9..39cbfa62 100644 --- a/Tempura.xcodeproj/xcshareddata/xcschemes/TempuraTesting.xcscheme +++ b/Tempura.xcodeproj/xcshareddata/xcschemes/TempuraTesting.xcscheme @@ -7,30 +7,52 @@ buildImplicitDependencies = "YES"> + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "NO" + buildForAnalyzing = "YES"> + ReferencedContainer = "container:Tempura.xcodeproj"> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + + + + + + - - + ReferencedContainer = "container:Tempura.xcodeproj"> + + - + debugDocumentVersioning = "YES"> + + ReferencedContainer = "container:Tempura.xcodeproj"> diff --git a/Tempura/Core/ViewController+Containment.swift b/Tempura/Core/ViewController+Containment.swift index 44ccb6b1..f3f2ebfa 100644 --- a/Tempura/Core/ViewController+Containment.swift +++ b/Tempura/Core/ViewController+Containment.swift @@ -9,19 +9,22 @@ /// ViewController containment extension ViewController { /// Add a ViewController (and its rootView) as a child of self - /// You must provide a ContainerView inside self.rootView to receive the rootView of the child ViewController + /// You must provide a ContainerView inside self.rootView, that container view + /// will automatically receive the rootView of the child ViewController as child public func add(_ child: ViewController, in view: ContainerView) { self.addChild(child) view.addSubview(child.rootView) child.didMove(toParent: self) } - /// Remove a child ViewController + /// Remove self as child ViewController of parent public func remove() { guard let _ = parent else { return } self.willMove(toParent: nil) self.removeFromParent() self.rootView.removeFromSuperview() + self.viewWillDisappear(false) + self.viewDidDisappear(false) } } diff --git a/TempuraTests/ViewControlerContainmentSpec.swift b/TempuraTests/ViewControlerContainmentSpec.swift new file mode 100644 index 00000000..de19eea7 --- /dev/null +++ b/TempuraTests/ViewControlerContainmentSpec.swift @@ -0,0 +1,170 @@ +// +// ViewControlerContainmentSpec.swift +// TempuraTests +// +// Created by Andrea De Angelis on 26/10/2018. +// + +@testable import Tempura +import Katana +import Quick +import Nimble + +class ViewControllerContainmentSpec: QuickSpec { + override func spec() { + describe("ViewController containment") { + + struct AppState: State { + var counter: Int = 0 + } + + struct Increment: Action { + func updatedState(currentState: State) -> State { + guard var state = currentState as? AppState else { + fatalError() + } + state.counter += 1 + return state + } + } + + + struct TestViewModel: ViewModelWithState { + var counter: Int = 0 + + init?(state: AppState) { + self.counter = state.counter + } + + init(counter: Int) { + self.counter = counter + } + } + + class TestView: UIView, ViewControllerModellableView { + var numberOfTimesSetupIsCalled: Int = 0 + var numberOfTimesStyleIsCalled: Int = 0 + var numberOfTimesUpdateIsCalled: Int = 0 + var lastOldModel: TestViewModel? + + typealias VM = TestViewModel + func setup() { + self.numberOfTimesSetupIsCalled += 1 + } + + func style() { + self.numberOfTimesStyleIsCalled += 1 + } + + func update(oldModel: TestViewModel?) { + self.numberOfTimesUpdateIsCalled += 1 + self.lastOldModel = oldModel + } + + override func layoutSubviews() { + + } + } + + class MainView: TestView { + var container: ContainerView = ContainerView() + + override func setup() { + super.setup() + self.addSubview(self.container) + } + + override func layoutSubviews() { + super.layoutSubviews() + self.container.frame = CGRect(x: 0, y: 0, width: 100, height: 100) + } + } + + class ChildView: TestView {} + + class TestViewController: ViewController { + var numberOfTimesWillUpdateIsCalled: Int = 0 + var viewModelWhenWillUpdateHasBeenCalled: V.VM? + var newViewModelWhenWillUpdateHasBeenCalled: V.VM? + var numberOfTimesDidUpdateIsCalled: Int = 0 + var viewModelWhenDidUpdateHasBeenCalled: V.VM? + var oldViewModelWhenDidUpdateHasBeenCalled: V.VM? + + override func willUpdate(new: V.VM?) { + self.numberOfTimesWillUpdateIsCalled += 1 + self.viewModelWhenWillUpdateHasBeenCalled = self.viewModel + self.newViewModelWhenWillUpdateHasBeenCalled = new + } + + override func didUpdate(old: V.VM?) { + self.numberOfTimesDidUpdateIsCalled += 1 + self.viewModelWhenDidUpdateHasBeenCalled = self.viewModel + self.oldViewModelWhenDidUpdateHasBeenCalled = old + } + } + + class MainViewController: TestViewController {} + + class ChildViewController: TestViewController {} + + var store: Store! + var mainVC: MainViewController! + var childVC: ChildViewController! + + beforeEach { + store = Store(middleware: [], dependencies: EmptySideEffectDependencyContainer.self) + mainVC = MainViewController(store: store, connected: true) + } + + + it("will call update on the Child VC when the state is changed") { + childVC = ChildViewController(store: store, connected: true) + mainVC.add(childVC, in: mainVC.rootView.container) + mainVC.viewWillAppear(true) + expect(mainVC.rootView.model?.counter).to(equal(0)) + expect(childVC.rootView.model?.counter).to(equal(0)) + store.dispatch(Increment()) + expect(mainVC.rootView.model?.counter).toEventually(equal(1)) + expect(childVC.rootView.model?.counter).toEventually(equal(1)) + } + + it("will have the childView as child of the parentView") { + childVC = ChildViewController(store: store, connected: true) + mainVC.add(childVC, in: mainVC.rootView.container) + expect(childVC.rootView.superview).to(equal(mainVC.rootView.container)) + } + + it("will not receive updates once disconnected") { + childVC = ChildViewController(store: store, connected: true) + mainVC.add(childVC, in: mainVC.rootView.container) + mainVC.viewWillAppear(true) + expect(mainVC.rootView.model?.counter).to(equal(0)) + expect(childVC.rootView.model?.counter).to(equal(0)) + childVC.connected = false + store.dispatch(Increment()) + expect(mainVC.rootView.model?.counter).toEventually(equal(1)) + expect(childVC.rootView.model?.counter).toNotEventually(equal(1)) + } + + it("will be removed from the View hierarchy when removed from the parent VC") { + childVC = ChildViewController(store: store, connected: true) + mainVC.add(childVC, in: mainVC.rootView.container) + expect(childVC.rootView.superview).to(equal(mainVC.rootView.container)) + childVC.remove() + expect(childVC.rootView.superview).to(beNil()) + } + + it("will not receive updates when removed from the parent VC") { + childVC = ChildViewController(store: store, connected: true) + mainVC.add(childVC, in: mainVC.rootView.container) + mainVC.viewWillAppear(true) + expect(mainVC.rootView.model?.counter).to(equal(0)) + expect(childVC.rootView.model?.counter).to(equal(0)) + childVC.remove() + store.dispatch(Increment()) + expect(mainVC.rootView.model?.counter).toEventually(equal(1)) + expect(childVC.rootView.model?.counter).toNotEventually(equal(1)) + } + } + } +} diff --git a/TempuraTests/ViewControllerWithLocalState.swift b/TempuraTests/ViewControllerWithLocalStateSpec.swift similarity index 100% rename from TempuraTests/ViewControllerWithLocalState.swift rename to TempuraTests/ViewControllerWithLocalStateSpec.swift