├── .gitignore ├── MVCWithSugar.pdf ├── MVCWithSugar.playground ├── Contents.swift ├── Sources │ ├── Container │ │ ├── MessageViewController.swift │ │ └── StateViewController.swift │ ├── Extensions │ │ └── UIViewController+Child.swift │ ├── Flow │ │ ├── RegionDetailViewController.swift │ │ └── RegionsFlowController.swift │ └── Generic │ │ ├── ContainerCollectionViewCell.swift │ │ ├── GenericCollectionViewController.swift │ │ ├── RegionView.swift │ │ └── Regions.swift └── contents.xcplayground └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __MACOSX 3 | *.pbxuser 4 | !default.pbxuser 5 | *.mode1v3 6 | !default.mode1v3 7 | *.mode2v3 8 | !default.mode2v3 9 | *.perspectivev3 10 | !default.perspectivev3 11 | *.xcworkspace 12 | !default.xcworkspace 13 | xcuserdata 14 | profile 15 | *.moved-aside 16 | DerivedData 17 | .idea/ 18 | Crashlytics.sh 19 | generatechangelog.sh 20 | Pods/ 21 | Carthage 22 | Provisioning 23 | Crashlytics.sh -------------------------------------------------------------------------------- /MVCWithSugar.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/mvcwithsugar/ca9af9075f5cf21b727eb9a77beaaccc293556c6/MVCWithSugar.pdf -------------------------------------------------------------------------------- /MVCWithSugar.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import PlaygroundSupport 3 | 4 | // CONTAINER 5 | 6 | func loadContent(with completion: @escaping (Result<[String], Error>) -> Void) { 7 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 8 | completion(.success([])) 9 | } 10 | } 11 | 12 | final class ContentViewController: UIViewController { 13 | init(content: [String]) { 14 | super.init(nibName: nil, bundle: nil) 15 | } 16 | required init?(coder: NSCoder) { 17 | fatalError() 18 | } 19 | } 20 | 21 | let stateController = StateViewController() 22 | stateController.title = "Container" 23 | 24 | loadContent { result in 25 | switch result { 26 | case .success(let content): 27 | if content.isEmpty { 28 | stateController.state = .empty(message: "There is no content here, this is an empty message.") 29 | } else { 30 | stateController.state = .content(controller: ContentViewController(content: content)) 31 | } 32 | case .failure(let error): 33 | stateController.state = .error(message: error.localizedDescription) 34 | } 35 | } 36 | 37 | // GENERIC CONTROLLER 38 | 39 | let genericController = GenericCollectionViewController(viewType: RegionView.self) 40 | 41 | genericController.numberOfItems = { regions.count } 42 | genericController.configureView = { $1.label.text = regions[$0.item].name } 43 | genericController.didSelectView = { i, _ in print(regions[i.item]) } 44 | genericController.title = "Generic" 45 | 46 | // FLOW CONTROLLER 47 | 48 | let flowController = RegionsFlowController() 49 | flowController.title = "Flow" 50 | 51 | let tabController = UITabBarController() 52 | 53 | tabController.setViewControllers([stateController, genericController, flowController], animated: false) 54 | 55 | PlaygroundPage.current.liveView = tabController 56 | -------------------------------------------------------------------------------- /MVCWithSugar.playground/Sources/Container/MessageViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class MessageViewController: UIViewController { 4 | 5 | public init(message: String = "", showSpinner: Bool) { 6 | super.init(nibName: nil, bundle: nil) 7 | 8 | label.text = message 9 | spinner.isHidden = !showSpinner 10 | } 11 | 12 | public required init?(coder: NSCoder) { 13 | fatalError() 14 | } 15 | 16 | private lazy var spinner: UIActivityIndicatorView = { 17 | UIActivityIndicatorView(style: .medium) 18 | }() 19 | 20 | private lazy var label: UILabel = { 21 | let l = UILabel() 22 | 23 | l.textAlignment = .center 24 | l.numberOfLines = 0 25 | l.lineBreakMode = .byTruncatingTail 26 | l.allowsDefaultTighteningForTruncation = true 27 | l.font = UIFont.systemFont(ofSize: 14, weight: .medium) 28 | l.textColor = .secondaryLabel 29 | 30 | return l 31 | }() 32 | 33 | private lazy var stackView: UIStackView = { 34 | let v = UIStackView(arrangedSubviews: [self.spinner, self.label]) 35 | 36 | v.axis = .vertical 37 | v.spacing = 2 38 | v.translatesAutoresizingMaskIntoConstraints = false 39 | v.backgroundColor = .green 40 | 41 | return v 42 | }() 43 | 44 | public override func loadView() { 45 | view = UIView() 46 | view.backgroundColor = .systemBackground 47 | 48 | view.addSubview(stackView) 49 | 50 | NSLayoutConstraint.activate([ 51 | stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), 52 | stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), 53 | stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor) 54 | ]) 55 | 56 | stackView.axis = .vertical 57 | } 58 | 59 | public override func viewWillAppear(_ animated: Bool) { 60 | super.viewWillAppear(animated) 61 | 62 | if !spinner.isHidden { spinner.startAnimating() } 63 | } 64 | 65 | public override func viewWillDisappear(_ animated: Bool) { 66 | super.viewWillDisappear(animated) 67 | 68 | if !spinner.isHidden { spinner.stopAnimating() } 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /MVCWithSugar.playground/Sources/Container/StateViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class StateViewController: UIViewController { 4 | 5 | public enum State { 6 | case loading(message: String) 7 | case content(controller: UIViewController) 8 | case error(message: String) 9 | case empty(message: String) 10 | } 11 | 12 | public var state: State = .loading(message: "Loading") { 13 | didSet { 14 | applyState() 15 | } 16 | } 17 | 18 | private var contentController: UIViewController? { 19 | didSet { 20 | guard contentController != oldValue else { return } 21 | 22 | swapContent(newValue: contentController, oldValue: oldValue) 23 | } 24 | } 25 | 26 | private func viewController(for state: State) -> UIViewController { 27 | switch state { 28 | case .loading(let message): 29 | return MessageViewController(message: message, showSpinner: true) 30 | case .empty(let message), .error(let message): 31 | return MessageViewController(message: message, showSpinner: false) 32 | case .content(let controller): 33 | return controller 34 | } 35 | } 36 | 37 | private func applyState() { 38 | contentController = viewController(for: state) 39 | } 40 | 41 | private func swapContent(newValue: UIViewController?, oldValue: UIViewController?) { 42 | oldValue?.view.removeFromSuperview() 43 | oldValue?.removeFromParent() 44 | oldValue?.didMove(toParent: nil) 45 | 46 | guard let controller = newValue else { return } 47 | 48 | install(controller) 49 | } 50 | 51 | public override func loadView() { 52 | view = UIView() 53 | 54 | applyState() 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /MVCWithSugar.playground/Sources/Extensions/UIViewController+Child.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public extension UIViewController { 4 | func install(_ child: UIViewController) { 5 | addChild(child) 6 | 7 | child.view.translatesAutoresizingMaskIntoConstraints = false 8 | view.addSubview(child.view) 9 | 10 | NSLayoutConstraint.activate([ 11 | child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), 12 | child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), 13 | child.view.topAnchor.constraint(equalTo: view.topAnchor), 14 | child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) 15 | ]) 16 | 17 | child.didMove(toParent: self) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /MVCWithSugar.playground/Sources/Flow/RegionDetailViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class RegionDetailViewController: UIViewController { 4 | 5 | public let region: Region 6 | 7 | public init(region: Region) { 8 | self.region = region 9 | 10 | super.init(nibName: nil, bundle: nil) 11 | } 12 | 13 | public required init?(coder: NSCoder) { 14 | fatalError() 15 | } 16 | 17 | private lazy var regionView: RegionView = { 18 | let v = RegionView() 19 | 20 | v.label.text = self.region.name 21 | v.translatesAutoresizingMaskIntoConstraints = false 22 | 23 | return v 24 | }() 25 | 26 | public override func loadView() { 27 | view = UIView() 28 | view.backgroundColor = .white 29 | 30 | view.addSubview(regionView) 31 | NSLayoutConstraint.activate([ 32 | regionView.centerYAnchor.constraint(equalTo: view.centerYAnchor), 33 | regionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8), 34 | regionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8), 35 | ]) 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /MVCWithSugar.playground/Sources/Flow/RegionsFlowController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class RegionsFlowController: UIViewController { 4 | 5 | private typealias RegionsCollectionController = GenericCollectionViewController> 6 | 7 | private lazy var ownedNavigationController: UINavigationController = { 8 | UINavigationController(rootViewController: self.stateController) 9 | }() 10 | 11 | private lazy var stateController: StateViewController = { 12 | let c = StateViewController() 13 | c.title = "Regions" 14 | return c 15 | }() 16 | 17 | private lazy var regionsCollectionController: RegionsCollectionController = { 18 | let c = RegionsCollectionController(viewType: RegionView.self) 19 | 20 | c.numberOfItems = { regions.count } 21 | c.configureView = { $1.label.text = regions[$0.item].name } 22 | c.didSelectView = { [weak self] indexPath, _ in 23 | self?.showDetail(for: regions[indexPath.item]) 24 | } 25 | 26 | return c 27 | }() 28 | 29 | private func loadRegions() { 30 | stateController.state = .loading(message: "Loading regions") 31 | 32 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 33 | self.stateController.state = .content(controller: self.regionsCollectionController) 34 | } 35 | } 36 | 37 | public override func loadView() { 38 | view = UIView() 39 | install(ownedNavigationController) 40 | } 41 | 42 | public override func viewDidAppear(_ animated: Bool) { 43 | super.viewDidAppear(animated) 44 | 45 | loadRegions() 46 | } 47 | 48 | private func showDetail(for region: Region) { 49 | let controller = RegionDetailViewController(region: region) 50 | controller.title = "Region Detail" 51 | ownedNavigationController.pushViewController(controller, animated: true) 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /MVCWithSugar.playground/Sources/Generic/ContainerCollectionViewCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class ContainerCollectionViewCell: UICollectionViewCell { 4 | 5 | public lazy var view: V = { 6 | return V() 7 | }() 8 | 9 | public override init(frame: CGRect) { 10 | super.init(frame: frame) 11 | 12 | setup() 13 | } 14 | 15 | public required init?(coder aDecoder: NSCoder) { 16 | fatalError() 17 | } 18 | 19 | private func setup() { 20 | view.translatesAutoresizingMaskIntoConstraints = false 21 | contentView.addSubview(view) 22 | 23 | NSLayoutConstraint.activate([ 24 | view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), 25 | view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), 26 | view.topAnchor.constraint(equalTo: contentView.topAnchor), 27 | view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) 28 | ]) 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /MVCWithSugar.playground/Sources/Generic/GenericCollectionViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | fileprivate func makeDefaultLayout() -> UICollectionViewFlowLayout { 4 | let l = UICollectionViewFlowLayout() 5 | let margin = CGFloat(16) 6 | 7 | l.scrollDirection = .vertical 8 | l.estimatedItemSize = CGSize(width: UIScreen.main.bounds.width, height: 100) 9 | l.minimumLineSpacing = 4 10 | l.sectionInset = UIEdgeInsets(top: margin, left: 0, bottom: 0, right: 0) 11 | 12 | return l 13 | } 14 | 15 | public final class GenericCollectionViewController>: UICollectionViewController { 16 | 17 | public init(viewType: V.Type) { 18 | super.init(collectionViewLayout: makeDefaultLayout()) 19 | } 20 | 21 | public required init?(coder: NSCoder) { 22 | fatalError() 23 | } 24 | 25 | public var numberOfItems: () -> Int = { 0 } { 26 | didSet { 27 | collectionView?.reloadData() 28 | } 29 | } 30 | 31 | public var configureView: (IndexPath, V) -> () = { _, _ in } { 32 | didSet { 33 | collectionView?.reloadData() 34 | } 35 | } 36 | 37 | public var didSelectView: (IndexPath, V) -> () = { _, _ in } 38 | 39 | public override func viewDidLoad() { 40 | super.viewDidLoad() 41 | 42 | collectionView?.backgroundColor = .systemBackground 43 | collectionView?.register(C.self, forCellWithReuseIdentifier: "cell") 44 | } 45 | 46 | public override func numberOfSections(in collectionView: UICollectionView) -> Int { 47 | 1 48 | } 49 | 50 | public override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 51 | numberOfItems() 52 | } 53 | 54 | public override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 55 | guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as? C else { 56 | fatalError("Unexpected cell type dequeued from collection view") 57 | } 58 | 59 | configureView(indexPath, cell.view) 60 | 61 | return cell 62 | } 63 | 64 | public override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 65 | guard let cell = collectionView.cellForItem(at: indexPath) as? C else { return } 66 | 67 | didSelectView(indexPath, cell.view) 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /MVCWithSugar.playground/Sources/Generic/RegionView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class RegionView: UIView { 4 | 5 | public private(set) lazy var label: UILabel = { 6 | let l = UILabel() 7 | 8 | l.font = UIFont.systemFont(ofSize: 16, weight: .regular) 9 | l.textColor = .label 10 | l.numberOfLines = 1 11 | l.lineBreakMode = .byTruncatingTail 12 | l.allowsDefaultTighteningForTruncation = true 13 | l.minimumScaleFactor = 0.7 14 | l.adjustsFontSizeToFitWidth = true 15 | l.translatesAutoresizingMaskIntoConstraints = false 16 | 17 | return l 18 | }() 19 | 20 | public override init(frame: CGRect) { 21 | super.init(frame: frame) 22 | 23 | setup() 24 | } 25 | 26 | public required init?(coder: NSCoder) { 27 | fatalError() 28 | } 29 | 30 | private func setup() { 31 | addSubview(label) 32 | 33 | clipsToBounds = true 34 | backgroundColor = .systemBackground 35 | layer.cornerRadius = 8 36 | layer.cornerCurve = .continuous 37 | layer.borderColor = UIColor.lightGray.cgColor 38 | layer.borderWidth = 1/UIScreen.main.scale 39 | 40 | NSLayoutConstraint.activate([ 41 | heightAnchor.constraint(equalToConstant: 48), 42 | label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), 43 | label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), 44 | label.centerYAnchor.constraint(equalTo: centerYAnchor) 45 | ]) 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /MVCWithSugar.playground/Sources/Generic/Regions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Region: Hashable { 4 | public let name: String 5 | } 6 | 7 | public let regions = [ 8 | Region(name: "Auvergne-Rhône-Alpes"), 9 | Region(name: "Bourgogne-Franche-Comté"), 10 | Region(name: "Bretagne"), 11 | Region(name: "Centre-Val de Loire"), 12 | Region(name: "Corse"), 13 | Region(name: "Grand Est"), 14 | Region(name: "Hauts-de-France"), 15 | Region(name: "Île-de-France"), 16 | Region(name: "Normandie"), 17 | Region(name: "Nouvelle-Aquitaine"), 18 | Region(name: "Occitanie"), 19 | Region(name: "Pays de la Loire"), 20 | Region(name: "Provence-Alpes-Côte d'Azur") 21 | ] 22 | -------------------------------------------------------------------------------- /MVCWithSugar.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MVC with sugar 2 | 3 | Companion Playground for my talk at dotSwift 2020. 4 | 5 | [Slides](./MVCWithSugar.pdf) 6 | 7 | [Video](https://www.dotconferences.com/2020/02/guilherme-rambo-mvc-many-view-controllers) 8 | 9 | [Post](https://rambo.codes/posts/2020-02-20-mvc-with-sugar) 10 | 11 | [rambo.codes](https://rambo.codes) --------------------------------------------------------------------------------