├── .gitattributes ├── .gitignore ├── .swiftlint.yml ├── Images ├── Coordinators.png ├── iPad_1.png └── iPad_2.png ├── MVVMC-SplitViewController.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── MVVMC-SplitViewController.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── MVVMC-SplitViewController ├── Application │ ├── AppCoordinator.swift │ ├── AppDelegate.swift │ └── AppDependency.swift ├── Base.lproj │ └── LaunchScreen.storyboard ├── Extensions │ ├── ColorCompatibility+Ext.swift │ └── Observable+Ext.swift ├── Models │ ├── Address.swift │ ├── Album.swift │ ├── Comment.swift │ ├── Company.swift │ ├── Geo.swift │ ├── Photo.swift │ ├── Post.swift │ ├── Todo.swift │ └── User.swift ├── Protocols │ ├── CoordinatorType.swift │ ├── ErrorAlertDisplayable.swift │ ├── PrimaryContainerType.swift │ ├── Reusable.swift │ ├── ViewModelAttaching.swift │ └── ViewModelType.swift ├── Resources │ └── Assets.xcassets │ │ ├── AlbumsTabIcon.imageset │ │ ├── Contents.json │ │ └── albums.pdf │ │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-20.png │ │ ├── Icon-20@2x-1.png │ │ ├── Icon-20@2x.png │ │ ├── Icon-20@3x.png │ │ ├── Icon-29.png │ │ ├── Icon-29@2x-1.png │ │ ├── Icon-29@2x.png │ │ ├── Icon-29@3x.png │ │ ├── Icon-40.png │ │ ├── Icon-40@2x-1.png │ │ ├── Icon-40@2x.png │ │ ├── Icon-40@3x.png │ │ ├── Icon-512@2x.png │ │ ├── Icon-60@2x.png │ │ ├── Icon-60@3x.png │ │ ├── Icon-76.png │ │ ├── Icon-76@2x.png │ │ └── Icon-83.5@2x.png │ │ ├── AquaHaze.colorset │ │ └── Contents.json │ │ ├── AthensGray.colorset │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── EmptyViewBackground.imageset │ │ ├── Contents.json │ │ └── cloud.pdf │ │ ├── PostsTabIcon.imageset │ │ ├── Contents.json │ │ └── today_apps.pdf │ │ ├── ProfileTabIcon.imageset │ │ ├── Contents.json │ │ └── user_male.pdf │ │ ├── Settings.imageset │ │ ├── Contents.json │ │ └── settings.pdf │ │ ├── StarDust.colorset │ │ └── Contents.json │ │ └── TodosTabIcon.imageset │ │ ├── Contents.json │ │ └── list.pdf ├── Scenes │ ├── Albums │ │ ├── AlbumList │ │ │ ├── AlbumListViewController.storyboard │ │ │ ├── AlbumListViewController.swift │ │ │ └── AlbumListViewModel.swift │ │ ├── AlbumsCoordinator.swift │ │ ├── PhotoCollection │ │ │ ├── PhotoCell.swift │ │ │ ├── PhotoCellViewModel.swift │ │ │ ├── PhotoCollectionViewController.storyboard │ │ │ ├── PhotoCollectionViewController.swift │ │ │ └── PhotoCollectionViewModel.swift │ │ └── PhotoDetail │ │ │ ├── PhotoDetailViewController.storyboard │ │ │ ├── PhotoDetailViewController.swift │ │ │ └── PhotoDetailViewModel.swift │ ├── Common │ │ ├── ActivityIndicator.swift │ │ ├── BaseCoordinator.swift │ │ ├── DetailNavigationController.swift │ │ ├── DetailNavigationControllerAnimator.swift │ │ ├── ErrorTracker.swift │ │ ├── NavigationController.swift │ │ ├── PlaceholderViewController.swift │ │ ├── SplitViewCoordinator.swift │ │ ├── SplitViewDelegate.swift │ │ ├── TabBarController.swift │ │ ├── TabBarCoordinator.swift │ │ └── TableViewController.swift │ ├── Login │ │ ├── LoginCoordinator.swift │ │ ├── LoginViewController.swift │ │ └── LoginViewModel.swift │ ├── Posts │ │ ├── PostDetail │ │ │ ├── PostDetailViewController.storyboard │ │ │ ├── PostDetailViewController.swift │ │ │ └── PostDetailViewModel.swift │ │ ├── PostsCoordinator.swift │ │ └── PostsList │ │ │ ├── PostTableViewCell.swift │ │ │ ├── PostsListViewController.storyboard │ │ │ ├── PostsListViewController.swift │ │ │ └── PostsListViewModel.swift │ ├── Profile │ │ ├── ProfileCoordinator.swift │ │ ├── ProfileViewController.storyboard │ │ ├── ProfileViewController.swift │ │ └── ProfileViewModel.swift │ ├── Settings │ │ ├── SettingsCoordinator.swift │ │ ├── SettingsViewController.storyboard │ │ ├── SettingsViewController.swift │ │ └── SettingsViewModel.swift │ ├── Signup │ │ ├── SignupCoordinator.swift │ │ ├── SignupViewController.storyboard │ │ ├── SignupViewController.swift │ │ └── SignupViewModel.swift │ └── Todos │ │ ├── TodosCoordinator.swift │ │ └── TodosList │ │ ├── TodosListViewController.storyboard │ │ ├── TodosListViewController.swift │ │ └── TodosListViewModel.swift ├── Services │ ├── APIClient.swift │ ├── AlbumService.swift │ ├── PostService.swift │ ├── Router.swift │ ├── TodoService.swift │ └── UserManager.swift └── Supporting Files │ └── Info.plist ├── MVVMC-SplitViewControllerTests ├── Info.plist └── MVVMC_SplitViewControllerTests.swift ├── Podfile ├── Podfile.lock └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj merge=union -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # /Pods 2 | 3 | # OS X Finder 4 | .DS_Store 5 | 6 | ## Build generated 7 | build/ 8 | DerivedData/ 9 | 10 | # Xcode per-user config 11 | *.mode1 12 | *.mode1v3 13 | *.mode2v3 14 | *.pbxuser 15 | *.perspective 16 | *.perspectivev3 17 | xcuserdata/ 18 | # Whitelist defaults since some projects need them 19 | !default.mode1v3 20 | !default.mode2v3 21 | !default.pbxuser 22 | !default.perspectivev3 23 | 24 | # Xcode temporary files 25 | *~.nib 26 | 27 | # Other 28 | *.moved-aside 29 | *.xccheckout 30 | 31 | # CocoaPods 32 | Pods -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: # rule identifiers to exclude from running 2 | - todo 3 | 4 | excluded: # paths to ignore during linting. Takes precedence over `included`. 5 | - Pods 6 | 7 | # configurable rules can be customized from this configuration file 8 | # binary rules can set their severity level 9 | force_cast: warning # implicitly. Give warning only for force casting 10 | 11 | force_try: 12 | severity: warning # explicitly. Give warning only for force try 13 | 14 | # naming rules can set warnings/errors for min_length and max_length 15 | # additionally they can set excluded names 16 | identifier_name: 17 | excluded: 18 | - bs 19 | - id 20 | - in 21 | - of 22 | - vc 23 | 24 | large_tuple: # warn user when using 3 values in tuple, give error if there are 4 25 | - 3 26 | - 4 27 | 28 | line_length: 29 | # warning: 120 30 | ignores_function_declarations: true 31 | ignores_comments: true 32 | 33 | # naming rules can set warnings/errors for min_length and max_length 34 | # additionally they can set excluded names 35 | type_name: 36 | min_length: 4 # only warning 37 | max_length: # warning and error 38 | warning: 30 39 | error: 35 40 | excluded: iPhone # excluded via string 41 | excluded: in 42 | reporter: "xcode" -------------------------------------------------------------------------------- /Images/Coordinators.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/Images/Coordinators.png -------------------------------------------------------------------------------- /Images/iPad_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/Images/iPad_1.png -------------------------------------------------------------------------------- /Images/iPad_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/Images/iPad_2.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Application/AppCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppCoordinator.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/28/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | 12 | class AppCoordinator: BaseCoordinator { 13 | private let window: UIWindow 14 | private let dependencies: AppDependency 15 | 16 | init(window: UIWindow) { 17 | self.window = window 18 | self.dependencies = AppDependency() 19 | } 20 | 21 | override func start() -> Observable { 22 | coordinateToRoot(basedOn: dependencies.userManager.authenticationState) 23 | return .never() 24 | } 25 | 26 | /// Recursive method that will restart a child coordinator after completion. 27 | /// Based on: 28 | /// https://github.com/uptechteam/Coordinator-MVVM-Rx-Example/issues/3 29 | private func coordinateToRoot(basedOn authState: AuthenticationState) { 30 | switch authState { 31 | case .signedIn: 32 | return showSplitView() 33 | .subscribe(onNext: { [weak self] authState in 34 | self?.window.rootViewController = nil 35 | self?.coordinateToRoot(basedOn: authState) 36 | }) 37 | .disposed(by: disposeBag) 38 | case .signedOut: 39 | return showLogin() 40 | .subscribe(onNext: { [weak self] authState in 41 | self?.window.rootViewController = nil 42 | self?.coordinateToRoot(basedOn: authState) 43 | }) 44 | .disposed(by: disposeBag) 45 | } 46 | } 47 | 48 | private func showSplitView() -> Observable { 49 | let splitViewCoordinator = SplitViewCoordinator(window: self.window, dependencies: dependencies) 50 | return coordinate(to: splitViewCoordinator) 51 | .map { [unowned self] _ in self.dependencies.userManager.authenticationState } 52 | } 53 | 54 | private func showLogin() -> Observable { 55 | let loginCoordinator = LoginCoordinator(window: window, dependencies: dependencies) 56 | return coordinate(to: loginCoordinator) 57 | .map { [unowned self] _ in self.dependencies.userManager.authenticationState } 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Application/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/21/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | 12 | @UIApplicationMain 13 | class AppDelegate: UIResponder, UIApplicationDelegate { 14 | 15 | var window: UIWindow? 16 | private var appCoordinator: AppCoordinator! 17 | private let disposeBag = DisposeBag() 18 | 19 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 20 | 21 | self.window = UIWindow(frame: UIScreen.main.bounds) 22 | 23 | self.appCoordinator = AppCoordinator(window: self.window!) 24 | self.appCoordinator.start() 25 | .subscribe() 26 | .disposed(by: self.disposeBag) 27 | /* 28 | #if DEBUG 29 | _ = Observable.interval(1, scheduler: MainScheduler.instance) 30 | .subscribe(onNext: { _ in 31 | print("Resource count \(RxSwift.Resources.total)") 32 | }) 33 | #endif 34 | */ 35 | return true 36 | } 37 | 38 | func applicationWillResignActive(_ application: UIApplication) { 39 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 40 | // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. 41 | } 42 | 43 | func applicationDidEnterBackground(_ application: UIApplication) { 44 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 45 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 46 | } 47 | 48 | func applicationWillEnterForeground(_ application: UIApplication) { 49 | // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. 50 | } 51 | 52 | func applicationDidBecomeActive(_ application: UIApplication) { 53 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 54 | } 55 | 56 | func applicationWillTerminate(_ application: UIApplication) { 57 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Application/AppDependency.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDependency.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/2/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // MARK: - A 12 | // http://merowing.info/2017/04/using-protocol-compositon-for-dependency-injection/ 13 | // https://www.swiftbysundell.com/posts/dependency-injection-using-factories-in-swift 14 | 15 | protocol HasClient { 16 | var client: APIClient { get } 17 | } 18 | 19 | protocol HasUserManager { 20 | var userManager: UserManager { get } 21 | } 22 | 23 | protocol HasAlbumService { 24 | var albumService: AlbumServiceType { get } 25 | } 26 | 27 | protocol HasPostService { 28 | var postService: PostServiceType { get } 29 | } 30 | 31 | protocol HasTodoService { 32 | var todoService: TodoServiceType { get } 33 | } 34 | 35 | struct AppDependency: HasClient, HasUserManager, HasAlbumService, HasPostService, HasTodoService { 36 | let client: APIClient 37 | let userManager: UserManager 38 | let albumService: AlbumServiceType 39 | let postService: PostServiceType 40 | let todoService: TodoServiceType 41 | 42 | init() { 43 | self.client = APIClient() 44 | self.userManager = UserManager() 45 | self.albumService = AlbumService(client: client) 46 | self.postService = PostService(client: client) 47 | self.todoService = TodoService(client: client) 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Extensions/ColorCompatibility+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorCompatibility+Ext.swift 3 | // MVVMC-SplitViewController 4 | // 5 | 6 | import UIKit 7 | import ColorCompatibility 8 | 9 | // MARK - RGBA Initializer 10 | extension UIColor { 11 | 12 | /// Creates a color object using the specified opacity and CSS RGB values. 13 | /// - Parameter r: The red value of the color object. Values below 0 are interpreted as 0 and values above 255 are interpreted as 255. 14 | /// - Parameter g: The green value of the color object. Values below 0 are interpreted as 0 and values above 255 are interpreted as 255. 15 | /// - Parameter b: The blue value of the color object. Values below 0 are interpreted as 0 and values above 255 are interpreted as 255. 16 | /// - Parameter a: The opacity value of the color object, specified as a value from 0.0 to 1.0. Alpha values below 0.0 are interpreted as 0.0, and values above 1.0 are interpreted as 1.0. 17 | convenience init(r red: Int, g green: Int, b blue: Int, a alpha: CGFloat) { 18 | self.init(red: UIColor.clampingPercentage(red), 19 | green: UIColor.clampingPercentage(green), 20 | blue: UIColor.clampingPercentage(blue), 21 | alpha: alpha) 22 | } 23 | 24 | fileprivate static func clampingPercentage(_ value: Int) -> CGFloat { 25 | if value <= 0 { 26 | return 0.0 27 | } else if value >= 255 { 28 | return 1.0 29 | } else { 30 | return CGFloat(value) / 255.0 31 | } 32 | } 33 | } 34 | 35 | // See https://noahgilmore.com/blog/dark-mode-uicolor-compatibility/ 36 | extension ColorCompatibility { 37 | 38 | // MARK: - Standard Colors 39 | 40 | // MARK: Adaptable Colors 41 | 42 | /// A blue color that automatically adapts to the current trait environment. 43 | static var systemBlue: UIColor { 44 | if #available(iOS 13, *) { 45 | return .systemBlue 46 | } 47 | // Dark 48 | //return UIColor(r: 10, g: 132, b: 255, a: 1.0) 49 | // Light 50 | return UIColor(r: 0, g: 122, b: 255, a: 1.0) 51 | } 52 | 53 | /// A green color that automatically adapts to the current trait environment. 54 | static var systemGreen: UIColor { 55 | if #available(iOS 13, *) { 56 | return .systemGreen 57 | } 58 | // Dark 59 | //return UIColor(r: 48, g: 209, b: 88, a: 1.0) 60 | // Light 61 | return UIColor(r: 52, g: 199, b: 89, a: 1.0) 62 | } 63 | 64 | /// An indigo color that automatically adapts to the current trait environment. 65 | static var systemIndigo: UIColor { 66 | if #available(iOS 13, *) { 67 | return .systemIndigo 68 | } 69 | // Dark 70 | //return UIColor(r: 94, g: 92, b: 230, a: 1.0) 71 | // Light 72 | return UIColor(r: 88, g: 86, b: 214, a: 1.0) 73 | } 74 | 75 | /// An orange color that automatically adapts to the current trait environment. 76 | static var systemOrange: UIColor { 77 | if #available(iOS 13, *) { 78 | return .systemOrange 79 | } 80 | // Dark 81 | //return UIColor(r: 255, g: 159, b: 10, a: 1.0) 82 | // Light 83 | return UIColor(r: 255, g: 149, b: 0, a: 1.0) 84 | } 85 | 86 | /// A pink color that automatically adapts to the current trait environment. 87 | static var systemPink: UIColor { 88 | if #available(iOS 13, *) { 89 | return .systemPink 90 | } 91 | // Dark 92 | //return UIColor(r: 255, g: 55, b: 95, a: 1.0) 93 | // Light 94 | return UIColor(r: 255, g: 45, b: 85, a: 1.0) 95 | } 96 | 97 | /// A purple color that automatically adapts to the current trait environment. 98 | static var systemPurple: UIColor { 99 | if #available(iOS 13, *) { 100 | return .systemPurple 101 | } 102 | // Dark 103 | //return UIColor(r: 191, g: 90, b: 242, a: 1.0) 104 | // Light 105 | return UIColor(r: 175, g: 82, b: 222, a: 1.0) 106 | } 107 | 108 | /// A red color that automatically adapts to the current trait environment. 109 | static var systemRed: UIColor { 110 | if #available(iOS 13, *) { 111 | return .systemRed 112 | } 113 | // Dark 114 | //return UIColor(r: 255, g: 69, b: 58, a: 1.0) 115 | // Light 116 | return UIColor(r: 255, g: 59, b: 48, a: 1.0) 117 | } 118 | 119 | /// A teal color that automatically adapts to the current trait environment. 120 | static var systemTeal: UIColor { 121 | if #available(iOS 13, *) { 122 | return .systemTeal 123 | } 124 | // Dark 125 | //return UIColor(r: 100, g: 210, b: 255, a: 1.0) 126 | // Light 127 | return UIColor(r: 90, g: 200, b: 250, a: 1.0) 128 | } 129 | 130 | /// A yellow color that automatically adapts to the current trait environment. 131 | static var systemYellow: UIColor { 132 | if #available(iOS 13, *) { 133 | return .systemYellow 134 | } 135 | // Dark 136 | //return UIColor(r: 255, g: 214, b: 10, a: 1.0) 137 | // Light 138 | return UIColor(r: 255, g: 204, b: 0, a: 1.0) 139 | } 140 | 141 | } 142 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Extensions/Observable+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Observable+Ext.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/27/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | // From: 9 | // https://github.com/sergdort/CleanArchitectureRxSwift 10 | // Copyright (c) 2017 Sergey Shulga 11 | // 12 | 13 | import RxSwift 14 | import RxCocoa 15 | 16 | extension ObservableType { 17 | 18 | func asDriverOnErrorJustComplete() -> Driver { 19 | return asDriver { _ in 20 | return Driver.empty() 21 | } 22 | } 23 | 24 | func mapToVoid() -> Observable { 25 | return map { _ in } 26 | } 27 | } 28 | 29 | extension PrimitiveSequenceType where Trait == SingleTrait { 30 | 31 | func asDriverOnErrorJustComplete() -> Driver { 32 | return self.primitiveSequence.asDriver { _ in 33 | return Driver.empty() 34 | } 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Models/Address.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Address.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/26/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Address: Codable { 12 | let street: String 13 | let suite: String 14 | let city: String 15 | let zipcode: String 16 | let geo: Geo 17 | } 18 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Models/Album.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Album.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/26/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Album: Codable { 12 | let userId: Int 13 | let id: Int 14 | let title: String 15 | } 16 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Models/Comment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Comment.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/26/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Comment: Codable { 12 | let postId: Int 13 | let id: Int 14 | let name: String 15 | let email: String 16 | let body: String 17 | } 18 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Models/Company.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Company.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/26/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Company: Codable { 12 | let name: String 13 | let catchPhrase: String 14 | let bs: String 15 | } 16 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Models/Geo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Geo.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/26/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // swiftlint:disable:next type_name 12 | struct Geo: Codable { 13 | let lat: String 14 | let lng: String 15 | } 16 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Models/Photo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Photo.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/26/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Photo: Codable { 12 | let albumId: Int 13 | let id: Int 14 | let title: String 15 | let url: URL 16 | let thumbnailUrl: URL 17 | } 18 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Models/Post.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Post.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/26/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Post: Codable { 12 | let userId: Int 13 | let id: Int 14 | let title: String 15 | let body: String 16 | } 17 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Models/Todo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Todo.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/26/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Todo: Codable { 12 | let userId: Int 13 | let id: Int 14 | let title: String 15 | let completed: Bool 16 | } 17 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Models/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/26/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct User: Codable { 12 | let id: Int 13 | let name: String 14 | let username: String 15 | let email: String 16 | let address: Address? 17 | let phone: String? 18 | let website: String? 19 | let company: Company? 20 | } 21 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Protocols/CoordinatorType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Coordinator.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/23/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | 11 | protocol CoordinatorType { 12 | associatedtype CoordinationResult 13 | var identifier: UUID { get } 14 | func start() -> Observable 15 | } 16 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Protocols/ErrorAlertDisplayable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorAlertDisplayable.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 2/27/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | 12 | protocol ErrorAlertDisplayable { 13 | var errorAlert: Binder { get } 14 | } 15 | 16 | extension ErrorAlertDisplayable where Self: UIViewController { 17 | var errorAlert: Binder { 18 | return Binder(self) { viewController, message in 19 | let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert) 20 | let action = UIAlertAction(title: "Dismiss", style: .cancel, handler: nil) 21 | alert.addAction(action) 22 | viewController.present(alert, animated: true, completion: nil) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Protocols/PrimaryContainerType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrimaryContainerType.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/28/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | typealias PrimaryContainerType = SplitTabRootViewControllerType & PlaceholderFactory 12 | 13 | /// Represents state of detail view controller in split view controller. 14 | enum DetailView { 15 | case collapsed(UIViewController) 16 | case separated(UIViewController) 17 | case placeholder 18 | } 19 | 20 | protocol SplitTabRootViewControllerType: AnyObject { 21 | /// Called to update secondary view controller with `PlaceholderViewControllerType` when popping view controller. 22 | var detailPopCompletion: (UIViewController & PlaceholderViewControllerType) -> Void { get } 23 | 24 | /// Represents state of detail view controller in split view controller. 25 | var detailView: DetailView { get set } 26 | 27 | /// Add detail view controller to `viewControllers` if it is visible and update `detailView`. 28 | func collapseDetail() 29 | 30 | /// Remove detail view controller from `viewControllers` if it is visible and update `detailView`. 31 | func separateDetail() 32 | } 33 | 34 | extension SplitTabRootViewControllerType where Self: UINavigationController { 35 | 36 | func collapseDetail() { 37 | switch detailView { 38 | case .separated(let detailViewController): 39 | viewControllers += [detailViewController] 40 | detailView = .collapsed(detailViewController) 41 | default: 42 | return 43 | } 44 | } 45 | 46 | func separateDetail() { 47 | switch detailView { 48 | case .collapsed(let detailViewController): 49 | viewControllers.removeLast() 50 | detailView = .separated(detailViewController) 51 | default: 52 | return 53 | } 54 | } 55 | 56 | } 57 | 58 | /// Represents empty detail view controller. 59 | protocol PlaceholderViewControllerType: AnyObject {} 60 | 61 | protocol PlaceholderFactory { 62 | /// Factory method to produce tab-specific placeholder for secondary view controller. 63 | func makePlaceholderViewController() -> UIViewController & PlaceholderViewControllerType 64 | } 65 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Protocols/Reusable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Reusable.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/27/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | // Based on code from: 9 | // https://cocoacasts.com/dequeueing-reusable-views-with-generics-and-protocols 10 | // https://github.com/sergdort/CleanArchitectureRxSwift 11 | // 12 | 13 | import UIKit 14 | 15 | protocol Reusable { 16 | static var reuseID: String {get} 17 | } 18 | 19 | extension Reusable { 20 | static var reuseID: String { 21 | return String(describing: self) 22 | } 23 | } 24 | 25 | // MARK: - View Controller 26 | 27 | extension UIViewController: Reusable { 28 | class func instance() -> Self { 29 | let storyboard = UIStoryboard(name: reuseID, bundle: nil) 30 | return storyboard.instantiateViewController() 31 | } 32 | } 33 | 34 | extension UIStoryboard { 35 | func instantiateViewController() -> T { 36 | guard let viewController = self.instantiateViewController(withIdentifier: T.reuseID) as? T else { 37 | fatalError("Unable to instantiate view controller: \(T.self)") 38 | } 39 | return viewController 40 | } 41 | } 42 | 43 | // MARK: - Collection View 44 | 45 | extension UICollectionReusableView: Reusable {} 46 | 47 | extension UICollectionView { 48 | 49 | func dequeueReusableCell(for indexPath: IndexPath) -> T { 50 | guard let cell = dequeueReusableCell(withReuseIdentifier: T.reuseID, for: indexPath) as? T else { 51 | fatalError("Unable to dequeue reusable collection view cell: \(T.self)") 52 | } 53 | return cell 54 | } 55 | 56 | func dequeueReusableSupplementaryView(ofKind kind: String, for indexPath: IndexPath) -> T { 57 | guard let section = dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: T.reuseID, for: indexPath) as? T else { 58 | fatalError("Unable to dequeue reusable supplementary view: \(T.self)") 59 | } 60 | return section 61 | } 62 | 63 | } 64 | 65 | // MARK: - Table View 66 | 67 | extension UITableViewCell: Reusable {} 68 | 69 | extension UITableView { 70 | func dequeueReusableCell(for indexPath: IndexPath) -> T { 71 | guard let cell = dequeueReusableCell(withIdentifier: T.reuseID, for: indexPath) as? T else { 72 | fatalError("Unable to dequeue reusable table view cell: \(T.self)") 73 | } 74 | return cell 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Protocols/ViewModelAttaching.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewModelAttaching.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/27/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | 12 | protocol ViewModelAttaching: AnyObject { 13 | associatedtype ViewModel: ViewModelType 14 | 15 | var bindings: ViewModel.Bindings { get } 16 | var viewModel: Attachable! { get set } 17 | 18 | func attach(wrapper: Attachable) -> ViewModel 19 | func bind(viewModel: ViewModel) -> ViewModel 20 | } 21 | 22 | extension ViewModelAttaching where Self: UIViewController { 23 | 24 | @discardableResult 25 | func attach(wrapper: Attachable) -> ViewModel { 26 | viewModel = wrapper 27 | loadViewIfNeeded() 28 | let vm = viewModel.bind(bindings) 29 | return bind(viewModel: vm) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Protocols/ViewModelType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewModelType.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/27/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | // Adopted from Thomas Visser: 9 | // [Reactive MVVM](http://www.thomasvisser.me/2017/02/09/mvvm-rx/) 10 | // 11 | 12 | import Foundation 13 | 14 | protocol ViewModelType { 15 | associatedtype Dependency 16 | associatedtype Bindings 17 | 18 | init(dependency: Dependency, bindings: Bindings) 19 | } 20 | 21 | enum Attachable { 22 | 23 | case detached(VM.Dependency) 24 | case attached(VM.Dependency, VM) 25 | 26 | mutating func bind(_ bindings: VM.Bindings) -> VM { 27 | switch self { 28 | case let .detached(dependency): 29 | let vm = VM(dependency: dependency, bindings: bindings) 30 | self = .attached(dependency, vm) 31 | return vm 32 | case let .attached(dependency, _): 33 | let vm = VM(dependency: dependency, bindings: bindings) 34 | self = .attached(dependency, vm) 35 | return vm 36 | } 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AlbumsTabIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "albums.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template", 14 | "preserves-vector-representation" : true 15 | } 16 | } -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AlbumsTabIcon.imageset/albums.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AlbumsTabIcon.imageset/albums.pdf -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-20@2x-1.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-29@2x-1.png", 19 | "scale" : "2x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-29@3x.png", 25 | "scale" : "3x" 26 | }, 27 | { 28 | "size" : "40x40", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-40@2x-1.png", 31 | "scale" : "2x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-40@3x.png", 37 | "scale" : "3x" 38 | }, 39 | { 40 | "size" : "60x60", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-60@2x.png", 43 | "scale" : "2x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-60@3x.png", 49 | "scale" : "3x" 50 | }, 51 | { 52 | "size" : "20x20", 53 | "idiom" : "ipad", 54 | "filename" : "Icon-20.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-20@2x.png", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "size" : "29x29", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-29.png", 67 | "scale" : "1x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-29@2x.png", 73 | "scale" : "2x" 74 | }, 75 | { 76 | "size" : "40x40", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-40.png", 79 | "scale" : "1x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-40@2x.png", 85 | "scale" : "2x" 86 | }, 87 | { 88 | "size" : "76x76", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-76.png", 91 | "scale" : "1x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-76@2x.png", 97 | "scale" : "2x" 98 | }, 99 | { 100 | "size" : "83.5x83.5", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-83.5@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "1024x1024", 107 | "idiom" : "ios-marketing", 108 | "filename" : "Icon-512@2x.png", 109 | "scale" : "1x" 110 | } 111 | ], 112 | "info" : { 113 | "version" : 1, 114 | "author" : "xcode" 115 | } 116 | } -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@2x-1.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@2x-1.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@2x-1.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-512@2x.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AquaHaze.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "0.940", 13 | "alpha" : "1.000", 14 | "blue" : "0.960", 15 | "green" : "0.950" 16 | } 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/AthensGray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "0.940", 13 | "alpha" : "1.000", 14 | "blue" : "0.960", 15 | "green" : "0.940" 16 | } 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/EmptyViewBackground.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "cloud.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template", 14 | "preserves-vector-representation" : true 15 | } 16 | } -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/EmptyViewBackground.imageset/cloud.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/EmptyViewBackground.imageset/cloud.pdf -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/PostsTabIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "today_apps.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template", 14 | "preserves-vector-representation" : true 15 | } 16 | } -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/PostsTabIcon.imageset/today_apps.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/PostsTabIcon.imageset/today_apps.pdf -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/ProfileTabIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "user_male.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template", 14 | "preserves-vector-representation" : true 15 | } 16 | } -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/ProfileTabIcon.imageset/user_male.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/ProfileTabIcon.imageset/user_male.pdf -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/Settings.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "settings.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template", 14 | "preserves-vector-representation" : true 15 | } 16 | } -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/Settings.imageset/settings.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/Settings.imageset/settings.pdf -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/StarDust.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "0.630", 13 | "alpha" : "1.000", 14 | "blue" : "0.630", 15 | "green" : "0.630" 16 | } 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/TodosTabIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "list.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template", 14 | "preserves-vector-representation" : true 15 | } 16 | } -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Resources/Assets.xcassets/TodosTabIcon.imageset/list.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgacy/MVVMC-SplitViewController/001b166518229db0c3a05f7f6964d78845ae78bf/MVVMC-SplitViewController/Resources/Assets.xcassets/TodosTabIcon.imageset/list.pdf -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Albums/AlbumList/AlbumListViewController.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Albums/AlbumList/AlbumListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumListViewController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/9/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | final class AlbumListViewController: TableViewController, ViewModelAttaching { 14 | 15 | var viewModel: Attachable! 16 | var bindings: AlbumListViewModel.Bindings { 17 | let viewWillAppear = rx.sentMessage(#selector(UIViewController.viewWillAppear(_:))) 18 | .mapToVoid() 19 | .asDriverOnErrorJustComplete() 20 | let refresh = tableView.refreshControl!.rx 21 | .controlEvent(.valueChanged) 22 | .asDriver() 23 | 24 | return AlbumListViewModel.Bindings( 25 | fetchTrigger: Driver.merge(viewWillAppear, refresh), 26 | selection: tableView.rx.itemSelected.asDriver() 27 | ) 28 | } 29 | 30 | // MARK: - Lifecycle 31 | 32 | override func viewDidLoad() { 33 | super.viewDidLoad() 34 | setupView() 35 | } 36 | 37 | // MARK: - View Methods 38 | 39 | private func setupView() { 40 | title = "Albums" 41 | } 42 | 43 | func bind(viewModel: AlbumListViewModel) -> AlbumListViewModel { 44 | viewModel.albums 45 | .drive(tableView.rx.items(cellIdentifier: "Cell")) { _, element, cell in 46 | cell.textLabel?.text = element.title 47 | } 48 | .disposed(by: disposeBag) 49 | 50 | viewModel.fetching 51 | .drive(tableView.refreshControl!.rx.isRefreshing) 52 | .disposed(by: disposeBag) 53 | 54 | viewModel.errors 55 | .delay(.milliseconds(100)) 56 | .map { $0.localizedDescription } 57 | .drive(errorAlert) 58 | .disposed(by: disposeBag) 59 | 60 | return viewModel 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Albums/AlbumList/AlbumListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumListViewModel.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/9/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | 12 | final class AlbumListViewModel: ViewModelType { 13 | 14 | let fetching: Driver 15 | let albums: Driver<[Album]> 16 | let selectedAlbum: Driver 17 | let errors: Driver 18 | 19 | // MARK: - Lifecycle 20 | 21 | init(dependency: Dependency, bindings: Bindings) { 22 | let activityIndicator = ActivityIndicator() 23 | let errorTracker = ErrorTracker() 24 | 25 | albums = bindings.fetchTrigger 26 | .flatMapLatest { 27 | return dependency.albumService.getAlbums() 28 | .trackActivity(activityIndicator) 29 | .trackError(errorTracker) 30 | .asDriverOnErrorJustComplete() 31 | } 32 | 33 | fetching = activityIndicator.asDriver() 34 | errors = errorTracker.asDriver() 35 | selectedAlbum = bindings.selection 36 | .withLatestFrom(self.albums) { (indexPath, albums: [Album]) -> Album in 37 | return albums[indexPath.row] 38 | } 39 | } 40 | 41 | // MARK: - ViewModelType 42 | 43 | typealias Dependency = HasAlbumService 44 | 45 | struct Bindings { 46 | let fetchTrigger: Driver 47 | let selection: Driver 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Albums/AlbumsCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumsCoordinator.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/9/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | 11 | class AlbumsCoordinator: BaseCoordinator { 12 | typealias Dependencies = HasAlbumService 13 | 14 | private let navigationController: UINavigationController 15 | private let dependencies: Dependencies 16 | 17 | init(navigationController: UINavigationController, dependencies: Dependencies) { 18 | self.navigationController = navigationController 19 | self.dependencies = dependencies 20 | } 21 | 22 | override func start() -> Observable { 23 | let viewController = AlbumListViewController.instance() 24 | navigationController.viewControllers = [viewController] 25 | 26 | let avm: Attachable = .detached(dependencies) 27 | let viewModel = viewController.attach(wrapper: avm) 28 | 29 | viewModel.selectedAlbum 30 | .drive(onNext: { [weak self] selection in 31 | self?.showDetailView(with: selection) 32 | }) 33 | .disposed(by: viewController.disposeBag) 34 | 35 | // View will never be dismissed 36 | return Observable.never() 37 | } 38 | 39 | private func showDetailView(with album: Album) { 40 | let viewController = PhotoCollectionViewController.instance() 41 | let avm: Attachable = .detached(PhotoCollectionViewModel.Dependency( 42 | albumService: dependencies.albumService, album: album)) 43 | let viewModel = viewController.attach(wrapper: avm) 44 | 45 | navigationController.pushViewController(viewController, animated: true) 46 | 47 | viewModel.selectedPhoto 48 | .drive(onNext: { [weak self] photoCellViewModel in 49 | self?.showDetail(with: photoCellViewModel.photo) 50 | }) 51 | .disposed(by: viewController.disposeBag) 52 | } 53 | 54 | private func showDetail(with photo: Photo) { 55 | let viewController = PhotoDetailViewController.instance() 56 | viewController.viewModel = PhotoDetailViewModel(albumService: dependencies.albumService, photo: photo) 57 | navigationController.showDetailViewController(viewController, sender: nil) 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Albums/PhotoCollection/PhotoCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoCell.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/11/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | 12 | class PhotoCell: UICollectionViewCell { 13 | @IBOutlet weak var imageView: UIImageView! 14 | @IBOutlet weak var titleLabel: UILabel! 15 | 16 | private(set) var disposeBag = DisposeBag() 17 | 18 | override func awakeFromNib() { 19 | super.awakeFromNib() 20 | imageView.layer.cornerRadius = 5 21 | imageView.layer.masksToBounds = true 22 | } 23 | 24 | override func prepareForReuse() { 25 | super.prepareForReuse() 26 | disposeBag = DisposeBag() // because life cicle of every cell ends on prepare for reuse 27 | } 28 | 29 | func bind(to viewModel: PhotoCellViewModel) { 30 | viewModel.title 31 | .drive(titleLabel.rx.text) 32 | .disposed(by: disposeBag) 33 | 34 | viewModel.thumbnail 35 | .drive(imageView.rx.image) 36 | .disposed(by: disposeBag) 37 | } 38 | 39 | } 40 | 41 | // MARK: - SectionView 42 | 43 | class PhotoSectionView: UICollectionReusableView { 44 | @IBOutlet weak var titleLabel: UILabel? 45 | } 46 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Albums/PhotoCollection/PhotoCellViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoCellViewModel.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/11/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Differentiator 10 | import RxCocoa 11 | import RxSwift 12 | 13 | final class PhotoCellViewModel { 14 | 15 | let title: Driver 16 | let thumbnail: Driver 17 | let photo: Photo 18 | 19 | init(albumService: AlbumServiceType, photo: Photo) { 20 | self.photo = photo 21 | self.title = Driver.just(photo.title) 22 | self.thumbnail = albumService.getThumbnail(for: photo) 23 | .asDriver(onErrorDriveWith: .empty()) 24 | } 25 | 26 | } 27 | 28 | // MARK: - RxDataSources - AnimatableSectionModelType 29 | 30 | extension PhotoCellViewModel: IdentifiableType { 31 | typealias Identity = Int 32 | 33 | var identity: Int { 34 | return photo.id 35 | } 36 | } 37 | 38 | extension PhotoCellViewModel: Equatable { 39 | 40 | static func == (lhs: PhotoCellViewModel, rhs: PhotoCellViewModel) -> Bool { 41 | return lhs.photo.id == rhs.photo.id 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Albums/PhotoCollection/PhotoCollectionViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoCollectionViewController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/9/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxDataSources 12 | import RxSwift 13 | 14 | class PhotoCollectionViewController: UIViewController, ViewModelAttaching { 15 | 16 | let disposeBag = DisposeBag() 17 | 18 | var viewModel: Attachable! 19 | var bindings: PhotoCollectionViewModel.Bindings { 20 | return PhotoCollectionViewModel.Bindings( 21 | selection: collectionView.rx.itemSelected.asDriver() 22 | ) 23 | } 24 | 25 | //fileprivate let sectionInsets = UIEdgeInsets(top: 50.0, left: 20.0, bottom: 50.0, right: 20.0) 26 | 27 | @IBOutlet weak var activityIndicator: UIActivityIndicatorView! 28 | @IBOutlet weak var collectionView: UICollectionView! 29 | 30 | // MARK: - Lifecycle 31 | 32 | override func viewDidLoad() { 33 | super.viewDidLoad() 34 | setupView() 35 | } 36 | 37 | // MARK: - View Methods 38 | 39 | private func setupView() { 40 | title = "Photos" 41 | } 42 | 43 | func bind(viewModel: PhotoCollectionViewModel) -> PhotoCollectionViewModel { 44 | let (configureCollectionViewCell, configureSupplementaryView) = PhotoCollectionViewController.collectionViewDataSourceUI() 45 | let cvDataSource = RxCollectionViewSectionedAnimatedDataSource( 46 | configureCell: configureCollectionViewCell, 47 | configureSupplementaryView: configureSupplementaryView 48 | ) 49 | 50 | viewModel.photos 51 | .asObservable() 52 | .bind(to: collectionView.rx.items(dataSource: cvDataSource)) 53 | .disposed(by: disposeBag) 54 | 55 | viewModel.fetching 56 | .drive(activityIndicator.rx.isAnimating) 57 | .disposed(by: disposeBag) 58 | 59 | viewModel.fetching 60 | .drive(collectionView.rx.isHidden) 61 | .disposed(by: disposeBag) 62 | 63 | return viewModel 64 | } 65 | 66 | } 67 | 68 | extension PhotoCollectionViewController { 69 | 70 | static func collectionViewDataSourceUI() -> (CollectionViewSectionedDataSource.ConfigureCell, CollectionViewSectionedDataSource.ConfigureSupplementaryView) { 71 | return ( 72 | // swiftlint:disable:next identifier_name 73 | { (_, cv, ip, i) in 74 | let cell: PhotoCell = cv.dequeueReusableCell(for: ip) 75 | cell.bind(to: i) 76 | return cell 77 | }, 78 | // swiftlint:disable:next identifier_name 79 | { (ds, cv, kind, ip) in 80 | let section: PhotoSectionView = cv.dequeueReusableSupplementaryView(ofKind: kind, for: ip) 81 | section.titleLabel!.text = "\(ds[ip.section].header)" 82 | return section 83 | } 84 | ) 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Albums/PhotoCollection/PhotoCollectionViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoCollectionViewModel.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/9/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Differentiator 10 | import RxDataSources 11 | import RxCocoa 12 | import RxSwift 13 | 14 | final class PhotoCollectionViewModel: ViewModelType { 15 | 16 | let fetching: Driver 17 | //let albumTitle: Driver 18 | let photos: Driver<[PhotoSection]> 19 | let selectedPhoto: Driver 20 | let errors: Driver 21 | 22 | // MARK: - Lifecycle 23 | 24 | init(dependency: Dependency, bindings: Bindings) { 25 | let activityIndicator = ActivityIndicator() 26 | let errorTracker = ErrorTracker() 27 | 28 | photos = dependency.albumService.getPhotos() 29 | .trackActivity(activityIndicator) 30 | .map { photo in 31 | return photo.map { 32 | return PhotoCellViewModel.init(albumService: dependency.albumService, photo: $0) 33 | } 34 | } 35 | .map { 36 | return [PhotoSection(header: dependency.album.title, photos: $0)] 37 | } 38 | .trackError(errorTracker) 39 | .asDriverOnErrorJustComplete() 40 | 41 | fetching = activityIndicator.asDriver() 42 | errors = errorTracker.asDriver() 43 | 44 | selectedPhoto = bindings.selection 45 | .withLatestFrom(self.photos) { (indexPath, sections: [PhotoSection]) -> PhotoCellViewModel in 46 | return sections[indexPath.section].items[indexPath.row] 47 | } 48 | } 49 | 50 | // MARK: - ViewModelType 51 | 52 | struct Dependency { 53 | let albumService: AlbumServiceType 54 | let album: Album 55 | } 56 | 57 | struct Bindings { 58 | let selection: Driver 59 | } 60 | 61 | } 62 | 63 | // MARK: - DataSource 64 | 65 | struct PhotoSection { 66 | var header: String 67 | var photos: [PhotoCellViewModel] 68 | 69 | init(header: String, photos: [Item]) { 70 | self.header = header 71 | self.photos = photos 72 | } 73 | 74 | } 75 | 76 | extension PhotoSection: AnimatableSectionModelType { 77 | typealias Item = PhotoCellViewModel 78 | typealias Identity = String 79 | 80 | var identity: String { 81 | return header 82 | } 83 | 84 | var items: [PhotoCellViewModel] { 85 | return photos 86 | } 87 | 88 | init(original: PhotoSection, items: [PhotoCellViewModel]) { 89 | self = original 90 | self.photos = items 91 | } 92 | 93 | } 94 | 95 | extension PhotoSection: Equatable { 96 | 97 | static func == (lhs: PhotoSection, rhs: PhotoSection) -> Bool { 98 | return lhs.header == rhs.header && lhs.items == rhs.items 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Albums/PhotoDetail/PhotoDetailViewController.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Albums/PhotoDetail/PhotoDetailViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoDetailViewController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/11/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | final class PhotoDetailViewController: UIViewController { 14 | 15 | let disposeBag = DisposeBag() 16 | var viewModel: PhotoDetailViewModel! 17 | 18 | @IBOutlet weak var activityIndicator: UIActivityIndicatorView! 19 | @IBOutlet weak var imageView: UIImageView! 20 | 21 | // MARK: - Lifecycle 22 | 23 | override func viewDidLoad() { 24 | super.viewDidLoad() 25 | setupView() 26 | } 27 | 28 | // MARK: - View Methods 29 | 30 | private func setupView() { 31 | bindViewModel() 32 | } 33 | 34 | private func bindViewModel() { 35 | viewModel.title 36 | .drive(self.rx.title) 37 | .disposed(by: disposeBag) 38 | 39 | viewModel.image 40 | .drive(imageView.rx.image) 41 | .disposed(by: disposeBag) 42 | 43 | viewModel.fetching 44 | .drive(activityIndicator.rx.isAnimating) 45 | .disposed(by: disposeBag) 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Albums/PhotoDetail/PhotoDetailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoDetailViewModel.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 2/22/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | 12 | final class PhotoDetailViewModel { 13 | 14 | let fetching: Driver 15 | let errors: Driver 16 | let title: Driver 17 | let image: Driver 18 | 19 | private let photo: Photo 20 | 21 | init(albumService: AlbumServiceType, photo: Photo) { 22 | self.photo = photo 23 | 24 | let activityIndicator = ActivityIndicator() 25 | let errorTracker = ErrorTracker() 26 | 27 | self.title = Driver.just(photo.title) 28 | 29 | self.image = albumService.getImage(for: photo) 30 | .trackActivity(activityIndicator) 31 | .trackError(errorTracker) 32 | .asDriverOnErrorJustComplete() 33 | 34 | fetching = activityIndicator.asDriver() 35 | errors = errorTracker.asDriver() 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Common/ActivityIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityIndicator.swift 3 | // RxExample 4 | // 5 | // Created by Krunoslav Zaher on 10/18/15. 6 | // Copyright © 2015 Krunoslav Zaher. All rights reserved. 7 | // 8 | // From: 9 | // https://github.com/ReactiveX/RxSwift 10 | // 11 | 12 | import RxSwift 13 | import RxCocoa 14 | 15 | // swiftlint:disable identifier_name 16 | // swiftlint:disable multiple_closures_with_trailing_closure 17 | // swiftlint:disable type_name 18 | 19 | private struct ActivityToken : ObservableConvertibleType, Disposable { 20 | private let _source: Observable 21 | private let _dispose: Cancelable 22 | 23 | init(source: Observable, disposeAction: @escaping () -> Void) { 24 | _source = source 25 | _dispose = Disposables.create(with: disposeAction) 26 | } 27 | 28 | func dispose() { 29 | _dispose.dispose() 30 | } 31 | 32 | func asObservable() -> Observable { 33 | return _source 34 | } 35 | } 36 | 37 | /** 38 | Enables monitoring of sequence computation. 39 | If there is at least one sequence computation in progress, `true` will be sent. 40 | When all activities complete `false` will be sent. 41 | */ 42 | public class ActivityIndicator : SharedSequenceConvertibleType { 43 | public typealias Element = Bool 44 | public typealias SharingStrategy = DriverSharingStrategy 45 | 46 | private let _lock = NSRecursiveLock() 47 | private let _relay = BehaviorRelay(value: 0) 48 | private let _loading: SharedSequence 49 | 50 | public init() { 51 | _loading = _relay.asDriver() 52 | .map { $0 > 0 } 53 | .distinctUntilChanged() 54 | } 55 | 56 | fileprivate func trackActivityOfObservable(_ source: Source) -> Observable { 57 | return Observable.using({ () -> ActivityToken in 58 | self.increment() 59 | return ActivityToken(source: source.asObservable(), disposeAction: self.decrement) 60 | }) { t in 61 | return t.asObservable() 62 | } 63 | } 64 | 65 | private func increment() { 66 | _lock.lock() 67 | _relay.accept(_relay.value + 1) 68 | _lock.unlock() 69 | } 70 | 71 | private func decrement() { 72 | _lock.lock() 73 | _relay.accept(_relay.value - 1) 74 | _lock.unlock() 75 | } 76 | 77 | public func asSharedSequence() -> SharedSequence { 78 | return _loading 79 | } 80 | } 81 | 82 | extension ObservableConvertibleType { 83 | public func trackActivity(_ activityIndicator: ActivityIndicator) -> Observable { 84 | return activityIndicator.trackActivityOfObservable(self) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Common/BaseCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseCoordinator.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/28/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | // From: 9 | // https://github.com/uptechteam/Coordinator-MVVM-Rx-Example 10 | // Arthur Myronenko 11 | // Copyright © 2017 UPTech Team. 12 | // 13 | 14 | import RxSwift 15 | 16 | /// Base abstract coordinator generic over the return type of the `start` method. 17 | class BaseCoordinator: CoordinatorType { 18 | 19 | /// Typealias which allows to access a ResultType of the Coordainator by `CoordinatorName.CoordinationResult`. 20 | typealias CoordinationResult = ResultType 21 | 22 | /// Utility `DisposeBag` used by the subclasses. 23 | let disposeBag = DisposeBag() 24 | 25 | /// Unique identifier. 26 | internal let identifier = UUID() 27 | 28 | /// Dictionary of the child coordinators. Every child coordinator should be added 29 | /// to that dictionary in order to keep it in memory. 30 | /// Key is an `identifier` of the child coordinator and value is the coordinator itself. 31 | /// Value type is `Any` because Swift doesn't allow to store generic types in the array. 32 | private var childCoordinators = [UUID: Any]() 33 | 34 | /// Stores coordinator to the `childCoordinators` dictionary. 35 | /// 36 | /// - Parameter coordinator: Child coordinator to store. 37 | private func store(coordinator: T) { 38 | childCoordinators[coordinator.identifier] = coordinator 39 | } 40 | 41 | /// Release coordinator from the `childCoordinators` dictionary. 42 | /// 43 | /// - Parameter coordinator: Coordinator to release. 44 | private func free(coordinator: T) { 45 | childCoordinators[coordinator.identifier] = nil 46 | } 47 | 48 | /// 1. Stores coordinator in a dictionary of child coordinators. 49 | /// 2. Calls method `start()` on that coordinator. 50 | /// 3. On the `onNext:` of returning observable of method `start()` removes coordinator from the dictionary. 51 | /// 52 | /// - Parameter coordinator: Coordinator to start. 53 | /// - Returns: Result of `start()` method. 54 | func coordinate(to coordinator: T) -> Observable where U == T.CoordinationResult { 55 | store(coordinator: coordinator) 56 | return coordinator.start() 57 | .do(onNext: { [weak self] _ in self?.free(coordinator: coordinator) }) 58 | } 59 | 60 | /// Starts job of the coordinator. 61 | /// 62 | /// - Returns: Result of coordinator job. 63 | func start() -> Observable { 64 | fatalError("Start method should be implemented.") 65 | } 66 | } 67 | 68 | // MARK: - CustomStringConvertible 69 | extension BaseCoordinator: CustomStringConvertible { 70 | var description: String { 71 | return "\(type(of: self))" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Common/DetailNavigationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailNavigationController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/29/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class DetailNavigationController: UINavigationController { 12 | 13 | init() { 14 | super.init(nibName: nil, bundle: nil) 15 | delegate = self 16 | } 17 | 18 | required init?(coder aDecoder: NSCoder) { 19 | fatalError("init(coder:) has not been implemented") 20 | } 21 | 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | navigationBar.isTranslucent = false 25 | } 26 | 27 | } 28 | 29 | // MARK: - UINavigationControllerDelegate 30 | extension DetailNavigationController: UINavigationControllerDelegate { 31 | 32 | public func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { 33 | guard operation == .push, toVC is PlaceholderViewController else { 34 | return nil 35 | } 36 | 37 | return DetailNavigationControllerAnimator(operation: operation) 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Common/DetailNavigationControllerAnimator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailNavigationControllerAnimator.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 2/22/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class DetailNavigationControllerAnimator: NSObject, UIViewControllerAnimatedTransitioning { 12 | let operation: UINavigationController.Operation 13 | 14 | init(operation: UINavigationController.Operation) { 15 | self.operation = operation 16 | super.init() 17 | } 18 | 19 | func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { 20 | return 0.35 21 | } 22 | 23 | public func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { 24 | guard 25 | let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from), 26 | let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) else { 27 | return 28 | } 29 | 30 | if operation == .push { 31 | switch toVC is PlaceholderViewControllerType { 32 | case true: 33 | animatePushAsPop(from: fromVC, to: toVC, using: transitionContext) 34 | case false: 35 | animatePush(from: fromVC, to: toVC, using: transitionContext) 36 | } 37 | } else if operation == .pop { 38 | animatePop(from: fromVC, to: toVC, using: transitionContext) 39 | } 40 | } 41 | 42 | // MARK: - Push / Pop 43 | 44 | private func animatePush(from fromVC: UIViewController, to toVC: UIViewController, using transitionContext: UIViewControllerContextTransitioning) { 45 | let containerView = transitionContext.containerView 46 | let finalFrame = transitionContext.finalFrame(for: toVC) 47 | 48 | let dx = containerView.frame.size.width 49 | toVC.view.frame = finalFrame.offsetBy(dx: dx, dy: 0.0) 50 | containerView.insertSubview(toVC.view, aboveSubview: fromVC.view) 51 | 52 | UIView.animate( 53 | withDuration: transitionDuration(using: transitionContext), delay: 0, 54 | options: [ UIView.AnimationOptions.curveEaseOut ], 55 | animations: { 56 | toVC.view.frame = transitionContext.finalFrame(for: toVC) 57 | fromVC.view.frame = finalFrame.offsetBy(dx: dx / -2.5, dy: 0.0) 58 | }, 59 | completion: { (finished) in transitionContext.completeTransition(true) } 60 | ) 61 | } 62 | 63 | private func animatePushAsPop(from fromVC: UIViewController, to toVC: UIViewController, using transitionContext: UIViewControllerContextTransitioning) { 64 | let containerView = transitionContext.containerView 65 | let finalFrame = transitionContext.finalFrame(for: toVC) 66 | 67 | let dx = containerView.frame.size.width 68 | toVC.view.frame = finalFrame.offsetBy(dx: dx / -2.5, dy: 0.0) 69 | containerView.insertSubview(toVC.view, belowSubview: fromVC.view) 70 | 71 | UIView.animate( 72 | withDuration: transitionDuration(using: transitionContext), delay: 0, 73 | options: [ UIView.AnimationOptions.curveEaseOut ], 74 | animations: { 75 | toVC.view.frame = transitionContext.finalFrame(for: toVC) 76 | fromVC.view.frame = finalFrame.offsetBy(dx: dx, dy: 0.0) 77 | }, 78 | completion: { (finished) in transitionContext.completeTransition(true) } 79 | ) 80 | } 81 | 82 | private func animatePop(from fromVC: UIViewController, to toVC: UIViewController, using transitionContext: UIViewControllerContextTransitioning) { 83 | let containerView = transitionContext.containerView 84 | containerView.addSubview(toVC.view) 85 | 86 | UIView.animate( 87 | withDuration: transitionDuration(using: transitionContext), delay: 0, 88 | options: [ UIView.AnimationOptions.curveEaseOut ], 89 | animations: { 90 | fromVC.view.frame = containerView.bounds.offsetBy(dx: containerView.frame.width, dy: 0) 91 | toVC.view.frame = containerView.bounds 92 | }, 93 | completion: { (finished) in transitionContext.completeTransition(true) } 94 | ) 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Common/ErrorTracker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorTracker.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/27/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | // From: 9 | // https://github.com/sergdort/CleanArchitectureRxSwift 10 | // Copyright © 2017 Sergey Shulga. All rights reserved. 11 | // 12 | 13 | import RxSwift 14 | import RxCocoa 15 | 16 | final class ErrorTracker: SharedSequenceConvertibleType { 17 | typealias SharingStrategy = DriverSharingStrategy 18 | private let _subject = PublishSubject() 19 | 20 | func trackError(from source: O) -> Observable { 21 | return source.asObservable().do(onError: onError) 22 | } 23 | 24 | func asSharedSequence() -> SharedSequence { 25 | return _subject.asObservable().asDriverOnErrorJustComplete() 26 | } 27 | 28 | func asObservable() -> Observable { 29 | return _subject.asObservable() 30 | } 31 | 32 | private func onError(_ error: Error) { 33 | _subject.onNext(error) 34 | } 35 | 36 | deinit { 37 | _subject.onCompleted() 38 | } 39 | } 40 | 41 | extension ObservableConvertibleType { 42 | func trackError(_ errorTracker: ErrorTracker) -> Observable { 43 | return errorTracker.trackError(from: self) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Common/NavigationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/8/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class NavigationController: UINavigationController, PrimaryContainerType { 12 | 13 | let detailPopCompletion: (UIViewController & PlaceholderViewControllerType) -> Void 14 | var detailView: DetailView = .placeholder 15 | 16 | // MARK: - Lifecycle 17 | 18 | init(withPopDetailCompletion completion: @escaping (UIViewController & PlaceholderViewControllerType) -> Void) { 19 | self.detailPopCompletion = completion 20 | super.init(nibName: nil, bundle: nil) 21 | } 22 | 23 | required init?(coder aDecoder: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | override func viewDidLoad() { 28 | super.viewDidLoad() 29 | navigationBar.isTranslucent = false 30 | } 31 | 32 | override func popViewController(animated: Bool) -> UIViewController? { 33 | switch detailView { 34 | case .collapsed: 35 | detailView = .placeholder 36 | case .separated: 37 | detailView = .placeholder 38 | /// Set detail view controller to `PlaceholderViewControllerType` to prevent confusion 39 | detailPopCompletion(makePlaceholderViewController()) 40 | case .placeholder: 41 | break 42 | } 43 | return super.popViewController(animated: animated) 44 | } 45 | 46 | func makePlaceholderViewController() -> UIViewController & PlaceholderViewControllerType { 47 | return PlaceholderViewController() 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Common/PlaceholderViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaceholderViewController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/8/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import ColorCompatibility 11 | 12 | class PlaceholderViewController: UIViewController, PlaceholderViewControllerType { 13 | 14 | let backgroundImageView: UIImageView = { 15 | let view = UIImageView() 16 | view.image = #imageLiteral(resourceName: "EmptyViewBackground") 17 | view.tintColor = ColorCompatibility.systemGray2 18 | view.translatesAutoresizingMaskIntoConstraints = false 19 | return view 20 | }() 21 | 22 | // MARK: - Lifecycle 23 | 24 | override func viewDidLoad() { 25 | super.viewDidLoad() 26 | setupView() 27 | setupConstraints() 28 | } 29 | 30 | override func viewWillAppear(_ animated: Bool) { 31 | if let displayModeButtonItem = splitViewController?.displayModeButtonItem { 32 | navigationItem.leftBarButtonItem = displayModeButtonItem 33 | } 34 | } 35 | 36 | // MARK: - View Methods 37 | 38 | func setupView() { 39 | view.backgroundColor = ColorCompatibility.systemBackground 40 | self.view.addSubview(backgroundImageView) 41 | navigationItem.leftItemsSupplementBackButton = true 42 | } 43 | 44 | func setupConstraints() { 45 | NSLayoutConstraint.activate([ 46 | backgroundImageView.widthAnchor.constraint(equalToConstant: 180.0), 47 | backgroundImageView.heightAnchor.constraint(equalToConstant: 180.0), 48 | backgroundImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), 49 | backgroundImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor) 50 | ]) 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Common/SplitViewCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplitViewCoordinator.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/28/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | 11 | class SplitViewCoordinator: BaseCoordinator { 12 | typealias Dependencies = HasClient & HasUserManager & HasAlbumService & HasPostService & HasTodoService 13 | 14 | private let window: UIWindow 15 | private let dependencies: Dependencies 16 | 17 | // swiftlint:disable:next weak_delegate 18 | private let viewDelegate: SplitViewDelegate 19 | 20 | enum SectionTab { 21 | case posts 22 | case albums 23 | case todos 24 | case profile 25 | 26 | var title: String { 27 | switch self { 28 | case .posts: return "Posts" 29 | case .albums: return "Albums" 30 | case .todos: return "Todos" 31 | case .profile: return "Profile" 32 | } 33 | } 34 | 35 | var image: UIImage { 36 | switch self { 37 | case .posts: return #imageLiteral(resourceName: "PostsTabIcon") 38 | case .albums: return #imageLiteral(resourceName: "AlbumsTabIcon") 39 | case .todos: return #imageLiteral(resourceName: "TodosTabIcon") 40 | case .profile: return #imageLiteral(resourceName: "ProfileTabIcon") 41 | } 42 | } 43 | 44 | } 45 | 46 | // MARK: - Lifecycle 47 | 48 | init(window: UIWindow, dependencies: Dependencies) { 49 | self.window = window 50 | self.dependencies = dependencies 51 | 52 | let detailNavigationController = DetailNavigationController() 53 | self.viewDelegate = SplitViewDelegate(detailNavigationController: detailNavigationController) 54 | } 55 | 56 | override func start() -> Observable { 57 | let tabBarController = UITabBarController() 58 | let tabs: [SectionTab] = [.posts, .albums, .todos, .profile] 59 | let coordinationResults = Observable.from(configure(tabBarController: tabBarController, withTabs: tabs)).merge() 60 | 61 | if let initialPrimaryView = tabBarController.selectedViewController as? PrimaryContainerType { 62 | viewDelegate.updateSecondaryWithDetail(from: initialPrimaryView) 63 | } 64 | 65 | let splitViewController = UISplitViewController() 66 | splitViewController.delegate = viewDelegate 67 | splitViewController.viewControllers = [tabBarController, viewDelegate.detailNavigationController] 68 | splitViewController.preferredDisplayMode = .allVisible 69 | 70 | window.rootViewController = splitViewController 71 | window.makeKeyAndVisible() 72 | 73 | return coordinationResults 74 | .take(1) 75 | } 76 | 77 | private func configure(tabBarController: UITabBarController, withTabs tabs: [SectionTab]) -> [Observable] { 78 | let navControllers = tabs 79 | .map { tab -> UINavigationController in 80 | let navController = NavigationController(withPopDetailCompletion: viewDelegate.replaceDetail) 81 | navController.tabBarItem = UITabBarItem(title: tab.title, image: tab.image, selectedImage: nil) 82 | //navController.navigationBar.prefersLargeTitles = true 83 | //navController.navigationItem.largeTitleDisplayMode = .automatic 84 | return navController 85 | } 86 | 87 | tabBarController.viewControllers = navControllers 88 | tabBarController.delegate = viewDelegate 89 | tabBarController.view.backgroundColor = UIColor.white // Fix dark shadow in nav bar on segue 90 | 91 | return zip(tabs, navControllers) 92 | .map { (tab, navCtrl) in 93 | switch tab { 94 | case .posts: 95 | let coordinator = PostsCoordinator(navigationController: navCtrl, dependencies: dependencies) 96 | return coordinate(to: coordinator) 97 | case .albums: 98 | let coordinator = AlbumsCoordinator(navigationController: navCtrl, dependencies: dependencies) 99 | return coordinate(to: coordinator) 100 | case .todos: 101 | let coordinator = TodosCoordinator(navigationController: navCtrl, dependencies: dependencies) 102 | return coordinate(to: coordinator) 103 | case .profile: 104 | let coordinator = ProfileCoordinator(navigationController: navCtrl, dependencies: dependencies) 105 | return coordinate(to: coordinator) 106 | } 107 | } 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Common/SplitViewDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplitViewDelegate.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/8/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | final class SplitViewDelegate: NSObject { 12 | 13 | let detailNavigationController: UINavigationController 14 | 15 | init(detailNavigationController: UINavigationController) { 16 | self.detailNavigationController = detailNavigationController 17 | super.init() 18 | } 19 | 20 | /// Changes the view controller displayed in the detail navigation controller. 21 | /// 22 | /// - Parameters: 23 | /// - primaryContainer: The `PrimaryContainerType` containing the `.detailView` used to update the detail nav controller. 24 | /// - animated: If `true`, animates the update. 25 | func updateSecondaryWithDetail(from primaryContainer: PrimaryContainerType, animated: Bool = false) { 26 | switch primaryContainer.detailView { 27 | case .collapsed(let detailViewController): 28 | detailNavigationController.setViewControllers([detailViewController], animated: animated) 29 | case .separated(let detailViewController): 30 | detailNavigationController.setViewControllers([detailViewController], animated: animated) 31 | case .placeholder: 32 | detailNavigationController.setViewControllers([primaryContainer.makePlaceholderViewController()], 33 | animated: animated) 34 | } 35 | } 36 | 37 | /// Sets view of detail navigation controller to a placeholder view controller. 38 | /// 39 | /// - Parameter viewController: Placeholder view controller to use. 40 | func replaceDetail(withEmpty viewController: UIViewController & PlaceholderViewControllerType) { 41 | detailNavigationController.setViewControllers([viewController], animated: true) 42 | } 43 | 44 | } 45 | 46 | // MARK: - UITabBarControllerDelegate 47 | extension SplitViewDelegate: UITabBarControllerDelegate { 48 | 49 | func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { 50 | /// Prevent selection of the same tab twice (which would reset its navigation controller) 51 | return tabBarController.selectedViewController === viewController ? false : true 52 | } 53 | 54 | func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { 55 | guard 56 | let splitViewController = tabBarController.splitViewController, 57 | let selectedNavController = viewController as? PrimaryContainerType else { 58 | fatalError("\(#function) FAILED : wrong view controller type") 59 | } 60 | /// If split view controller is collapsed, detail view will already be on `selectedNavController.viewControllers`; 61 | /// otherwise, we need to change the secondary view controller to the selected tab's detail view. 62 | if !splitViewController.isCollapsed { 63 | updateSecondaryWithDetail(from: selectedNavController) 64 | } 65 | } 66 | 67 | } 68 | 69 | // MARK: - UISplitViewControllerDelegate 70 | extension SplitViewDelegate: UISplitViewControllerDelegate { 71 | 72 | // MARK: Collapsing the Interface 73 | 74 | func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool { 75 | guard 76 | let tabBarController = splitViewController.viewControllers.first as? UITabBarController, 77 | let navigationControllers = tabBarController.viewControllers as? [PrimaryContainerType] else { 78 | fatalError("\(#function) FAILED : wrong view controller type") 79 | } 80 | 81 | navigationControllers.forEach { $0.collapseDetail() } 82 | return true /// Prevent UIKit from performing default collapse behavior 83 | } 84 | 85 | // MARK: Expanding the Interface 86 | 87 | func splitViewController(_ splitViewController: UISplitViewController, separateSecondaryFrom primaryViewController: UIViewController) -> UIViewController? { 88 | guard 89 | let tabBarController = primaryViewController as? UITabBarController, 90 | let navigationControllers = tabBarController.viewControllers as? [PrimaryContainerType], 91 | let selectedNavController = tabBarController.selectedViewController as? PrimaryContainerType else { 92 | fatalError("\(#function) FAILED : wrong view controller type") 93 | } 94 | 95 | navigationControllers.forEach { $0.separateDetail() } 96 | 97 | /// There is no point in hiding the primary view controller with a placeholder detail view 98 | if case .placeholder = selectedNavController.detailView, splitViewController.preferredDisplayMode == .primaryHidden { 99 | splitViewController.preferredDisplayMode = .allVisible 100 | } 101 | updateSecondaryWithDetail(from: selectedNavController) 102 | return detailNavigationController 103 | } 104 | 105 | // MARK: Overriding the Presentation Behavior 106 | 107 | func splitViewController(_ splitViewController: UISplitViewController, showDetail vc: UIViewController, sender: Any?) -> Bool { 108 | guard 109 | let tabBarController = splitViewController.viewControllers.first as? UITabBarController, 110 | let selectedNavController = tabBarController.selectedViewController as? UINavigationController 111 | & PrimaryContainerType else { 112 | fatalError("\(#function) FAILED : wrong view controller type") 113 | } 114 | 115 | vc.navigationItem.leftItemsSupplementBackButton = true 116 | vc.navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem 117 | 118 | if splitViewController.isCollapsed { 119 | selectedNavController.pushViewController(vc, animated: true) 120 | selectedNavController.detailView = .collapsed(vc) 121 | } else { 122 | switch selectedNavController.detailView { 123 | /// Animate only the initial presentation of the detail vc 124 | case .placeholder: 125 | detailNavigationController.setViewControllers([vc], animated: true) 126 | default: 127 | detailNavigationController.setViewControllers([vc], animated: false) 128 | } 129 | selectedNavController.detailView = .separated(vc) 130 | } 131 | return true /// Prevent UIKit from performing default behavior 132 | } 133 | 134 | } 135 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Common/TabBarController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabBarController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/8/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TabBarController: UITabBarController { 12 | 13 | let detailNavigationController: UINavigationController 14 | 15 | // MARK: - Lifecycle 16 | 17 | init(detailNavigationController: UINavigationController) { 18 | self.detailNavigationController = detailNavigationController 19 | super.init(nibName: nil, bundle: nil) 20 | delegate = self 21 | } 22 | 23 | required init?(coder aDecoder: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | // MARK: - 28 | 29 | func updateSecondaryWithDetail(from primaryContainer: PrimaryContainerType, animated: Bool = false) { 30 | switch primaryContainer.detailView { 31 | case .collapsed(let detailViewController): 32 | detailNavigationController.setViewControllers([detailViewController], animated: animated) 33 | case .separated(let detailViewController): 34 | detailNavigationController.setViewControllers([detailViewController], animated: animated) 35 | case .placeholder: 36 | detailNavigationController.setViewControllers([primaryContainer.makePlaceholderViewController()], 37 | animated: animated) 38 | } 39 | } 40 | 41 | func replaceDetail(withEmpty viewController: UIViewController & PlaceholderViewControllerType) { 42 | detailNavigationController.setViewControllers([viewController], animated: true) 43 | } 44 | 45 | } 46 | 47 | // MARK: - UITabBarControllerDelegate 48 | extension TabBarController: UITabBarControllerDelegate { 49 | 50 | func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { 51 | // Prevent selection of the same tab twice (which would reset its navigation controller) 52 | return selectedViewController === viewController ? false : true 53 | } 54 | 55 | func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { 56 | guard let svc = splitViewController, let selectedNavController = viewController as? PrimaryContainerType else { 57 | fatalError("Wrong view controller type: \(viewController)") 58 | } 59 | // If split view controller is collapsed, detail view will already be on `selectedNavController.viewControllers`; 60 | // otherwise, we need to change the secondary view controller to the selected tab's detail view. 61 | if !svc.isCollapsed { 62 | updateSecondaryWithDetail(from: selectedNavController) 63 | } 64 | } 65 | 66 | } 67 | 68 | // MARK: - UISplitViewControllerDelegate 69 | extension TabBarController: UISplitViewControllerDelegate { 70 | 71 | // MARK: Collapsing the Interface 72 | 73 | func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool { 74 | guard let navigationControllers = viewControllers as? [PrimaryContainerType] else { 75 | fatalError("Wrong view controller type: \(String(describing: viewControllers?.filter { !($0 is PrimaryContainerType) }))") 76 | } 77 | 78 | navigationControllers.forEach { $0.collapseDetail() } 79 | return true // Prevent UIKit from performing default collapse behavior 80 | } 81 | 82 | // MARK: Expanding the Interface 83 | 84 | func splitViewController(_ splitViewController: UISplitViewController, separateSecondaryFrom primaryViewController: UIViewController) -> UIViewController? { 85 | guard 86 | let navigationControllers = viewControllers as? [PrimaryContainerType], 87 | let selectedNavController = selectedViewController as? PrimaryContainerType else { 88 | fatalError("Wrong view controller type: \(String(describing: viewControllers?.filter { !($0 is PrimaryContainerType) }))") 89 | } 90 | 91 | navigationControllers.forEach { $0.separateDetail() } 92 | 93 | // There is no point in hiding the primary view controller with a placeholder detail view 94 | if case .placeholder = selectedNavController.detailView, splitViewController.preferredDisplayMode == .primaryHidden { 95 | splitViewController.preferredDisplayMode = .allVisible 96 | } 97 | updateSecondaryWithDetail(from: selectedNavController) 98 | return detailNavigationController 99 | } 100 | 101 | // MARK: Overriding the Presentation Behavior 102 | 103 | func splitViewController(_ splitViewController: UISplitViewController, showDetail vc: UIViewController, sender: Any?) -> Bool { 104 | guard let selectedNavController = selectedViewController as? UINavigationController & PrimaryContainerType else { 105 | fatalError("\(#function) FAILED : wrong view controller type") 106 | } 107 | 108 | vc.navigationItem.leftItemsSupplementBackButton = true 109 | vc.navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem 110 | 111 | if splitViewController.isCollapsed { 112 | selectedNavController.pushViewController(vc, animated: true) 113 | selectedNavController.detailView = .collapsed(vc) 114 | } else { 115 | switch selectedNavController.detailView { 116 | // Animate only the initial presentation of the detail vc 117 | case .placeholder: 118 | detailNavigationController.setViewControllers([vc], animated: true) 119 | default: 120 | detailNavigationController.setViewControllers([vc], animated: false) 121 | } 122 | selectedNavController.detailView = .separated(vc) 123 | } 124 | return true // Prevent UIKit from performing default behavior 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Common/TabBarCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplitViewCoordinator.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/28/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | 11 | class SplitViewCoordinator: BaseCoordinator { 12 | typealias Dependencies = HasClient & HasUserManager 13 | 14 | private let window: UIWindow 15 | private let dependencies: Dependencies 16 | 17 | private let splitViewController: UISplitViewController 18 | private let tabBarController: TabBarController 19 | // swiftlint:disable:next weak_delegate 20 | private var viewDelegate: SplitViewDelegate? 21 | 22 | enum SectionTab { 23 | case posts 24 | case todos 25 | case settings 26 | 27 | var title: String { 28 | switch self { 29 | case .posts: return "Posts" 30 | case .todos: return "Todos" 31 | case .settings: return "Settings" 32 | } 33 | } 34 | 35 | var image: UIImage { 36 | switch self { 37 | case .posts: return #imageLiteral(resourceName: "PostsTabIcon") 38 | case .todos: return #imageLiteral(resourceName: "TodosTabIcon") 39 | case .settings: return #imageLiteral(resourceName: "Settings") 40 | } 41 | } 42 | 43 | var tag: Int { 44 | switch self { 45 | case .posts: return 0 46 | case .todos: return 1 47 | case .settings: return 2 48 | } 49 | } 50 | } 51 | 52 | // MARK: - Lifecycle 53 | 54 | init(window: UIWindow, dependencies: Dependencies) { 55 | self.window = window 56 | self.dependencies = dependencies 57 | self.splitViewController = UISplitViewController() 58 | self.tabBarController = TabBarController() 59 | } 60 | 61 | override func start() -> Observable { 62 | let tabBarController = TabBarController() 63 | let tabs: [SectionTab] = [.posts, .todos, .settings] 64 | let coordinationResults = Observable.from(configure(tabBarController: tabBarController, withTabs: tabs)).merge() 65 | 66 | self.viewDelegate = SplitViewDelegate(splitViewController: splitViewController, 67 | tabBarController: tabBarController) 68 | 69 | window.rootViewController = splitViewController 70 | window.makeKeyAndVisible() 71 | 72 | return coordinationResults 73 | } 74 | 75 | private func configure(tabBarController: UITabBarController, withTabs tabs: [SectionTab]) -> [Observable] { 76 | let navControllers = tabs 77 | .map { tab -> UINavigationController in 78 | let navController = NavigationController() 79 | navController.tabBarItem = UITabBarItem(title: tab.title, image: tab.image, tag: tab.tag) 80 | //navController.navigationBar.prefersLargeTitles = true 81 | //navController.navigationItem.largeTitleDisplayMode = .automatic 82 | return navController 83 | } 84 | 85 | tabBarController.viewControllers = navControllers 86 | tabBarController.view.backgroundColor = UIColor.white // Fix dark shadow in nav bar on segue 87 | 88 | return zip(tabs, navControllers) 89 | .map { (tab, navCtrl) in 90 | switch tab { 91 | case .posts: 92 | let coordinator = PostsCoordinator(navigationController: navCtrl, dependencies: dependencies) 93 | return coordinate(to: coordinator) 94 | case .todos: 95 | let coordinator = TodosCoordinator(navigationController: navCtrl, dependencies: dependencies) 96 | return coordinate(to: coordinator) 97 | case .settings: 98 | let coordinator = SettingsCoordinator(navigationController: navCtrl, dependencies: dependencies) 99 | return coordinate(to: coordinator) 100 | } 101 | } 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Common/TableViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TableViewController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/8/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxSwift 11 | 12 | class TableViewController: UITableViewController, ErrorAlertDisplayable { 13 | 14 | let disposeBag = DisposeBag() 15 | 16 | // MARK: - Lifecycle 17 | 18 | override func viewDidLoad() { 19 | super.viewDidLoad() 20 | setupTableView() 21 | } 22 | 23 | // MARK: - View Methods 24 | 25 | func setupTableView() { 26 | // This is necessary since UITableViewController automatically sets tableview delegate and dataSource to self 27 | tableView.delegate = nil 28 | tableView.dataSource = nil 29 | 30 | tableView.tableFooterView = UIView() // Prevent empty rows 31 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: UITableViewCell.reuseID) 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Login/LoginCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginCoordinator.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/1/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | class LoginCoordinator: BaseCoordinator { 14 | typealias Dependencies = HasUserManager 15 | 16 | private let window: UIWindow 17 | private let dependencies: Dependencies 18 | 19 | init(window: UIWindow, dependencies: Dependencies) { 20 | self.window = window 21 | self.dependencies = dependencies 22 | } 23 | 24 | override func start() -> Observable { 25 | let viewController = LoginViewController() 26 | let avm: Attachable = .detached(dependencies) 27 | let viewModel = viewController.attach(wrapper: avm) 28 | 29 | let login = viewModel.loggedIn 30 | .asObservable() 31 | .filter { $0 } 32 | .map { _ in return } 33 | 34 | let signup = viewModel.signupTaps 35 | .asObservable() 36 | .flatMap { [weak self] _ -> Observable in 37 | guard let strongSelf = self else { return .empty() } 38 | return strongSelf.showSignup(on: viewController) 39 | } 40 | .filter { $0 != SignupCoordinationResult.cancel } 41 | .map { _ in return } 42 | 43 | window.rootViewController = viewController 44 | window.makeKeyAndVisible() 45 | 46 | return Observable.merge(login, signup) 47 | .take(1) 48 | } 49 | 50 | private func showSignup(on rootViewController: UIViewController) -> Observable { 51 | let signupCoordinator = SignupCoordinator(rootViewController: rootViewController, dependencies: dependencies) 52 | return coordinate(to: signupCoordinator) 53 | } 54 | 55 | } 56 | 57 | // MARK: - Modal Presentation 58 | 59 | enum ModalLoginCoordinationResult { 60 | case login 61 | case cancel 62 | } 63 | 64 | class ModalLoginCoordinator: BaseCoordinator { 65 | typealias Dependencies = HasUserManager 66 | 67 | private let rootViewController: UIViewController 68 | private let dependencies: Dependencies 69 | 70 | init(rootViewController: UIViewController, dependencies: Dependencies) { 71 | self.rootViewController = rootViewController 72 | self.dependencies = dependencies 73 | } 74 | 75 | override func start() -> Observable { 76 | let viewController = LoginViewController() 77 | let navigationController = UINavigationController(rootViewController: viewController) 78 | 79 | let avm: Attachable = .detached(dependencies) 80 | let viewModel = viewController.attach(wrapper: avm) 81 | 82 | let login = viewModel.loggedIn 83 | .filter { $0 } 84 | .map { _ in return ModalLoginCoordinationResult.login } 85 | 86 | let cancel = viewModel.cancelTaps 87 | .map { _ in return ModalLoginCoordinationResult.cancel } 88 | 89 | rootViewController.present(navigationController, animated: true) 90 | 91 | return Driver.merge(cancel, login) 92 | .asObservable() 93 | .take(1) 94 | .do(onNext: { [weak self] _ in self?.rootViewController.dismiss(animated: true) }) 95 | } 96 | 97 | private func showSignup(on rootViewController: UIViewController) -> Observable { 98 | let signupCoordinator = SignupCoordinator(rootViewController: rootViewController, dependencies: dependencies) 99 | return coordinate(to: signupCoordinator) 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Login/LoginViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/1/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | import ColorCompatibility 13 | 14 | class LoginViewController: UIViewController, ViewModelAttaching { 15 | 16 | var viewModel: Attachable! 17 | lazy var bindings: LoginViewModel.Bindings = { 18 | return LoginViewModel.Bindings( 19 | username: loginTextField.rx.text.orEmpty.asDriver(), 20 | password: passwordTextField.rx.text.orEmpty.asDriver(), 21 | loginTaps: loginButton.rx.tap.asDriver(), 22 | signupTaps: signupButton.rx.tap.asDriver(), 23 | doneTaps: passwordTextField.rx.controlEvent(.editingDidEndOnExit).asDriver(), 24 | cancelTaps: cancelButtonItem.rx.tap.asDriver() 25 | ) 26 | }() 27 | 28 | let disposeBag = DisposeBag() 29 | 30 | // MARK: Interface 31 | 32 | let loginTextField: UITextField = { 33 | let view = UITextField() 34 | view.placeholder = "Email" 35 | view.translatesAutoresizingMaskIntoConstraints = false 36 | view.borderStyle = .roundedRect 37 | view.clearButtonMode = .whileEditing 38 | view.textAlignment = .left 39 | // UITextInputTraits 40 | view.autocapitalizationType = .none 41 | view.autocorrectionType = .no 42 | view.keyboardType = .emailAddress 43 | view.returnKeyType = .next 44 | view.spellCheckingType = .no 45 | view.textContentType = .emailAddress 46 | return view 47 | }() 48 | 49 | let passwordTextField: UITextField = { 50 | let view = UITextField() 51 | view.placeholder = "Password" 52 | view.isSecureTextEntry = true 53 | view.translatesAutoresizingMaskIntoConstraints = false 54 | view.borderStyle = .roundedRect 55 | view.clearButtonMode = .whileEditing 56 | view.textAlignment = .left 57 | // UITextInputTraits 58 | view.autocapitalizationType = .none 59 | view.returnKeyType = .go 60 | view.spellCheckingType = .no 61 | return view 62 | }() 63 | 64 | let loginButton: UIButton = { 65 | let view = UIButton() 66 | view.setTitle("Login", for: .normal) 67 | view.translatesAutoresizingMaskIntoConstraints = false 68 | view.layer.cornerRadius = 5 69 | view.clipsToBounds = true 70 | view.setTitleColor(ColorCompatibility.systemRed, for: .highlighted) 71 | view.backgroundColor = ColorCompatibility.systemBlue 72 | return view 73 | }() 74 | 75 | let signupButton: UIButton = { 76 | let view = UIButton() 77 | view.setTitle("Signup", for: .normal) 78 | view.translatesAutoresizingMaskIntoConstraints = false 79 | view.layer.cornerRadius = 5 80 | view.layer.borderWidth = 1 81 | view.layer.borderColor = ColorCompatibility.systemBlue.cgColor 82 | view.setTitleColor(ColorCompatibility.systemBlue, for: .normal) 83 | view.setTitleColor(ColorCompatibility.systemRed, for: .highlighted) 84 | view.backgroundColor = .clear 85 | return view 86 | }() 87 | 88 | private lazy var stackView: UIStackView = { 89 | let view = UIStackView(arrangedSubviews: [loginTextField, passwordTextField, loginButton, signupButton]) 90 | view.axis = .vertical 91 | view.alignment = .fill 92 | view.distribution = .fillEqually 93 | view.spacing = 8.0 94 | view.translatesAutoresizingMaskIntoConstraints = false 95 | return view 96 | }() 97 | 98 | let cancelButtonItem = UIBarButtonItem( 99 | barButtonSystemItem: .cancel, 100 | target: LoginViewController.self, 101 | action: nil) 102 | 103 | // MARK: - Lifecycle 104 | 105 | override func viewDidLoad() { 106 | super.viewDidLoad() 107 | setupView() 108 | setupConstraints() 109 | } 110 | 111 | override func viewWillAppear(_ animated: Bool) { 112 | if self.presentingViewController != nil { 113 | navigationItem.leftBarButtonItem = cancelButtonItem 114 | signupButton.isHidden = true 115 | } 116 | } 117 | 118 | // MARK: - View Methods 119 | 120 | private func setupView() { 121 | view.backgroundColor = ColorCompatibility.systemBackground 122 | view.addSubview(stackView) 123 | } 124 | 125 | private func setupConstraints() { 126 | let guide = view.safeAreaLayoutGuide 127 | let height = view.frame.height 128 | NSLayoutConstraint.activate([ 129 | stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor), 130 | stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: height / 4.0), 131 | stackView.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 16.0), 132 | stackView.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -16.0) 133 | //stackView.widthAnchor.constraint(lessThanOrEqualToConstant: 380.0) 134 | ]) 135 | } 136 | 137 | func bind(viewModel: LoginViewModel) -> LoginViewModel { 138 | viewModel.isValid 139 | .drive(loginButton.rx.isEnabled) 140 | .disposed(by: disposeBag) 141 | 142 | viewModel.loggingIn 143 | .drive(UIApplication.shared.rx.isNetworkActivityIndicatorVisible) 144 | .disposed(by: disposeBag) 145 | 146 | // Next keyboard button 147 | loginTextField.rx.controlEvent(.editingDidEndOnExit) 148 | .subscribe(onNext: { [weak self] _ in 149 | self?.passwordTextField.becomeFirstResponder() 150 | }) 151 | .disposed(by: disposeBag) 152 | 153 | return viewModel 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Login/LoginViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewModel.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/1/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | 12 | final class LoginViewModel: ViewModelType { 13 | 14 | let isValid: Driver 15 | let loggingIn: Driver 16 | let loggedIn: Driver 17 | let signupTaps: Driver 18 | let cancelTaps: Driver 19 | 20 | // MARK: - Lifecycle 21 | 22 | init(dependency: Dependency, bindings: Bindings) { 23 | let userInputs = Driver.combineLatest( 24 | bindings.username, bindings.password 25 | ) { (username, password) -> (String, String) in 26 | return (username, password) 27 | } 28 | 29 | isValid = userInputs 30 | .map { username, password in 31 | return username.count > 0 && password.count > 0 32 | } 33 | 34 | let loggingIn = ActivityIndicator() 35 | self.loggingIn = loggingIn.asDriver() 36 | 37 | loggedIn = Driver.merge(bindings.loginTaps, bindings.doneTaps) 38 | .withLatestFrom(userInputs) 39 | .flatMap { (arg) -> Driver in 40 | let (username, password) = arg 41 | return dependency.userManager.login(username: username, password: password) 42 | .trackActivity(loggingIn) 43 | .asDriver(onErrorJustReturn: false) 44 | } 45 | //.share(replay: 1) 46 | 47 | signupTaps = bindings.signupTaps 48 | cancelTaps = bindings.cancelTaps 49 | } 50 | 51 | // MARK: - ViewModelType 52 | 53 | typealias Dependency = HasUserManager 54 | 55 | struct Bindings { 56 | let username: Driver 57 | let password: Driver 58 | let loginTaps: Driver 59 | let signupTaps: Driver 60 | let doneTaps: Driver 61 | let cancelTaps: Driver 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Posts/PostDetail/PostDetailViewController.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 28 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Posts/PostDetail/PostDetailViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostDetailViewController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/28/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | final class PostDetailViewController: UIViewController { 14 | 15 | @IBOutlet weak var titleLabel: UILabel! 16 | @IBOutlet weak var bodyLabel: UILabel! 17 | 18 | let disposeBag = DisposeBag() 19 | var viewModel: PostDetailViewModel! 20 | 21 | // MARK: - Lifecycle 22 | 23 | override func viewDidLoad() { 24 | super.viewDidLoad() 25 | setupView() 26 | } 27 | 28 | // MARK: - View Methods 29 | 30 | private func setupView() { 31 | bindViewModel() 32 | } 33 | 34 | func bindViewModel() { 35 | viewModel.title 36 | .drive(titleLabel.rx.text) 37 | .disposed(by: disposeBag) 38 | 39 | viewModel.body 40 | .drive(bodyLabel.rx.text) 41 | .disposed(by: disposeBag) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Posts/PostDetail/PostDetailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostDetailViewModel.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/28/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | 12 | final class PostDetailViewModel { 13 | let title: Driver 14 | let body: Driver 15 | let post: Post 16 | 17 | // MARK: - Lifecycle 18 | 19 | init(post: Post) { 20 | self.post = post 21 | self.title = Observable.just(post.title).asDriver(onErrorJustReturn: "Error") 22 | self.body = Observable.just(post.body).asDriver(onErrorJustReturn: "Error") 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Posts/PostsCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostsCoordinator.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/28/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | 11 | class PostsCoordinator: BaseCoordinator { 12 | typealias Dependencies = HasPostService 13 | 14 | private let navigationController: UINavigationController 15 | private let dependencies: Dependencies 16 | 17 | init(navigationController: UINavigationController, dependencies: Dependencies) { 18 | self.navigationController = navigationController 19 | self.dependencies = dependencies 20 | } 21 | 22 | override func start() -> Observable { 23 | let viewController = PostsListViewController.instance() 24 | navigationController.viewControllers = [viewController] 25 | 26 | let avm: Attachable = .detached(dependencies) 27 | let viewModel = viewController.attach(wrapper: avm) 28 | 29 | viewModel.selectedPost 30 | .drive(onNext: { [weak self] selection in 31 | self?.showDetailView(with: selection) 32 | }) 33 | .disposed(by: viewController.disposeBag) 34 | 35 | // View will never be dismissed 36 | return Observable.never() 37 | } 38 | 39 | private func showDetailView(with post: Post) { 40 | let viewController = PostDetailViewController.instance() 41 | viewController.viewModel = PostDetailViewModel(post: post) 42 | navigationController.showDetailViewController(viewController, sender: nil) 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Posts/PostsList/PostTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostTableViewCell.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/16/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | final class PostTableViewCell: UITableViewCell { 14 | @IBOutlet weak var titleLabel: UILabel! 15 | @IBOutlet weak var bodyLabel: UILabel! 16 | 17 | private(set) var disposeBag = DisposeBag() 18 | 19 | override func prepareForReuse() { 20 | super.prepareForReuse() 21 | disposeBag = DisposeBag() 22 | } 23 | 24 | func bind(to viewModel: PostDetailViewModel) { 25 | viewModel.title 26 | .drive(titleLabel.rx.text) 27 | .disposed(by: disposeBag) 28 | 29 | viewModel.body 30 | .drive(bodyLabel.rx.text) 31 | .disposed(by: disposeBag) 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Posts/PostsList/PostsListViewController.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 35 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Posts/PostsList/PostsListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostsListViewController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/27/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | final class PostsListViewController: TableViewController, ViewModelAttaching { 14 | 15 | var viewModel: Attachable! 16 | var bindings: PostsListViewModel.Bindings { 17 | let viewWillAppear = rx.sentMessage(#selector(UIViewController.viewWillAppear(_:))) 18 | .mapToVoid() 19 | .asDriverOnErrorJustComplete() 20 | let refresh = tableView.refreshControl!.rx 21 | .controlEvent(.valueChanged) 22 | .asDriver() 23 | 24 | return PostsListViewModel.Bindings( 25 | fetchTrigger: Driver.merge(viewWillAppear, refresh), 26 | selection: tableView.rx.itemSelected.asDriver() 27 | ) 28 | } 29 | 30 | // MARK: - Lifecycle 31 | 32 | override func viewDidLoad() { 33 | super.viewDidLoad() 34 | setupView() 35 | } 36 | 37 | // MARK: - View Methods 38 | 39 | private func setupView() { 40 | title = "Posts" 41 | } 42 | 43 | func bind(viewModel: PostsListViewModel) -> PostsListViewModel { 44 | viewModel.posts 45 | .drive(tableView.rx.items(cellIdentifier: PostTableViewCell.reuseID, cellType: PostTableViewCell.self)) { _, viewModel, cell in 46 | cell.bind(to: viewModel) 47 | } 48 | .disposed(by: disposeBag) 49 | 50 | viewModel.fetching 51 | .drive(tableView.refreshControl!.rx.isRefreshing) 52 | .disposed(by: disposeBag) 53 | 54 | viewModel.errors 55 | .delay(.milliseconds(100)) 56 | .map { $0.localizedDescription } 57 | .drive(errorAlert) 58 | .disposed(by: disposeBag) 59 | 60 | return viewModel 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Posts/PostsList/PostsListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostsListViewModel.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/27/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | 12 | final class PostsListViewModel: ViewModelType { 13 | 14 | let fetching: Driver 15 | let posts: Driver<[PostDetailViewModel]> 16 | let selectedPost: Driver 17 | let errors: Driver 18 | 19 | // MARK: - Lifecycle 20 | 21 | init(dependency: Dependency, bindings: Bindings) { 22 | let activityIndicator = ActivityIndicator() 23 | let errorTracker = ErrorTracker() 24 | 25 | posts = bindings.fetchTrigger 26 | .flatMapLatest { 27 | return dependency.postService.getPosts() 28 | .trackActivity(activityIndicator) 29 | .trackError(errorTracker) 30 | .asDriverOnErrorJustComplete() 31 | .map { $0.map(PostDetailViewModel.init) } 32 | } 33 | 34 | fetching = activityIndicator.asDriver() 35 | errors = errorTracker.asDriver() 36 | selectedPost = bindings.selection 37 | .withLatestFrom(self.posts) { (indexPath, posts: [PostDetailViewModel]) -> Post in 38 | return posts[indexPath.row].post 39 | } 40 | } 41 | 42 | // MARK: - ViewModelType 43 | 44 | typealias Dependency = HasPostService 45 | 46 | struct Bindings { 47 | let fetchTrigger: Driver 48 | let selection: Driver 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Profile/ProfileCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileCoordinator.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/12/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | class ProfileCoordinator: BaseCoordinator { 14 | typealias Dependencies = HasUserManager 15 | 16 | private let navigationController: UINavigationController 17 | private let dependencies: Dependencies 18 | 19 | init(navigationController: UINavigationController, dependencies: Dependencies) { 20 | self.navigationController = navigationController 21 | self.dependencies = dependencies 22 | } 23 | 24 | override func start() -> Observable { 25 | let viewController = ProfileViewController.instance() 26 | navigationController.viewControllers = [viewController] 27 | 28 | let avm: Attachable = .detached(dependencies) 29 | let viewModel = viewController.attach(wrapper: avm) 30 | 31 | return viewModel.settingsTap 32 | .asObservable() 33 | .flatMap { [weak self] _ -> Observable in 34 | guard let strongSelf = self else { return .empty() } 35 | return strongSelf.showSettings(on: viewController) 36 | } 37 | .filter { $0 != .none } 38 | .map { _ in return } 39 | } 40 | 41 | private func showSettings(on rootViewController: UIViewController) -> Observable { 42 | let settingsCoordinator = SettingsCoordinator(rootViewController: rootViewController, 43 | dependencies: dependencies) 44 | return coordinate(to: settingsCoordinator) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Profile/ProfileViewController.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Profile/ProfileViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileViewController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/12/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | class ProfileViewController: UIViewController, ViewModelAttaching { 14 | 15 | var viewModel: Attachable! 16 | var bindings: ProfileViewModel.Bindings { 17 | return ProfileViewModel.Bindings( 18 | settingsTaps: settingsButtonItem.rx.tap.asDriver() 19 | ) 20 | } 21 | 22 | let disposeBag = DisposeBag() 23 | 24 | // MARK: Interface 25 | 26 | @IBOutlet weak var settingsButtonItem: UIBarButtonItem! 27 | @IBOutlet weak var avatarView: UIView! 28 | @IBOutlet weak var avatarLabel: UILabel! 29 | @IBOutlet weak var nameLabel: UILabel! 30 | @IBOutlet weak var usernameLabel: UILabel! 31 | 32 | // MARK: Lifecycle 33 | 34 | override func viewDidLoad() { 35 | super.viewDidLoad() 36 | setupView() 37 | } 38 | 39 | // MARK: - View Methods 40 | 41 | private func setupView() { 42 | avatarView.layer.cornerRadius = 35 43 | avatarView.clipsToBounds = true 44 | } 45 | 46 | func bind(viewModel: ProfileViewModel) -> ProfileViewModel { 47 | viewModel.initials.drive(avatarLabel.rx.text) 48 | .disposed(by: disposeBag) 49 | 50 | viewModel.name.drive(nameLabel.rx.text) 51 | .disposed(by: disposeBag) 52 | 53 | viewModel.username.drive(usernameLabel.rx.text) 54 | .disposed(by: disposeBag) 55 | 56 | return viewModel 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Profile/ProfileViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileViewModel.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/12/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | import RxSwiftExt 12 | 13 | final class ProfileViewModel: ViewModelType { 14 | 15 | let initials: Driver 16 | let name: Driver 17 | let username: Driver 18 | let settingsTap: Driver 19 | 20 | // MARK: - Lifecycle 21 | 22 | init(dependency: Dependency, bindings: Bindings) { 23 | let currentUser = dependency.userManager.currentUser 24 | .unwrap() 25 | 26 | name = currentUser 27 | .map { $0.name } 28 | .asDriver(onErrorJustReturn: "Error") 29 | 30 | initials = name 31 | // https://stackoverflow.com/questions/35285978/get-the-initials-from-a-name-and-limit-it-to-2-initials 32 | .map { $0.components(separatedBy: " ").reduce("") { ($0 == "" ? "" : "\($0.first ?? "X")") + "\($1.first ?? "X")" } } 33 | .asDriver(onErrorJustReturn: "Error") 34 | 35 | username = currentUser 36 | .map { $0.username } 37 | .asDriver(onErrorJustReturn: "Error") 38 | 39 | settingsTap = bindings.settingsTaps 40 | } 41 | 42 | // MARK: - ViewModelType 43 | 44 | typealias Dependency = HasUserManager 45 | 46 | struct Bindings { 47 | let settingsTaps: Driver 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Settings/SettingsCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsCoordinator.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/4/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | 11 | /// Type defining possible coordination results of the `SettingsCoordinator`. 12 | /// 13 | /// - none: No changes to user authentication status. 14 | /// - logout: User logged out. 15 | /// - login: User logged out and back in again (possibly as a different user). 16 | enum SettingsCoordinationResult { 17 | case none 18 | case logout 19 | case login 20 | } 21 | 22 | class SettingsCoordinator: BaseCoordinator { 23 | typealias Dependencies = HasUserManager 24 | 25 | private let rootViewController: UIViewController 26 | private let dependencies: Dependencies 27 | 28 | init(rootViewController: UIViewController, dependencies: Dependencies) { 29 | self.rootViewController = rootViewController 30 | self.dependencies = dependencies 31 | } 32 | 33 | override func start() -> Observable { 34 | let viewController = SettingsViewController.instance() 35 | let navigationController = UINavigationController(rootViewController: viewController) 36 | 37 | let avm: Attachable = .detached(dependencies) 38 | let viewModel = viewController.attach(wrapper: avm) 39 | 40 | let login = viewModel.showLogin 41 | .asObservable() 42 | .flatMap { [weak self] _ -> Observable in 43 | guard let strongSelf = self else { return .empty() } 44 | return strongSelf.showLogin(on: viewController) 45 | } 46 | .filter { $0 == .login } 47 | .map { _ in return SettingsCoordinationResult.login } 48 | 49 | let logout = viewModel.didLogout 50 | .asObservable() 51 | .map { _ in return SettingsCoordinationResult.logout } 52 | 53 | let authenticationChanges = Observable.of(logout, login) 54 | .merge() 55 | .startWith(SettingsCoordinationResult.none) 56 | 57 | if let navVC = rootViewController.parent as? UINavigationController, let tabVC = navVC.parent, 58 | let splitVC = tabVC.parent, splitVC.traitCollection.horizontalSizeClass == .regular { 59 | navigationController.modalPresentationStyle = .formSheet 60 | } 61 | 62 | rootViewController.present(navigationController, animated: true) 63 | 64 | return viewController.doneButtonItem.rx.tap 65 | .take(1) 66 | .withLatestFrom(authenticationChanges) 67 | .do(onNext: { [weak self] _ in self?.rootViewController.dismiss(animated: true) }) 68 | } 69 | 70 | private func showLogin(on rootViewController: UIViewController) -> Observable { 71 | let loginCoordinator = ModalLoginCoordinator(rootViewController: rootViewController, dependencies: dependencies) 72 | return coordinate(to: loginCoordinator) 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Settings/SettingsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsViewController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/4/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | class SettingsViewController: UITableViewController, ViewModelAttaching { 14 | 15 | lazy var bindings: SettingsViewModel.Bindings = { 16 | return SettingsViewModel.Bindings( 17 | selection: tableView.rx.itemSelected.asDriver() 18 | ) 19 | }() 20 | 21 | let disposeBag = DisposeBag() 22 | var viewModel: Attachable! 23 | 24 | // MARK: - Interface 25 | 26 | @IBOutlet weak var doneButtonItem: UIBarButtonItem! 27 | @IBOutlet weak var accountCell: UITableViewCell! 28 | 29 | // MARK: - Lifecycle 30 | 31 | override func viewDidLoad() { 32 | super.viewDidLoad() 33 | setupView() 34 | } 35 | 36 | // MARK: - View Methods 37 | 38 | private func setupView() { 39 | title = "Settings" 40 | } 41 | 42 | func bind(viewModel: SettingsViewModel) -> SettingsViewModel { 43 | viewModel.didLogout 44 | .drive() 45 | .disposed(by: disposeBag) 46 | 47 | viewModel.accountCellText 48 | .drive(accountCell.textLabel!.rx.text) 49 | .disposed(by: disposeBag) 50 | 51 | return viewModel 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Settings/SettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsViewModel.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/4/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | 12 | struct SettingsViewModel: ViewModelType { 13 | 14 | let accountCellText: Driver 15 | let didLogout: Driver 16 | let showLogin: Driver 17 | 18 | // MARK: - Lifecycle 19 | 20 | init(dependency: Dependency, bindings: Bindings) { 21 | 22 | accountCellText = dependency.userManager.currentUser 23 | .map { user in 24 | return user != nil ? "Logout \(user!.username)" : "Login" 25 | } 26 | .asDriver(onErrorJustReturn: "Error") 27 | 28 | let accountCellTaps = bindings.selection 29 | .filter { $0.section == 0 } 30 | 31 | didLogout = accountCellTaps 32 | .filter { _ in dependency.userManager.authenticationState == .signedIn } 33 | .flatMap { _ in 34 | return dependency.userManager.logout() 35 | .asDriver(onErrorJustReturn: false) 36 | } 37 | 38 | showLogin = accountCellTaps 39 | .filter { _ in dependency.userManager.authenticationState == .signedOut } 40 | .map { _ in return } 41 | } 42 | 43 | // MARK: - ViewModelType 44 | 45 | typealias Dependency = HasUserManager 46 | 47 | struct Bindings { 48 | let selection: Driver 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Signup/SignupCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignupCoordinator.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/1/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | 12 | /// Type defining possible coordination results of the `SignupCoordinator`. 13 | /// 14 | /// - signUp: Signup completed successfully. 15 | /// - cancel: Cancel button was tapped. 16 | enum SignupCoordinationResult { 17 | case signUp 18 | case cancel 19 | } 20 | 21 | class SignupCoordinator: BaseCoordinator { 22 | typealias Dependencies = HasUserManager 23 | 24 | private let rootViewController: UIViewController 25 | private let dependencies: Dependencies 26 | 27 | init(rootViewController: UIViewController, dependencies: Dependencies) { 28 | self.rootViewController = rootViewController 29 | self.dependencies = dependencies 30 | } 31 | 32 | override func start() -> Observable { 33 | let viewController = SignupViewController.instance() 34 | let navigationController = UINavigationController(rootViewController: viewController) 35 | 36 | let avm: Attachable = .detached(dependencies) 37 | let viewModel = viewController.attach(wrapper: avm) 38 | 39 | let cancel = viewModel.cancelled 40 | .map { _ in CoordinationResult.cancel } 41 | let signUp = viewModel.signedUp 42 | .map { _ in CoordinationResult.signUp } 43 | 44 | rootViewController.present(navigationController, animated: true) 45 | 46 | return Driver.merge(cancel, signUp) 47 | .asObservable() 48 | .take(1) 49 | .do(onNext: { [weak self] _ in self?.rootViewController.dismiss(animated: true) }) 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Signup/SignupViewController.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Signup/SignupViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignupViewController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/1/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | class SignupViewController: UIViewController, ViewModelAttaching { 14 | 15 | var viewModel: Attachable! 16 | lazy var bindings: SignupViewModel.Bindings = { 17 | return SignupViewModel.Bindings( 18 | firstName: firstNameTextField.rx.text.orEmpty.asDriver(), 19 | lastName: lastNameTextField.rx.text.orEmpty.asDriver(), 20 | login: loginTextField.rx.text.orEmpty.asDriver(), 21 | password: passwordTextField.rx.text.orEmpty.asDriver(), 22 | cancelTaps: cancelButton.rx.tap.asDriver(), 23 | signupTaps: signupButton.rx.tap.asDriver(), 24 | doneTaps: passwordTextField.rx.controlEvent(.editingDidEndOnExit).asDriver() 25 | ) 26 | }() 27 | 28 | let disposeBag = DisposeBag() 29 | 30 | // MARK: Interface 31 | let cancelButton = UIBarButtonItem( 32 | barButtonSystemItem: .cancel, 33 | target: SignupViewController.self, 34 | action: nil) 35 | @IBOutlet weak var firstNameTextField: UITextField! 36 | @IBOutlet weak var lastNameTextField: UITextField! 37 | @IBOutlet weak var loginTextField: UITextField! 38 | @IBOutlet weak var passwordTextField: UITextField! 39 | @IBOutlet weak var signupButton: UIButton! 40 | 41 | // MARK: Lifecycle 42 | 43 | override func viewDidLoad() { 44 | super.viewDidLoad() 45 | setupView() 46 | } 47 | 48 | // MARK: - View Methods 49 | 50 | private func setupView() { 51 | title = "Signup" 52 | self.navigationItem.leftBarButtonItem = cancelButton 53 | signupButton.layer.cornerRadius = 5 54 | 55 | // Next keyboard button 56 | firstNameTextField.rx.controlEvent(.editingDidEndOnExit) 57 | .subscribe(onNext: { [weak self] _ in 58 | self?.lastNameTextField.becomeFirstResponder() 59 | }) 60 | .disposed(by: disposeBag) 61 | 62 | lastNameTextField.rx.controlEvent(.editingDidEndOnExit) 63 | .subscribe(onNext: { [weak self] _ in 64 | self?.loginTextField.becomeFirstResponder() 65 | }) 66 | .disposed(by: disposeBag) 67 | 68 | loginTextField.rx.controlEvent(.editingDidEndOnExit) 69 | .subscribe(onNext: { [weak self] _ in 70 | self?.passwordTextField.becomeFirstResponder() 71 | }) 72 | .disposed(by: disposeBag) 73 | } 74 | 75 | func bind(viewModel: SignupViewModel) -> SignupViewModel { 76 | viewModel.isValid 77 | .drive(signupButton.rx.isEnabled) 78 | .disposed(by: disposeBag) 79 | 80 | viewModel.signingUp 81 | .drive(UIApplication.shared.rx.isNetworkActivityIndicatorVisible) 82 | .disposed(by: disposeBag) 83 | 84 | return viewModel 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Signup/SignupViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignupViewModel.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/1/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | 12 | final class SignupViewModel: ViewModelType { 13 | 14 | let isValid: Driver 15 | let signingUp: Driver 16 | let signedUp: Driver 17 | let cancelled: Driver 18 | 19 | // MARK: - Lifecycle 20 | 21 | init(dependency: Dependency, bindings: Bindings) { 22 | let userInputs = Driver.combineLatest( 23 | bindings.firstName, bindings.lastName, bindings.login, bindings.password 24 | ) { (firstName, lastName, username, password) -> (String, String, String, String) in 25 | return (firstName, lastName, username, password) 26 | } 27 | 28 | isValid = userInputs 29 | .map { firstName, lastName, username, password in 30 | return firstName.count > 0 && lastName.count > 0 && username.count > 0 && password.count > 0 31 | } 32 | 33 | let signingUp = ActivityIndicator() 34 | self.signingUp = signingUp.asDriver() 35 | 36 | signedUp = Driver.merge(bindings.signupTaps, bindings.doneTaps) 37 | .withLatestFrom(userInputs) 38 | .flatMap { (arg) -> Driver in 39 | let (firstName, lastName, username, password) = arg 40 | return dependency.userManager.signup(firstName: firstName, lastName: lastName, username: username, 41 | password: password) 42 | .trackActivity(signingUp) 43 | .asDriver(onErrorJustReturn: false) 44 | } 45 | 46 | cancelled = bindings.cancelTaps 47 | } 48 | 49 | // MARK: - ViewModelType 50 | 51 | typealias Dependency = HasUserManager 52 | 53 | struct Bindings { 54 | let firstName: Driver 55 | let lastName: Driver 56 | let login: Driver 57 | let password: Driver 58 | let cancelTaps: Driver 59 | let signupTaps: Driver 60 | let doneTaps: Driver 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Todos/TodosCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosCoordinator.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/28/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | 11 | class TodosCoordinator: BaseCoordinator { 12 | typealias Dependencies = HasTodoService 13 | 14 | private let navigationController: UINavigationController 15 | private let dependencies: Dependencies 16 | 17 | init(navigationController: UINavigationController, dependencies: Dependencies) { 18 | self.navigationController = navigationController 19 | self.dependencies = dependencies 20 | } 21 | 22 | override func start() -> Observable { 23 | let viewController = TodosListViewController.instance() 24 | navigationController.viewControllers = [viewController] 25 | 26 | let avm: Attachable = .detached(dependencies) 27 | let viewModel = viewController.attach(wrapper: avm) 28 | 29 | viewModel.selectedTodo 30 | .drive(onNext: { selection in 31 | print("Selected: \(selection)") 32 | }) 33 | .disposed(by: viewController.disposeBag) 34 | 35 | // View will never be dismissed 36 | return Observable.never() 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Todos/TodosList/TodosListViewController.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Todos/TodosList/TodosListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosListViewController.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/28/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RxCocoa 11 | import RxSwift 12 | 13 | final class TodosListViewController: TableViewController, ViewModelAttaching { 14 | 15 | var viewModel: Attachable! 16 | var bindings: TodosListViewModel.Bindings { 17 | let viewWillAppear = rx.sentMessage(#selector(UIViewController.viewWillAppear(_:))) 18 | .mapToVoid() 19 | .asDriverOnErrorJustComplete() 20 | let refresh = tableView.refreshControl!.rx 21 | .controlEvent(.valueChanged) 22 | .asDriver() 23 | 24 | return TodosListViewModel.Bindings( 25 | fetchTrigger: Driver.merge(viewWillAppear, refresh), 26 | selection: tableView.rx.itemSelected.asDriver() 27 | ) 28 | } 29 | 30 | // MARK: - Lifecycle 31 | 32 | override func viewDidLoad() { 33 | super.viewDidLoad() 34 | setupView() 35 | } 36 | 37 | // MARK: - View Methods 38 | 39 | private func setupView() { 40 | title = "Todos" 41 | } 42 | 43 | func bind(viewModel: TodosListViewModel) -> TodosListViewModel { 44 | viewModel.todos 45 | .drive(tableView.rx.items(cellIdentifier: "Cell")) { _, element, cell in 46 | cell.textLabel?.text = element.title 47 | } 48 | .disposed(by: disposeBag) 49 | 50 | viewModel.fetching 51 | .drive(tableView.refreshControl!.rx.isRefreshing) 52 | .disposed(by: disposeBag) 53 | 54 | viewModel.errors 55 | .delay(.milliseconds(100)) 56 | .map { $0.localizedDescription } 57 | .drive(errorAlert) 58 | .disposed(by: disposeBag) 59 | 60 | return viewModel 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Scenes/Todos/TodosList/TodosListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosListViewModel.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/28/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | 12 | final class TodosListViewModel: ViewModelType { 13 | 14 | let fetching: Driver 15 | let todos: Driver<[Todo]> 16 | let selectedTodo: Driver 17 | let errors: Driver 18 | 19 | // MARK: - Lifecycle 20 | 21 | init(dependency: Dependency, bindings: Bindings) { 22 | let activityIndicator = ActivityIndicator() 23 | let errorTracker = ErrorTracker() 24 | 25 | todos = bindings.fetchTrigger 26 | .flatMapLatest { 27 | return dependency.todoService.getTodos() 28 | .trackActivity(activityIndicator) 29 | .trackError(errorTracker) 30 | .asDriverOnErrorJustComplete() 31 | } 32 | 33 | fetching = activityIndicator.asDriver() 34 | errors = errorTracker.asDriver() 35 | selectedTodo = bindings.selection 36 | .withLatestFrom(self.todos) { (indexPath, todos: [Todo]) -> Todo in 37 | return todos[indexPath.row] 38 | } 39 | } 40 | 41 | // MARK: - ViewModelType 42 | 43 | typealias Dependency = HasTodoService 44 | 45 | struct Bindings { 46 | let fetchTrigger: Driver 47 | let selection: Driver 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Services/APIClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIClient.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/27/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Alamofire 10 | import CodableAlamofire 11 | import RxSwift 12 | 13 | protocol ClientType { 14 | func request(_: URLRequestConvertible) -> Single 15 | func requestImage(_ endpoint: URLRequestConvertible) -> Single 16 | } 17 | 18 | class APIClient: ClientType { 19 | 20 | // MARK: Properties 21 | 22 | private let sessionManager: SessionManager 23 | private let decoder: JSONDecoder 24 | 25 | private let queue = DispatchQueue(label: "com.mgacy.response-queue", qos: .userInitiated, attributes: [.concurrent]) 26 | 27 | // MARK: Lifecycle 28 | 29 | init() { 30 | let configuration = URLSessionConfiguration.default 31 | configuration.timeoutIntervalForRequest = 8 // seconds 32 | configuration.timeoutIntervalForResource = 8 // seconds 33 | sessionManager = Alamofire.SessionManager(configuration: configuration) 34 | 35 | // JSON Decoding 36 | decoder = JSONDecoder() 37 | //decoder.dateDecodingStrategy = .iso8601 38 | } 39 | 40 | // MARK: Methods 41 | 42 | func request(_ endpoint: URLRequestConvertible) -> Single { 43 | return Single.create { [unowned self] single in 44 | let request = self.sessionManager.request(endpoint) 45 | request 46 | .validate() 47 | .responseDecodableObject(queue: self.queue, decoder: self.decoder) { (response: DataResponse) in 48 | switch response.result { 49 | case let .success(val): 50 | single(.success(val)) 51 | case let .failure(err): 52 | single(.error(err)) 53 | } 54 | } 55 | return Disposables.create { 56 | request.cancel() 57 | } 58 | } 59 | } 60 | 61 | func requestImage(_ endpoint: URLRequestConvertible) -> Single { 62 | return Single.create { [unowned self] single in 63 | let request = self.sessionManager.request(endpoint) 64 | request 65 | .validate() 66 | .responseData { response in 67 | switch response.result { 68 | case .success(let value): 69 | guard let image = UIImage(data: value) else { 70 | single(.error(ClientError.imageDecodingFailed)) 71 | return 72 | } 73 | single(.success(image)) 74 | case .failure(let err): 75 | single(.error(err)) 76 | } 77 | } 78 | return Disposables.create { 79 | request.cancel() 80 | } 81 | } 82 | } 83 | 84 | } 85 | 86 | // MARK: - Errors 87 | 88 | enum ClientError: Error { 89 | case imageDecodingFailed 90 | } 91 | 92 | extension ClientError: LocalizedError { 93 | public var errorDescription: String? { 94 | switch self { 95 | case .imageDecodingFailed: 96 | return "Unable to decode image" 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Services/AlbumService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumService.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 2/28/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Alamofire 10 | import RxSwift 11 | 12 | protocol AlbumServiceType { 13 | func getAlbums() -> Single<[Album]> 14 | func getAlbum(id: Int) -> Single 15 | func getPhotos() -> Single<[Photo]> 16 | func getPhotosFromAlbum(id: Int) -> Single<[Photo]> 17 | func getPhoto(id: Int) -> Single 18 | func getThumbnail(for photo: Photo) -> Single 19 | func getImage(for photo: Photo) -> Single 20 | } 21 | 22 | class AlbumService: AlbumServiceType { 23 | private let client: ClientType 24 | 25 | init(client: ClientType) { 26 | self.client = client 27 | } 28 | 29 | // MARK: Albums 30 | 31 | func getAlbums() -> Single<[Album]> { 32 | return client.request(Router.getAlbums) 33 | } 34 | 35 | func getAlbum(id: Int) -> Single { 36 | return client.request(Router.getAlbum(id: id)) 37 | } 38 | 39 | // MARK: Photos 40 | 41 | func getPhotos() -> Single<[Photo]> { 42 | return client.request(Router.getPhotos) 43 | } 44 | 45 | func getPhotosFromAlbum(id: Int) -> Single<[Photo]> { 46 | return client.request(Router.getPhotosFromAlbum(id: id)) 47 | } 48 | 49 | func getPhoto(id: Int) -> Single { 50 | return client.request(Router.getPhoto(id: id)) 51 | } 52 | 53 | func getThumbnail(for photo: Photo) -> Single { 54 | return client.requestImage(URLRequest(url: photo.thumbnailUrl)) 55 | } 56 | 57 | func getImage(for photo: Photo) -> Single { 58 | return client.requestImage(URLRequest(url: photo.url)) 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Services/PostService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostService.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 2/28/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Alamofire 10 | import RxSwift 11 | 12 | protocol PostServiceType { 13 | func getPosts() -> Single<[Post]> 14 | func getPost(id: Int) -> Single 15 | } 16 | 17 | class PostService: PostServiceType { 18 | private let client: ClientType 19 | 20 | init(client: ClientType) { 21 | self.client = client 22 | } 23 | 24 | func getPosts() -> Single<[Post]> { 25 | return client.request(Router.getPosts) 26 | } 27 | 28 | func getPost(id: Int) -> Single { 29 | return client.request(Router.getPost(id: id)) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Services/Router.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Router.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 12/27/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Alamofire 10 | 11 | public enum Router: URLRequestConvertible { 12 | case getAlbums 13 | case getAlbum(id: Int) 14 | case getPhotos 15 | case getPhoto(id: Int) 16 | case getPhotosFromAlbum(id: Int) 17 | case getPosts 18 | case getPost(id: Int) 19 | case getTodos 20 | case getTodo(id: Int) 21 | case getUser(id: Int) 22 | 23 | static let baseURLString = "https://jsonplaceholder.typicode.com" 24 | 25 | var method: HTTPMethod { 26 | switch self { 27 | default: 28 | return .get 29 | } 30 | } 31 | 32 | var path: String { 33 | switch self { 34 | case .getAlbums: 35 | return "/albums" 36 | case .getAlbum(let id): 37 | return "/albums/\(id)" 38 | case .getPhotos: 39 | return "/photos" 40 | case .getPhoto(let id): 41 | return "/photos/\(id)" 42 | case .getPhotosFromAlbum: 43 | return "/photos" 44 | case .getPosts: 45 | return "/posts" 46 | case .getPost(let id): 47 | return "/posts/\(id)" 48 | case .getTodos: 49 | return "/todos" 50 | case .getTodo(let id): 51 | return "/todos/\(id)" 52 | case .getUser(let id): 53 | return "/users/\(id)" 54 | } 55 | } 56 | 57 | var parameters: Parameters { 58 | switch self { 59 | case .getPhotosFromAlbum(let id): 60 | return ["album": id] 61 | default: 62 | return [:] 63 | } 64 | } 65 | 66 | // MARK: URLRequestConvertible 67 | 68 | public func asURLRequest() throws -> URLRequest { 69 | let url = try Router.baseURLString.asURL() 70 | var urlRequest = URLRequest(url: url.appendingPathComponent(path)) 71 | urlRequest.httpMethod = method.rawValue 72 | 73 | switch self { 74 | case .getPhotosFromAlbum: 75 | urlRequest = try JSONEncoding.default.encode(urlRequest, with: parameters) 76 | default: 77 | break 78 | } 79 | 80 | return urlRequest 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Services/TodoService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodoService.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 2/28/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Alamofire 10 | import RxSwift 11 | 12 | protocol TodoServiceType { 13 | func getTodos() -> Single<[Todo]> 14 | func getTodo(id: Int) -> Single 15 | } 16 | 17 | class TodoService: TodoServiceType { 18 | private let client: ClientType 19 | 20 | init(client: ClientType) { 21 | self.client = client 22 | } 23 | 24 | func getTodos() -> Single<[Todo]> { 25 | return client.request(Router.getTodos) 26 | } 27 | 28 | func getTodo(id: Int) -> Single { 29 | return client.request(Router.getTodo(id: id)) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Services/UserManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserManager.swift 3 | // MVVMC-SplitViewController 4 | // 5 | // Created by Mathew Gacy on 1/1/18. 6 | // Copyright © 2018 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxSwift 11 | import RxCocoa 12 | 13 | enum AuthenticationState { 14 | case signedIn 15 | case signedOut 16 | } 17 | 18 | class UserManager { 19 | var authenticationState: AuthenticationState 20 | let currentUser = BehaviorSubject(value: nil) 21 | private let client: ClientType 22 | private let storageManager: UserStorageManagerType 23 | 24 | init() { 25 | self.client = APIClient() 26 | self.storageManager = UserStorageManager() 27 | if let user = storageManager.read() { 28 | self.authenticationState = .signedIn 29 | self.currentUser.onNext(user) 30 | } else { 31 | self.authenticationState = .signedOut 32 | } 33 | } 34 | 35 | } 36 | 37 | // MARK: - Authentication Services 38 | 39 | typealias AuthenticationService = LoginService & LogoutService & SignupService 40 | 41 | enum AuthenticationError: Error { 42 | case invalidCredentials 43 | } 44 | 45 | extension AuthenticationError: LocalizedError { 46 | public var errorDescription: String? { 47 | switch self { 48 | case .invalidCredentials: 49 | return "Invalid credentials" 50 | } 51 | } 52 | } 53 | 54 | // MARK: LoginService 55 | 56 | protocol LoginService { 57 | func login(username: String, password: String) -> Single 58 | } 59 | 60 | extension UserManager: LoginService { 61 | 62 | func login(username: String, password: String) -> Single { 63 | // just a mock 64 | let loginResult = arc4random() % 5 == 0 ? false : true 65 | if loginResult == false { 66 | return .error(AuthenticationError.invalidCredentials) 67 | } 68 | 69 | return client.request(Router.getUser(id: 1)) 70 | .do(onSuccess: { [weak self] (user: User) in 71 | self?.authenticationState = .signedIn 72 | self?.currentUser.onNext(user) 73 | self?.storageManager.store(user: user) 74 | }) 75 | .map { _ in return true } 76 | } 77 | 78 | } 79 | 80 | // MARK: LogoutService 81 | 82 | protocol LogoutService { 83 | func logout() -> Single 84 | } 85 | 86 | extension UserManager: LogoutService { 87 | 88 | func logout() -> Single { 89 | // just a mock 90 | return Single.just(true) 91 | .delay(.milliseconds(500), scheduler: MainScheduler.instance) 92 | .do(onSuccess: { [weak self] _ in 93 | self?.authenticationState = .signedOut 94 | self?.storageManager.clear() 95 | self?.currentUser.onNext(nil) 96 | }) 97 | } 98 | 99 | } 100 | 101 | // MARK: SignupService 102 | 103 | protocol SignupService { 104 | func signup(firstName: String, lastName: String, username: String, password: String) -> Single 105 | } 106 | 107 | extension UserManager: SignupService { 108 | 109 | func signup(firstName: String, lastName: String, username: String, password: String) -> Single { 110 | // just a mock 111 | let signupResult = arc4random() % 5 == 0 ? false : true 112 | if signupResult == false { 113 | return .error(AuthenticationError.invalidCredentials) 114 | } 115 | 116 | return client.request(Router.getUser(id: 1)) 117 | .do(onSuccess: { [weak self] (user: User) in 118 | self?.authenticationState = .signedIn 119 | self?.currentUser.onNext(user) 120 | self?.storageManager.store(user: user) 121 | }) 122 | .map { _ in return true } 123 | } 124 | 125 | } 126 | 127 | // MARK: - Persistence 128 | 129 | protocol UserStorageManagerType { 130 | func store(user: User) 131 | func read() -> User? 132 | func clear() 133 | } 134 | 135 | class UserStorageManager: UserStorageManagerType { 136 | private let encoder: JSONEncoder 137 | private let archiveURL: URL 138 | 139 | init() { 140 | encoder = JSONEncoder() 141 | archiveURL = UserStorageManager.getDocumentsURL().appendingPathComponent("user") 142 | } 143 | 144 | func store(user: User) { 145 | // should incorporate better error handling 146 | do { 147 | let data = try encoder.encode(user) 148 | guard NSKeyedArchiver.archiveRootObject(data, toFile: archiveURL.path) else { 149 | fatalError("Could not store data to url") 150 | } 151 | } catch { 152 | fatalError(error.localizedDescription) 153 | } 154 | } 155 | 156 | func read() -> User? { 157 | if let data = NSKeyedUnarchiver.unarchiveObject(withFile: archiveURL.path) as? Data { 158 | let decoder = JSONDecoder() 159 | do { 160 | let user = try decoder.decode(User.self, from: data) 161 | return user 162 | } catch { 163 | fatalError(error.localizedDescription) 164 | } 165 | } else { 166 | return nil 167 | } 168 | } 169 | 170 | func clear() { 171 | // should incorporate better error handling 172 | do { 173 | try FileManager.default.removeItem(at: archiveURL) 174 | } catch { 175 | fatalError("Could not delete data from url") 176 | } 177 | } 178 | 179 | // MARK: - Helper Methods 180 | 181 | private static func getDocumentsURL() -> URL { 182 | if let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { 183 | return url 184 | } else { 185 | // should incorporate better error handling 186 | fatalError("Could not retrieve documents directory") 187 | } 188 | } 189 | 190 | } 191 | -------------------------------------------------------------------------------- /MVVMC-SplitViewController/Supporting Files/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | NSAppTransportSecurity 24 | 25 | NSExceptionDomains 26 | 27 | placehold.it 28 | 29 | NSIncludesSubdomains 30 | 31 | NSTemporaryExceptionAllowsInsecureHTTPLoads 32 | 33 | 34 | 35 | 36 | UILaunchStoryboardName 37 | LaunchScreen 38 | UIRequiredDeviceCapabilities 39 | 40 | armv7 41 | 42 | UISupportedInterfaceOrientations 43 | 44 | UIInterfaceOrientationPortrait 45 | UIInterfaceOrientationLandscapeLeft 46 | UIInterfaceOrientationLandscapeRight 47 | 48 | UISupportedInterfaceOrientations~ipad 49 | 50 | UIInterfaceOrientationPortrait 51 | UIInterfaceOrientationPortraitUpsideDown 52 | UIInterfaceOrientationLandscapeLeft 53 | UIInterfaceOrientationLandscapeRight 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /MVVMC-SplitViewControllerTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /MVVMC-SplitViewControllerTests/MVVMC_SplitViewControllerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MVVMC_SplitViewControllerTests.swift 3 | // MVVMC-SplitViewControllerTests 4 | // 5 | // Created by Mathew Gacy on 12/21/17. 6 | // Copyright © 2017 Mathew Gacy. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import MVVMC_SplitViewController 11 | 12 | // swiftlint:disable type_name 13 | 14 | class MVVMC_SplitViewControllerTests: XCTestCase { 15 | 16 | override func setUp() { 17 | super.setUp() 18 | // Put setup code here. This method is called before the invocation of each test method in the class. 19 | } 20 | 21 | override func tearDown() { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | super.tearDown() 24 | } 25 | 26 | func testExample() { 27 | // This is an example of a functional test case. 28 | // Use XCTAssert and related functions to verify your tests produce the correct results. 29 | } 30 | 31 | func testPerformanceExample() { 32 | // This is an example of a performance test case. 33 | self.measure { 34 | // Put the code you want to measure the time of here. 35 | } 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment the next line to define a global platform for your project 2 | platform :ios, '12.0' 3 | 4 | # Ignore all warnings from all pods 5 | inhibit_all_warnings! 6 | 7 | # Basic 8 | def basic_pods 9 | pod 'Alamofire', '~> 4.4' 10 | pod 'CodableAlamofire' 11 | pod 'ColorCompatibility' 12 | # pod 'SwiftyBeaver' 13 | pod 'RxSwift', '~> 5.0' 14 | pod 'RxCocoa', '~> 5.0' 15 | pod 'RxSwiftExt', '~> 5.0' 16 | pod 'RxDataSources', '~> 4.0' 17 | end 18 | 19 | # Testing 20 | def test_pods 21 | pod 'RxBlocking', '~> 5.0' 22 | pod 'RxTest', '~> 5.0' 23 | end 24 | 25 | target 'MVVMC-SplitViewController' do 26 | # Comment the next line if you're not using Swift and don't want to use dynamic frameworks 27 | use_frameworks! 28 | 29 | # Pods for MVVMC-SplitViewController 30 | basic_pods 31 | 32 | target 'MVVMC-SplitViewControllerTests' do 33 | inherit! :search_paths 34 | # Pods for testing 35 | # test_pods 36 | end 37 | 38 | post_install do |installer| 39 | installer.pods_project.targets.each do |target| 40 | 41 | # Fix RxSwift error 42 | # https://stackoverflow.com/a/75729977/4472195 43 | target.build_configurations.each do |config| 44 | config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' 45 | end 46 | 47 | # Enable RxSwift.Resources for debugging 48 | if target.name == 'RxSwift' 49 | target.build_configurations.each do |config| 50 | if config.name == 'Debug' 51 | config.build_settings['OTHER_SWIFT_FLAGS'] ||= ['-D', 'TRACE_RESOURCES'] 52 | end 53 | end 54 | end 55 | end 56 | end 57 | 58 | end 59 | -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Alamofire (4.9.1) 3 | - CodableAlamofire (1.1.2): 4 | - Alamofire (~> 4.0) 5 | - ColorCompatibility (1.0.0) 6 | - Differentiator (4.0.1) 7 | - RxCocoa (5.1.1): 8 | - RxRelay (~> 5) 9 | - RxSwift (~> 5) 10 | - RxDataSources (4.0.1): 11 | - Differentiator (~> 4.0) 12 | - RxCocoa (~> 5.0) 13 | - RxSwift (~> 5.0) 14 | - RxRelay (5.1.1): 15 | - RxSwift (~> 5) 16 | - RxSwift (5.1.1) 17 | - RxSwiftExt (5.2.0): 18 | - RxSwiftExt/Core (= 5.2.0) 19 | - RxSwiftExt/RxCocoa (= 5.2.0) 20 | - RxSwiftExt/Core (5.2.0): 21 | - RxSwift (~> 5.0) 22 | - RxSwiftExt/RxCocoa (5.2.0): 23 | - RxCocoa (~> 5.0) 24 | - RxSwiftExt/Core 25 | 26 | DEPENDENCIES: 27 | - Alamofire (~> 4.4) 28 | - CodableAlamofire 29 | - ColorCompatibility 30 | - RxCocoa (~> 5.0) 31 | - RxDataSources (~> 4.0) 32 | - RxSwift (~> 5.0) 33 | - RxSwiftExt (~> 5.0) 34 | 35 | SPEC REPOS: 36 | trunk: 37 | - Alamofire 38 | - CodableAlamofire 39 | - ColorCompatibility 40 | - Differentiator 41 | - RxCocoa 42 | - RxDataSources 43 | - RxRelay 44 | - RxSwift 45 | - RxSwiftExt 46 | 47 | SPEC CHECKSUMS: 48 | Alamofire: 85e8a02c69d6020a0d734f6054870d7ecb75cf18 49 | CodableAlamofire: b7adfb2e5e980392014b4b3f3b8b62c01b9de4aa 50 | ColorCompatibility: 2762fea4195ed88bfb15eeca3ca5de73bb4a9380 51 | Differentiator: 886080237d9f87f322641dedbc5be257061b0602 52 | RxCocoa: 32065309a38d29b5b0db858819b5bf9ef038b601 53 | RxDataSources: efee07fa4de48477eca0a4611e6d11e2da9c1114 54 | RxRelay: d77f7d771495f43c556cbc43eebd1bb54d01e8e9 55 | RxSwift: 81470a2074fa8780320ea5fe4102807cb7118178 56 | RxSwiftExt: 4ca80336f43c28f11a2825cdd2fc61dd6c044697 57 | 58 | PODFILE CHECKSUM: 3c1c308934ce6e7a3e045736a55f2d602d3ead96 59 | 60 | COCOAPODS: 1.13.0 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MVVMC-SplitViewController 2 | 3 | ### Description 4 | Example project with UITabBarController inside UISplitViewController using RxSwift and MVVM-C architecture. 5 | 6 | Loads example data from [JSONPlaceholder](https://jsonplaceholder.typicode.com/) 7 | 8 | 9 | ### Coordinators 10 | 11 | ![](./Images/Coordinators.png) 12 | 13 | 14 | ### Screenshots 15 | 16 | ![](./Images/iPad_1.png) 17 | 18 | ![](./Images/iPad_2.png) 19 | 20 | ### Requirements 21 | 22 | - Xcode 12 23 | - Swift 5 24 | - iOS 12 25 | 26 | ### Links 27 | 28 | - [Coordinators Redux](http://khanlou.com/2015/10/coordinators-redux/) 29 | - [Reactive MVVM](http://www.thomasvisser.me/2017/02/09/mvvm-rx/) 30 | - [Taming Great Complexity: MVVM, Coordinators and RxSwift](https://blog.uptech.team/taming-great-complexity-mvvm-coordinators-and-rxswift-8daf8a76e7fd) 31 | - [CleanArchitectureRxSwift](https://github.com/sergdort/CleanArchitectureRxSwift) 32 | --------------------------------------------------------------------------------