├── demo.gif ├── ModernMVVM ├── SupportingFIles │ ├── Assets.xcassets │ │ ├── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ └── Info.plist ├── Extensions │ └── View+Ext.swift ├── API │ ├── Agent.swift │ └── MoviesAPI.swift ├── Controls │ ├── Spinner.swift │ └── AsyncImage │ │ ├── ImageCache.swift │ │ ├── AsyncImage.swift │ │ └── ImageLoader.swift ├── Feedback │ ├── Feedback.swift │ └── System.swift ├── App │ ├── AppDelegate.swift │ └── SceneDelegate.swift └── Features │ ├── MoviesList │ ├── MoviesListView.swift │ └── MovieListViewModel.swift │ └── MovieDetails │ ├── MovieDetailView.swift │ └── MovieDetailViewModel.swift ├── ModernMVVM.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── project.pbxproj ├── README.md ├── LICENSE └── .gitignore /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/V8tr/ModernMVVM/HEAD/demo.gif -------------------------------------------------------------------------------- /ModernMVVM/SupportingFIles/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ModernMVVM.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ModernMVVM/Extensions/View+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Ext.swift 3 | // ModernMVVM 4 | // 5 | // Created by Vadim Bulavin on 3/20/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | extension View { 12 | func eraseToAnyView() -> AnyView { AnyView(self) } 13 | } 14 | -------------------------------------------------------------------------------- /ModernMVVM.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ModernMVVM/API/Agent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Agent.swift 3 | // ModernMVVM 4 | // 5 | // Created by Vadym Bulavin on 2/20/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | struct Agent { 13 | func run(_ request: URLRequest) -> AnyPublisher { 14 | return URLSession.shared 15 | .dataTaskPublisher(for: request) 16 | .map { $0.data } 17 | .handleEvents(receiveOutput: { print(NSString(data: $0, encoding: String.Encoding.utf8.rawValue)!) }) 18 | .decode(type: T.self, decoder: JSONDecoder()) 19 | .receive(on: DispatchQueue.main) 20 | .eraseToAnyPublisher() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Article related to this project 2 | 3 | - [Modern MVVM iOS App Architecture with Combine and SwiftUI](https://www.vadimbulavin.com/modern-mvvm-ios-app-architecture-with-combine-and-swiftui/). 4 | 5 | --- 6 | 7 | # ModernMVVM 8 | 9 | A sample project demonstrating the modern approach to building iOS apps with the MVVM architecture pattern, Combine and SwiftUI frameworks. The app follows unidirectional data flow and shows how to represent the UI as a reactive finite-state machine using [CombineFeedback](https://github.com/sergdort/CombineFeedback). 10 | 11 |

12 | Modern MVVM iOS App Architecture with Combine and SwiftUI 13 |

14 | -------------------------------------------------------------------------------- /ModernMVVM/Controls/Spinner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Spinner.swift 3 | // ModernMVVM 4 | // 5 | // Created by Vadym Bulavin on 2/18/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UIKit 11 | 12 | struct Spinner: UIViewRepresentable { 13 | let isAnimating: Bool 14 | let style: UIActivityIndicatorView.Style 15 | 16 | func makeUIView(context: Context) -> UIActivityIndicatorView { 17 | let spinner = UIActivityIndicatorView(style: style) 18 | spinner.hidesWhenStopped = true 19 | return spinner 20 | } 21 | 22 | func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) { 23 | isAnimating ? uiView.startAnimating() : uiView.stopAnimating() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ModernMVVM/Feedback/Feedback.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Feedback.swift 3 | // ModernMVVMList 4 | // 5 | // Created by Vadim Bulavin on 3/17/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | struct Feedback { 13 | let run: (AnyPublisher) -> AnyPublisher 14 | } 15 | 16 | extension Feedback { 17 | init(effects: @escaping (State) -> Effect) where Effect.Output == Event, Effect.Failure == Never { 18 | self.run = { state -> AnyPublisher in 19 | state 20 | .map { effects($0) } 21 | .switchToLatest() 22 | .eraseToAnyPublisher() 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ModernMVVM/App/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // ModernMVVM 4 | // 5 | // Created by Vadym Bulavin on 2/13/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 15 | URLCache.shared.removeAllCachedResponses() 16 | return true 17 | } 18 | 19 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 20 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ModernMVVM/App/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // ModernMVVM 4 | // 5 | // Created by Vadym Bulavin on 2/13/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | 12 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 13 | var window: UIWindow? 14 | 15 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 16 | if let windowScene = scene as? UIWindowScene { 17 | let window = UIWindow(windowScene: windowScene) 18 | let rootView = MoviesListView(viewModel: MoviesListViewModel()) 19 | window.rootViewController = UIHostingController(rootView: rootView) 20 | self.window = window 21 | window.makeKeyAndVisible() 22 | } 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /ModernMVVM/Controls/AsyncImage/ImageCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageCache.swift 3 | // ModernMVVM 4 | // 5 | // Created by Vadym Bulavin on 2/19/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | 12 | protocol ImageCache { 13 | subscript(_ url: URL) -> UIImage? { get set } 14 | } 15 | 16 | struct TemporaryImageCache: ImageCache { 17 | private let cache = NSCache() 18 | 19 | subscript(_ key: URL) -> UIImage? { 20 | get { cache.object(forKey: key as NSURL) } 21 | set { newValue == nil ? cache.removeObject(forKey: key as NSURL) : cache.setObject(newValue!, forKey: key as NSURL) } 22 | } 23 | } 24 | 25 | struct ImageCacheKey: EnvironmentKey { 26 | static let defaultValue: ImageCache = TemporaryImageCache() 27 | } 28 | 29 | extension EnvironmentValues { 30 | var imageCache: ImageCache { 31 | get { self[ImageCacheKey.self] } 32 | set { self[ImageCacheKey.self] = newValue } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ModernMVVM/Feedback/System.swift: -------------------------------------------------------------------------------- 1 | // 2 | // System.swift 3 | // ModernMVVMList 4 | // 5 | // Created by Vadim Bulavin on 3/17/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import Combine 10 | 11 | extension Publishers { 12 | 13 | static func system( 14 | initial: State, 15 | reduce: @escaping (State, Event) -> State, 16 | scheduler: Scheduler, 17 | feedbacks: [Feedback] 18 | ) -> AnyPublisher { 19 | 20 | let state = CurrentValueSubject(initial) 21 | 22 | let events = feedbacks.map { feedback in feedback.run(state.eraseToAnyPublisher()) } 23 | 24 | return Deferred { 25 | Publishers.MergeMany(events) 26 | .receive(on: scheduler) 27 | .scan(initial, reduce) 28 | .handleEvents(receiveOutput: state.send) 29 | .receive(on: scheduler) 30 | .prepend(initial) 31 | .eraseToAnyPublisher() 32 | } 33 | .eraseToAnyPublisher() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ModernMVVM/Controls/AsyncImage/AsyncImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModernMVVM.swift 3 | // ModernMVVM 4 | // 5 | // Created by Vadym Bulavin on 2/13/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct AsyncImage: View { 12 | @ObservedObject private var loader: ImageLoader 13 | private let placeholder: Placeholder? 14 | private let configuration: (Image) -> Image 15 | 16 | init(url: URL, cache: ImageCache? = nil, placeholder: Placeholder? = nil, configuration: @escaping (Image) -> Image = { $0 }) { 17 | loader = ImageLoader(url: url, cache: cache) 18 | self.placeholder = placeholder 19 | self.configuration = configuration 20 | } 21 | 22 | var body: some View { 23 | image 24 | .onAppear(perform: loader.load) 25 | .onDisappear(perform: loader.cancel) 26 | } 27 | 28 | private var image: some View { 29 | Group { 30 | if loader.image != nil { 31 | configuration(Image(uiImage: loader.image!)) 32 | } else { 33 | placeholder 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /ModernMVVM/SupportingFIles/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 | -------------------------------------------------------------------------------- /ModernMVVM/Controls/AsyncImage/ImageLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageLoader.swift 3 | // ModernMVVM 4 | // 5 | // Created by Vadym Bulavin on 2/13/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import UIKit 11 | 12 | class ImageLoader: ObservableObject { 13 | @Published var image: UIImage? 14 | 15 | private(set) var isLoading = false 16 | 17 | private let url: URL 18 | private var cache: ImageCache? 19 | private var cancellable: AnyCancellable? 20 | 21 | private static let imageProcessingQueue = DispatchQueue(label: "image-processing") 22 | 23 | init(url: URL, cache: ImageCache? = nil) { 24 | self.url = url 25 | self.cache = cache 26 | } 27 | 28 | deinit { 29 | cancellable?.cancel() 30 | } 31 | 32 | func load() { 33 | guard !isLoading else { return } 34 | 35 | if let image = cache?[url] { 36 | self.image = image 37 | return 38 | } 39 | 40 | cancellable = URLSession.shared.dataTaskPublisher(for: url) 41 | .map { UIImage(data: $0.data) } 42 | .replaceError(with: nil) 43 | .handleEvents(receiveSubscription: { [weak self] _ in self?.onStart() }, 44 | receiveOutput: { [weak self] in self?.cache($0) }, 45 | receiveCompletion: { [weak self] _ in self?.onFinish() }, 46 | receiveCancel: { [weak self] in self?.onFinish() }) 47 | .subscribe(on: Self.imageProcessingQueue) 48 | .receive(on: DispatchQueue.main) 49 | .assign(to: \.image, on: self) 50 | } 51 | 52 | func cancel() { 53 | cancellable?.cancel() 54 | } 55 | 56 | private func onStart() { 57 | isLoading = true 58 | } 59 | 60 | private func onFinish() { 61 | isLoading = false 62 | } 63 | 64 | private func cache(_ image: UIImage?) { 65 | image.map { cache?[url] = $0 } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /ModernMVVM/SupportingFIles/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /ModernMVVM/SupportingFIles/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 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | UIInterfaceOrientationLandscapeLeft 50 | UIInterfaceOrientationLandscapeRight 51 | 52 | UISupportedInterfaceOrientations~ipad 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationPortraitUpsideDown 56 | UIInterfaceOrientationLandscapeLeft 57 | UIInterfaceOrientationLandscapeRight 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ -------------------------------------------------------------------------------- /ModernMVVM/Features/MoviesList/MoviesListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesListView.swift 3 | // ModernMVVM 4 | // 5 | // Created by Vadym Bulavin on 2/20/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import SwiftUI 11 | 12 | struct MoviesListView: View { 13 | @ObservedObject var viewModel: MoviesListViewModel 14 | 15 | var body: some View { 16 | NavigationView { 17 | content 18 | .navigationBarTitle("Trending Movies") 19 | } 20 | .onAppear { self.viewModel.send(event: .onAppear) } 21 | } 22 | 23 | private var content: some View { 24 | switch viewModel.state { 25 | case .idle: 26 | return Color.clear.eraseToAnyView() 27 | case .loading: 28 | return Spinner(isAnimating: true, style: .large).eraseToAnyView() 29 | case .error(let error): 30 | return Text(error.localizedDescription).eraseToAnyView() 31 | case .loaded(let movies): 32 | return list(of: movies).eraseToAnyView() 33 | } 34 | } 35 | 36 | private func list(of movies: [MoviesListViewModel.ListItem]) -> some View { 37 | return List(movies) { movie in 38 | NavigationLink( 39 | destination: MovieDetailView(viewModel: MovieDetailViewModel(movieID: movie.id)), 40 | label: { MovieListItemView(movie: movie) } 41 | ) 42 | } 43 | } 44 | } 45 | 46 | struct MovieListItemView: View { 47 | let movie: MoviesListViewModel.ListItem 48 | @Environment(\.imageCache) var cache: ImageCache 49 | 50 | var body: some View { 51 | VStack { 52 | title 53 | poster 54 | } 55 | } 56 | 57 | private var title: some View { 58 | Text(movie.title) 59 | .font(.title) 60 | .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center) 61 | } 62 | 63 | private var poster: some View { 64 | movie.poster.map { url in 65 | AsyncImage( 66 | url: url, 67 | cache: cache, 68 | placeholder: spinner, 69 | configuration: { $0.resizable().renderingMode(.original) } 70 | ) 71 | } 72 | .aspectRatio(contentMode: .fit) 73 | .frame(idealHeight: UIScreen.main.bounds.width / 2 * 3) // 2:3 aspect ratio 74 | } 75 | 76 | private var spinner: some View { 77 | Spinner(isAnimating: true, style: .medium) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /ModernMVVM/API/MoviesAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesAPI.swift 3 | // ModernMVVM 4 | // 5 | // Created by Vadym Bulavin on 2/20/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | enum MoviesAPI { 13 | static let imageBase = URL(string: "https://image.tmdb.org/t/p/original/")! 14 | 15 | private static let base = URL(string: "https://api.themoviedb.org/3")! 16 | private static let apiKey = "efb6cac7ab6a05e4522f6b4d1ad0fa43" 17 | private static let agent = Agent() 18 | 19 | static func trending() -> AnyPublisher, Error> { 20 | let request = URLComponents(url: base.appendingPathComponent("trending/movie/week"), resolvingAgainstBaseURL: true)? 21 | .addingApiKey(apiKey) 22 | .request 23 | return agent.run(request!) 24 | } 25 | 26 | static func movieDetail(id: Int) -> AnyPublisher { 27 | let request = URLComponents(url: base.appendingPathComponent("movie/\(id)"), resolvingAgainstBaseURL: true)? 28 | .addingApiKey(apiKey) 29 | .request 30 | return agent.run(request!) 31 | } 32 | } 33 | 34 | private extension URLComponents { 35 | func addingApiKey(_ apiKey: String) -> URLComponents { 36 | var copy = self 37 | copy.queryItems = [URLQueryItem(name: "api_key", value: apiKey)] 38 | return copy 39 | } 40 | 41 | var request: URLRequest? { 42 | url.map { URLRequest.init(url: $0) } 43 | } 44 | } 45 | 46 | // MARK: - DTOs 47 | 48 | struct MovieDTO: Codable { 49 | let id: Int 50 | let title: String 51 | let poster_path: String? 52 | 53 | var poster: URL? { poster_path.map { MoviesAPI.imageBase.appendingPathComponent($0) } } 54 | } 55 | 56 | struct MovieDetailDTO: Codable { 57 | let id: Int 58 | let title: String 59 | let overview: String? 60 | let poster_path: String? 61 | let vote_average: Double? 62 | let genres: [GenreDTO] 63 | let release_date: String? 64 | let runtime: Int? 65 | let spoken_languages: [LanguageDTO] 66 | 67 | var poster: URL? { poster_path.map { MoviesAPI.imageBase.appendingPathComponent($0) } } 68 | 69 | struct GenreDTO: Codable { 70 | let id: Int 71 | let name: String 72 | } 73 | 74 | struct LanguageDTO: Codable { 75 | let name: String 76 | } 77 | } 78 | 79 | struct PageDTO: Codable { 80 | let page: Int? 81 | let total_results: Int? 82 | let total_pages: Int? 83 | let results: [T] 84 | } 85 | -------------------------------------------------------------------------------- /ModernMVVM/Features/MovieDetails/MovieDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MovieDetailView.swift 3 | // ModernMVVMList 4 | // 5 | // Created by Vadim Bulavin on 3/18/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | struct MovieDetailView: View { 13 | @ObservedObject var viewModel: MovieDetailViewModel 14 | @Environment(\.imageCache) var cache: ImageCache 15 | 16 | var body: some View { 17 | content 18 | .onAppear { self.viewModel.send(event: .onAppear) } 19 | } 20 | 21 | private var content: some View { 22 | switch viewModel.state { 23 | case .idle: 24 | return Color.clear.eraseToAnyView() 25 | case .loading: 26 | return spinner.eraseToAnyView() 27 | case .error(let error): 28 | return Text(error.localizedDescription).eraseToAnyView() 29 | case .loaded(let movie): 30 | return self.movie(movie).eraseToAnyView() 31 | } 32 | } 33 | 34 | private func movie(_ movie: MovieDetailViewModel.MovieDetail) -> some View { 35 | ScrollView { 36 | VStack { 37 | fillWidth 38 | 39 | Text(movie.title) 40 | .font(.largeTitle) 41 | .multilineTextAlignment(.center) 42 | 43 | Divider() 44 | 45 | HStack { 46 | Text(movie.releasedAt) 47 | Text(movie.language) 48 | Text(movie.duration) 49 | } 50 | .font(.subheadline) 51 | 52 | poster(of: movie) 53 | 54 | genres(of: movie) 55 | 56 | Divider() 57 | 58 | movie.rating.map { 59 | Text("⭐️ \(String($0))/10").font(.body) 60 | } 61 | 62 | Divider() 63 | 64 | movie.overview.map { 65 | Text($0).font(.body) 66 | } 67 | } 68 | } 69 | } 70 | 71 | private var fillWidth: some View { 72 | HStack { 73 | Spacer() 74 | } 75 | } 76 | 77 | private func poster(of movie: MovieDetailViewModel.MovieDetail) -> some View { 78 | movie.poster.map { url in 79 | AsyncImage( 80 | url: url, 81 | cache: cache, 82 | placeholder: self.spinner, 83 | configuration: { $0.resizable() } 84 | ) 85 | .aspectRatio(contentMode: .fit) 86 | } 87 | } 88 | 89 | private var spinner: Spinner { Spinner(isAnimating: true, style: .large) } 90 | 91 | private func genres(of movie: MovieDetailViewModel.MovieDetail) -> some View { 92 | HStack { 93 | ForEach(movie.genres, id: \.self) { genre in 94 | Text(genre) 95 | .padding(5) 96 | .border(Color.gray) 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /ModernMVVM/Features/MoviesList/MovieListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MovieListViewModel.swift 3 | // ModernMVVMList 4 | // 5 | // Created by Vadim Bulavin on 3/17/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | final class MoviesListViewModel: ObservableObject { 13 | @Published private(set) var state = State.idle 14 | 15 | private var bag = Set() 16 | 17 | private let input = PassthroughSubject() 18 | 19 | init() { 20 | Publishers.system( 21 | initial: state, 22 | reduce: Self.reduce, 23 | scheduler: RunLoop.main, 24 | feedbacks: [ 25 | Self.whenLoading(), 26 | Self.userInput(input: input.eraseToAnyPublisher()) 27 | ] 28 | ) 29 | .assign(to: \.state, on: self) 30 | .store(in: &bag) 31 | } 32 | 33 | deinit { 34 | bag.removeAll() 35 | } 36 | 37 | func send(event: Event) { 38 | input.send(event) 39 | } 40 | } 41 | 42 | // MARK: - Inner Types 43 | 44 | extension MoviesListViewModel { 45 | enum State { 46 | case idle 47 | case loading 48 | case loaded([ListItem]) 49 | case error(Error) 50 | } 51 | 52 | enum Event { 53 | case onAppear 54 | case onSelectMovie(Int) 55 | case onMoviesLoaded([ListItem]) 56 | case onFailedToLoadMovies(Error) 57 | } 58 | 59 | struct ListItem: Identifiable { 60 | let id: Int 61 | let title: String 62 | let poster: URL? 63 | 64 | init(movie: MovieDTO) { 65 | id = movie.id 66 | title = movie.title 67 | poster = movie.poster 68 | } 69 | } 70 | } 71 | 72 | // MARK: - State Machine 73 | 74 | extension MoviesListViewModel { 75 | static func reduce(_ state: State, _ event: Event) -> State { 76 | switch state { 77 | case .idle: 78 | switch event { 79 | case .onAppear: 80 | return .loading 81 | default: 82 | return state 83 | } 84 | case .loading: 85 | switch event { 86 | case .onFailedToLoadMovies(let error): 87 | return .error(error) 88 | case .onMoviesLoaded(let movies): 89 | return .loaded(movies) 90 | default: 91 | return state 92 | } 93 | case .loaded: 94 | return state 95 | case .error: 96 | return state 97 | } 98 | } 99 | 100 | static func whenLoading() -> Feedback { 101 | Feedback { (state: State) -> AnyPublisher in 102 | guard case .loading = state else { return Empty().eraseToAnyPublisher() } 103 | 104 | return MoviesAPI.trending() 105 | .map { $0.results.map(ListItem.init) } 106 | .map(Event.onMoviesLoaded) 107 | .catch { Just(Event.onFailedToLoadMovies($0)) } 108 | .eraseToAnyPublisher() 109 | } 110 | } 111 | 112 | static func userInput(input: AnyPublisher) -> Feedback { 113 | Feedback { _ in input } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /ModernMVVM/Features/MovieDetails/MovieDetailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MovieDetailViewModel.swift 3 | // ModernMVVMList 4 | // 5 | // Created by Vadim Bulavin on 3/19/20. 6 | // Copyright © 2020 Vadym Bulavin. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | final class MovieDetailViewModel: ObservableObject { 13 | @Published private(set) var state: State 14 | 15 | private var bag = Set() 16 | 17 | private let input = PassthroughSubject() 18 | 19 | init(movieID: Int) { 20 | state = .idle(movieID) 21 | 22 | Publishers.system( 23 | initial: state, 24 | reduce: Self.reduce, 25 | scheduler: RunLoop.main, 26 | feedbacks: [ 27 | Self.whenLoading(), 28 | Self.userInput(input: input.eraseToAnyPublisher()) 29 | ] 30 | ) 31 | .assign(to: \.state, on: self) 32 | .store(in: &bag) 33 | } 34 | 35 | func send(event: Event) { 36 | input.send(event) 37 | } 38 | } 39 | 40 | // MARK: - Inner Types 41 | 42 | extension MovieDetailViewModel { 43 | enum State { 44 | case idle(Int) 45 | case loading(Int) 46 | case loaded(MovieDetail) 47 | case error(Error) 48 | } 49 | 50 | enum Event { 51 | case onAppear 52 | case onLoaded(MovieDetail) 53 | case onFailedToLoad(Error) 54 | } 55 | 56 | struct MovieDetail { 57 | let id: Int 58 | let title: String 59 | let overview: String? 60 | let poster: URL? 61 | let rating: Double? 62 | let duration: String 63 | let genres: [String] 64 | let releasedAt: String 65 | let language: String 66 | 67 | init(movie: MovieDetailDTO) { 68 | id = movie.id 69 | title = movie.title 70 | overview = movie.overview 71 | poster = movie.poster 72 | rating = movie.vote_average 73 | 74 | let formatter = DateComponentsFormatter() 75 | formatter.unitsStyle = .abbreviated 76 | formatter.allowedUnits = [.minute, .hour] 77 | duration = movie.runtime.flatMap { formatter.string(from: TimeInterval($0 * 60)) } ?? "N/A" 78 | 79 | genres = movie.genres.map(\.name) 80 | 81 | releasedAt = movie.release_date ?? "N/A" 82 | 83 | language = movie.spoken_languages.first?.name ?? "N/A" 84 | } 85 | } 86 | } 87 | 88 | // MARK: - State Machine 89 | 90 | extension MovieDetailViewModel { 91 | static func reduce(_ state: State, _ event: Event) -> State { 92 | switch state { 93 | case .idle(let id): 94 | switch event { 95 | case .onAppear: 96 | return .loading(id) 97 | default: 98 | return state 99 | } 100 | case .loading: 101 | switch event { 102 | case .onFailedToLoad(let error): 103 | return .error(error) 104 | case .onLoaded(let movie): 105 | return .loaded(movie) 106 | default: 107 | return state 108 | } 109 | case .loaded: 110 | return state 111 | case .error: 112 | return state 113 | } 114 | } 115 | 116 | static func whenLoading() -> Feedback { 117 | Feedback { (state: State) -> AnyPublisher in 118 | guard case .loading(let id) = state else { return Empty().eraseToAnyPublisher() } 119 | return MoviesAPI.movieDetail(id: id) 120 | .map(MovieDetail.init) 121 | .map(Event.onLoaded) 122 | .catch { Just(Event.onFailedToLoad($0)) } 123 | .eraseToAnyPublisher() 124 | } 125 | } 126 | 127 | static func userInput(input: AnyPublisher) -> Feedback { 128 | Feedback(run: { _ in 129 | return input 130 | }) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /ModernMVVM.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 80365CE1241A7CCE005B59B9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 80365CE0241A7CCE005B59B9 /* Assets.xcassets */; }; 11 | 80365CE7241A7CCE005B59B9 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 80365CE5241A7CCE005B59B9 /* LaunchScreen.storyboard */; }; 12 | 88C9C2342424BD6C004EB00E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C2322424BD6C004EB00E /* AppDelegate.swift */; }; 13 | 88C9C2352424BD6C004EB00E /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C2332424BD6C004EB00E /* SceneDelegate.swift */; }; 14 | 88C9C2392424BD71004EB00E /* Agent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C2372424BD71004EB00E /* Agent.swift */; }; 15 | 88C9C23A2424BD71004EB00E /* MoviesAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C2382424BD71004EB00E /* MoviesAPI.swift */; }; 16 | 88C9C2412424BD7C004EB00E /* Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C23C2424BD7B004EB00E /* Spinner.swift */; }; 17 | 88C9C2422424BD7C004EB00E /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C23E2424BD7B004EB00E /* ImageCache.swift */; }; 18 | 88C9C2432424BD7C004EB00E /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C23F2424BD7B004EB00E /* ImageLoader.swift */; }; 19 | 88C9C2442424BD7C004EB00E /* AsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C2402424BD7B004EB00E /* AsyncImage.swift */; }; 20 | 88C9C24C2424BD7F004EB00E /* MovieDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C2472424BD7F004EB00E /* MovieDetailView.swift */; }; 21 | 88C9C24D2424BD7F004EB00E /* MovieDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C2482424BD7F004EB00E /* MovieDetailViewModel.swift */; }; 22 | 88C9C24E2424BD7F004EB00E /* MovieListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C24A2424BD7F004EB00E /* MovieListViewModel.swift */; }; 23 | 88C9C24F2424BD7F004EB00E /* MoviesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C24B2424BD7F004EB00E /* MoviesListView.swift */; }; 24 | 88C9C2512424BD86004EB00E /* System.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C2502424BD86004EB00E /* System.swift */; }; 25 | 88C9C2532424BD8A004EB00E /* Feedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C2522424BD8A004EB00E /* Feedback.swift */; }; 26 | 88C9C2552424C197004EB00E /* View+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C9C2542424C197004EB00E /* View+Ext.swift */; }; 27 | /* End PBXBuildFile section */ 28 | 29 | /* Begin PBXFileReference section */ 30 | 80365CD7241A7CCB005B59B9 /* ModernMVVM.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ModernMVVM.app; sourceTree = BUILT_PRODUCTS_DIR; }; 31 | 80365CE0241A7CCE005B59B9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 32 | 80365CE6241A7CCE005B59B9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 33 | 80365CE8241A7CCE005B59B9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 34 | 88C9C2322424BD6C004EB00E /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 35 | 88C9C2332424BD6C004EB00E /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 36 | 88C9C2372424BD71004EB00E /* Agent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Agent.swift; sourceTree = ""; }; 37 | 88C9C2382424BD71004EB00E /* MoviesAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoviesAPI.swift; sourceTree = ""; }; 38 | 88C9C23C2424BD7B004EB00E /* Spinner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Spinner.swift; sourceTree = ""; }; 39 | 88C9C23E2424BD7B004EB00E /* ImageCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; 40 | 88C9C23F2424BD7B004EB00E /* ImageLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; 41 | 88C9C2402424BD7B004EB00E /* AsyncImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncImage.swift; sourceTree = ""; }; 42 | 88C9C2472424BD7F004EB00E /* MovieDetailView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MovieDetailView.swift; sourceTree = ""; }; 43 | 88C9C2482424BD7F004EB00E /* MovieDetailViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MovieDetailViewModel.swift; sourceTree = ""; }; 44 | 88C9C24A2424BD7F004EB00E /* MovieListViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MovieListViewModel.swift; sourceTree = ""; }; 45 | 88C9C24B2424BD7F004EB00E /* MoviesListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoviesListView.swift; sourceTree = ""; }; 46 | 88C9C2502424BD86004EB00E /* System.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = System.swift; sourceTree = ""; }; 47 | 88C9C2522424BD8A004EB00E /* Feedback.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Feedback.swift; sourceTree = ""; }; 48 | 88C9C2542424C197004EB00E /* View+Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Ext.swift"; sourceTree = ""; }; 49 | /* End PBXFileReference section */ 50 | 51 | /* Begin PBXFrameworksBuildPhase section */ 52 | 80365CD4241A7CCB005B59B9 /* Frameworks */ = { 53 | isa = PBXFrameworksBuildPhase; 54 | buildActionMask = 2147483647; 55 | files = ( 56 | ); 57 | runOnlyForDeploymentPostprocessing = 0; 58 | }; 59 | /* End PBXFrameworksBuildPhase section */ 60 | 61 | /* Begin PBXGroup section */ 62 | 80365CCE241A7CCB005B59B9 = { 63 | isa = PBXGroup; 64 | children = ( 65 | 80365CD9241A7CCB005B59B9 /* ModernMVVM */, 66 | 80365CD8241A7CCB005B59B9 /* Products */, 67 | ); 68 | sourceTree = ""; 69 | }; 70 | 80365CD8241A7CCB005B59B9 /* Products */ = { 71 | isa = PBXGroup; 72 | children = ( 73 | 80365CD7241A7CCB005B59B9 /* ModernMVVM.app */, 74 | ); 75 | name = Products; 76 | sourceTree = ""; 77 | }; 78 | 80365CD9241A7CCB005B59B9 /* ModernMVVM */ = { 79 | isa = PBXGroup; 80 | children = ( 81 | 88C9C2362424BD71004EB00E /* API */, 82 | 88C9C2312424BD6C004EB00E /* App */, 83 | 88C9C23B2424BD7B004EB00E /* Controls */, 84 | 88B0A7D5242919E00045F8AB /* Extensions */, 85 | 88C9C2452424BD7F004EB00E /* Features */, 86 | 88B0A7D4242919C10045F8AB /* Feedback */, 87 | 88C9C2302424BD5D004EB00E /* SupportingFIles */, 88 | ); 89 | path = ModernMVVM; 90 | sourceTree = ""; 91 | }; 92 | 88B0A7D4242919C10045F8AB /* Feedback */ = { 93 | isa = PBXGroup; 94 | children = ( 95 | 88C9C2522424BD8A004EB00E /* Feedback.swift */, 96 | 88C9C2502424BD86004EB00E /* System.swift */, 97 | ); 98 | path = Feedback; 99 | sourceTree = ""; 100 | }; 101 | 88B0A7D5242919E00045F8AB /* Extensions */ = { 102 | isa = PBXGroup; 103 | children = ( 104 | 88C9C2542424C197004EB00E /* View+Ext.swift */, 105 | ); 106 | path = Extensions; 107 | sourceTree = ""; 108 | }; 109 | 88C9C2302424BD5D004EB00E /* SupportingFIles */ = { 110 | isa = PBXGroup; 111 | children = ( 112 | 80365CE0241A7CCE005B59B9 /* Assets.xcassets */, 113 | 80365CE5241A7CCE005B59B9 /* LaunchScreen.storyboard */, 114 | 80365CE8241A7CCE005B59B9 /* Info.plist */, 115 | ); 116 | path = SupportingFIles; 117 | sourceTree = ""; 118 | }; 119 | 88C9C2312424BD6C004EB00E /* App */ = { 120 | isa = PBXGroup; 121 | children = ( 122 | 88C9C2322424BD6C004EB00E /* AppDelegate.swift */, 123 | 88C9C2332424BD6C004EB00E /* SceneDelegate.swift */, 124 | ); 125 | path = App; 126 | sourceTree = ""; 127 | }; 128 | 88C9C2362424BD71004EB00E /* API */ = { 129 | isa = PBXGroup; 130 | children = ( 131 | 88C9C2372424BD71004EB00E /* Agent.swift */, 132 | 88C9C2382424BD71004EB00E /* MoviesAPI.swift */, 133 | ); 134 | path = API; 135 | sourceTree = ""; 136 | }; 137 | 88C9C23B2424BD7B004EB00E /* Controls */ = { 138 | isa = PBXGroup; 139 | children = ( 140 | 88C9C23C2424BD7B004EB00E /* Spinner.swift */, 141 | 88C9C23D2424BD7B004EB00E /* AsyncImage */, 142 | ); 143 | path = Controls; 144 | sourceTree = ""; 145 | }; 146 | 88C9C23D2424BD7B004EB00E /* AsyncImage */ = { 147 | isa = PBXGroup; 148 | children = ( 149 | 88C9C23E2424BD7B004EB00E /* ImageCache.swift */, 150 | 88C9C23F2424BD7B004EB00E /* ImageLoader.swift */, 151 | 88C9C2402424BD7B004EB00E /* AsyncImage.swift */, 152 | ); 153 | path = AsyncImage; 154 | sourceTree = ""; 155 | }; 156 | 88C9C2452424BD7F004EB00E /* Features */ = { 157 | isa = PBXGroup; 158 | children = ( 159 | 88C9C2462424BD7F004EB00E /* MovieDetails */, 160 | 88C9C2492424BD7F004EB00E /* MoviesList */, 161 | ); 162 | path = Features; 163 | sourceTree = ""; 164 | }; 165 | 88C9C2462424BD7F004EB00E /* MovieDetails */ = { 166 | isa = PBXGroup; 167 | children = ( 168 | 88C9C2472424BD7F004EB00E /* MovieDetailView.swift */, 169 | 88C9C2482424BD7F004EB00E /* MovieDetailViewModel.swift */, 170 | ); 171 | path = MovieDetails; 172 | sourceTree = ""; 173 | }; 174 | 88C9C2492424BD7F004EB00E /* MoviesList */ = { 175 | isa = PBXGroup; 176 | children = ( 177 | 88C9C24A2424BD7F004EB00E /* MovieListViewModel.swift */, 178 | 88C9C24B2424BD7F004EB00E /* MoviesListView.swift */, 179 | ); 180 | path = MoviesList; 181 | sourceTree = ""; 182 | }; 183 | /* End PBXGroup section */ 184 | 185 | /* Begin PBXNativeTarget section */ 186 | 80365CD6241A7CCB005B59B9 /* ModernMVVM */ = { 187 | isa = PBXNativeTarget; 188 | buildConfigurationList = 80365CEB241A7CCE005B59B9 /* Build configuration list for PBXNativeTarget "ModernMVVM" */; 189 | buildPhases = ( 190 | 80365CD3241A7CCB005B59B9 /* Sources */, 191 | 80365CD4241A7CCB005B59B9 /* Frameworks */, 192 | 80365CD5241A7CCB005B59B9 /* Resources */, 193 | ); 194 | buildRules = ( 195 | ); 196 | dependencies = ( 197 | ); 198 | name = ModernMVVM; 199 | productName = ModernMVVM; 200 | productReference = 80365CD7241A7CCB005B59B9 /* ModernMVVM.app */; 201 | productType = "com.apple.product-type.application"; 202 | }; 203 | /* End PBXNativeTarget section */ 204 | 205 | /* Begin PBXProject section */ 206 | 80365CCF241A7CCB005B59B9 /* Project object */ = { 207 | isa = PBXProject; 208 | attributes = { 209 | LastSwiftUpdateCheck = 1140; 210 | LastUpgradeCheck = 1140; 211 | ORGANIZATIONNAME = "Vadym Bulavin"; 212 | TargetAttributes = { 213 | 80365CD6241A7CCB005B59B9 = { 214 | CreatedOnToolsVersion = 11.4; 215 | }; 216 | }; 217 | }; 218 | buildConfigurationList = 80365CD2241A7CCB005B59B9 /* Build configuration list for PBXProject "ModernMVVM" */; 219 | compatibilityVersion = "Xcode 9.3"; 220 | developmentRegion = en; 221 | hasScannedForEncodings = 0; 222 | knownRegions = ( 223 | en, 224 | Base, 225 | ); 226 | mainGroup = 80365CCE241A7CCB005B59B9; 227 | productRefGroup = 80365CD8241A7CCB005B59B9 /* Products */; 228 | projectDirPath = ""; 229 | projectRoot = ""; 230 | targets = ( 231 | 80365CD6241A7CCB005B59B9 /* ModernMVVM */, 232 | ); 233 | }; 234 | /* End PBXProject section */ 235 | 236 | /* Begin PBXResourcesBuildPhase section */ 237 | 80365CD5241A7CCB005B59B9 /* Resources */ = { 238 | isa = PBXResourcesBuildPhase; 239 | buildActionMask = 2147483647; 240 | files = ( 241 | 80365CE7241A7CCE005B59B9 /* LaunchScreen.storyboard in Resources */, 242 | 80365CE1241A7CCE005B59B9 /* Assets.xcassets in Resources */, 243 | ); 244 | runOnlyForDeploymentPostprocessing = 0; 245 | }; 246 | /* End PBXResourcesBuildPhase section */ 247 | 248 | /* Begin PBXSourcesBuildPhase section */ 249 | 80365CD3241A7CCB005B59B9 /* Sources */ = { 250 | isa = PBXSourcesBuildPhase; 251 | buildActionMask = 2147483647; 252 | files = ( 253 | 88C9C2432424BD7C004EB00E /* ImageLoader.swift in Sources */, 254 | 88C9C2422424BD7C004EB00E /* ImageCache.swift in Sources */, 255 | 88C9C2552424C197004EB00E /* View+Ext.swift in Sources */, 256 | 88C9C2512424BD86004EB00E /* System.swift in Sources */, 257 | 88C9C2392424BD71004EB00E /* Agent.swift in Sources */, 258 | 88C9C24C2424BD7F004EB00E /* MovieDetailView.swift in Sources */, 259 | 88C9C2342424BD6C004EB00E /* AppDelegate.swift in Sources */, 260 | 88C9C2442424BD7C004EB00E /* AsyncImage.swift in Sources */, 261 | 88C9C24D2424BD7F004EB00E /* MovieDetailViewModel.swift in Sources */, 262 | 88C9C24E2424BD7F004EB00E /* MovieListViewModel.swift in Sources */, 263 | 88C9C2352424BD6C004EB00E /* SceneDelegate.swift in Sources */, 264 | 88C9C24F2424BD7F004EB00E /* MoviesListView.swift in Sources */, 265 | 88C9C23A2424BD71004EB00E /* MoviesAPI.swift in Sources */, 266 | 88C9C2532424BD8A004EB00E /* Feedback.swift in Sources */, 267 | 88C9C2412424BD7C004EB00E /* Spinner.swift in Sources */, 268 | ); 269 | runOnlyForDeploymentPostprocessing = 0; 270 | }; 271 | /* End PBXSourcesBuildPhase section */ 272 | 273 | /* Begin PBXVariantGroup section */ 274 | 80365CE5241A7CCE005B59B9 /* LaunchScreen.storyboard */ = { 275 | isa = PBXVariantGroup; 276 | children = ( 277 | 80365CE6241A7CCE005B59B9 /* Base */, 278 | ); 279 | name = LaunchScreen.storyboard; 280 | sourceTree = ""; 281 | }; 282 | /* End PBXVariantGroup section */ 283 | 284 | /* Begin XCBuildConfiguration section */ 285 | 80365CE9241A7CCE005B59B9 /* Debug */ = { 286 | isa = XCBuildConfiguration; 287 | buildSettings = { 288 | ALWAYS_SEARCH_USER_PATHS = NO; 289 | CLANG_ANALYZER_NONNULL = YES; 290 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 291 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 292 | CLANG_CXX_LIBRARY = "libc++"; 293 | CLANG_ENABLE_MODULES = YES; 294 | CLANG_ENABLE_OBJC_ARC = YES; 295 | CLANG_ENABLE_OBJC_WEAK = YES; 296 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 297 | CLANG_WARN_BOOL_CONVERSION = YES; 298 | CLANG_WARN_COMMA = YES; 299 | CLANG_WARN_CONSTANT_CONVERSION = YES; 300 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 301 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 302 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 303 | CLANG_WARN_EMPTY_BODY = YES; 304 | CLANG_WARN_ENUM_CONVERSION = YES; 305 | CLANG_WARN_INFINITE_RECURSION = YES; 306 | CLANG_WARN_INT_CONVERSION = YES; 307 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 308 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 309 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 310 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 311 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 312 | CLANG_WARN_STRICT_PROTOTYPES = YES; 313 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 314 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 315 | CLANG_WARN_UNREACHABLE_CODE = YES; 316 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 317 | COPY_PHASE_STRIP = NO; 318 | DEBUG_INFORMATION_FORMAT = dwarf; 319 | ENABLE_STRICT_OBJC_MSGSEND = YES; 320 | ENABLE_TESTABILITY = YES; 321 | GCC_C_LANGUAGE_STANDARD = gnu11; 322 | GCC_DYNAMIC_NO_PIC = NO; 323 | GCC_NO_COMMON_BLOCKS = YES; 324 | GCC_OPTIMIZATION_LEVEL = 0; 325 | GCC_PREPROCESSOR_DEFINITIONS = ( 326 | "DEBUG=1", 327 | "$(inherited)", 328 | ); 329 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 330 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 331 | GCC_WARN_UNDECLARED_SELECTOR = YES; 332 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 333 | GCC_WARN_UNUSED_FUNCTION = YES; 334 | GCC_WARN_UNUSED_VARIABLE = YES; 335 | IPHONEOS_DEPLOYMENT_TARGET = 13.4; 336 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 337 | MTL_FAST_MATH = YES; 338 | ONLY_ACTIVE_ARCH = YES; 339 | SDKROOT = iphoneos; 340 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 341 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 342 | }; 343 | name = Debug; 344 | }; 345 | 80365CEA241A7CCE005B59B9 /* Release */ = { 346 | isa = XCBuildConfiguration; 347 | buildSettings = { 348 | ALWAYS_SEARCH_USER_PATHS = NO; 349 | CLANG_ANALYZER_NONNULL = YES; 350 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 351 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 352 | CLANG_CXX_LIBRARY = "libc++"; 353 | CLANG_ENABLE_MODULES = YES; 354 | CLANG_ENABLE_OBJC_ARC = YES; 355 | CLANG_ENABLE_OBJC_WEAK = YES; 356 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 357 | CLANG_WARN_BOOL_CONVERSION = YES; 358 | CLANG_WARN_COMMA = YES; 359 | CLANG_WARN_CONSTANT_CONVERSION = YES; 360 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 361 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 362 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 363 | CLANG_WARN_EMPTY_BODY = YES; 364 | CLANG_WARN_ENUM_CONVERSION = YES; 365 | CLANG_WARN_INFINITE_RECURSION = YES; 366 | CLANG_WARN_INT_CONVERSION = YES; 367 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 368 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 369 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 370 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 371 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 372 | CLANG_WARN_STRICT_PROTOTYPES = YES; 373 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 374 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 375 | CLANG_WARN_UNREACHABLE_CODE = YES; 376 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 377 | COPY_PHASE_STRIP = NO; 378 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 379 | ENABLE_NS_ASSERTIONS = NO; 380 | ENABLE_STRICT_OBJC_MSGSEND = YES; 381 | GCC_C_LANGUAGE_STANDARD = gnu11; 382 | GCC_NO_COMMON_BLOCKS = YES; 383 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 384 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 385 | GCC_WARN_UNDECLARED_SELECTOR = YES; 386 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 387 | GCC_WARN_UNUSED_FUNCTION = YES; 388 | GCC_WARN_UNUSED_VARIABLE = YES; 389 | IPHONEOS_DEPLOYMENT_TARGET = 13.4; 390 | MTL_ENABLE_DEBUG_INFO = NO; 391 | MTL_FAST_MATH = YES; 392 | SDKROOT = iphoneos; 393 | SWIFT_COMPILATION_MODE = wholemodule; 394 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 395 | VALIDATE_PRODUCT = YES; 396 | }; 397 | name = Release; 398 | }; 399 | 80365CEC241A7CCE005B59B9 /* Debug */ = { 400 | isa = XCBuildConfiguration; 401 | buildSettings = { 402 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 403 | CODE_SIGN_STYLE = Automatic; 404 | DEVELOPMENT_ASSET_PATHS = ""; 405 | ENABLE_PREVIEWS = YES; 406 | INFOPLIST_FILE = ModernMVVM/SupportingFiles/Info.plist; 407 | LD_RUNPATH_SEARCH_PATHS = ( 408 | "$(inherited)", 409 | "@executable_path/Frameworks", 410 | ); 411 | PRODUCT_BUNDLE_IDENTIFIER = v8tr.ModernMVVM; 412 | PRODUCT_NAME = "$(TARGET_NAME)"; 413 | SWIFT_VERSION = 5.0; 414 | TARGETED_DEVICE_FAMILY = "1,2"; 415 | }; 416 | name = Debug; 417 | }; 418 | 80365CED241A7CCE005B59B9 /* Release */ = { 419 | isa = XCBuildConfiguration; 420 | buildSettings = { 421 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 422 | CODE_SIGN_STYLE = Automatic; 423 | DEVELOPMENT_ASSET_PATHS = ""; 424 | ENABLE_PREVIEWS = YES; 425 | INFOPLIST_FILE = ModernMVVM/SupportingFiles/Info.plist; 426 | LD_RUNPATH_SEARCH_PATHS = ( 427 | "$(inherited)", 428 | "@executable_path/Frameworks", 429 | ); 430 | PRODUCT_BUNDLE_IDENTIFIER = v8tr.ModernMVVM; 431 | PRODUCT_NAME = "$(TARGET_NAME)"; 432 | SWIFT_VERSION = 5.0; 433 | TARGETED_DEVICE_FAMILY = "1,2"; 434 | }; 435 | name = Release; 436 | }; 437 | /* End XCBuildConfiguration section */ 438 | 439 | /* Begin XCConfigurationList section */ 440 | 80365CD2241A7CCB005B59B9 /* Build configuration list for PBXProject "ModernMVVM" */ = { 441 | isa = XCConfigurationList; 442 | buildConfigurations = ( 443 | 80365CE9241A7CCE005B59B9 /* Debug */, 444 | 80365CEA241A7CCE005B59B9 /* Release */, 445 | ); 446 | defaultConfigurationIsVisible = 0; 447 | defaultConfigurationName = Release; 448 | }; 449 | 80365CEB241A7CCE005B59B9 /* Build configuration list for PBXNativeTarget "ModernMVVM" */ = { 450 | isa = XCConfigurationList; 451 | buildConfigurations = ( 452 | 80365CEC241A7CCE005B59B9 /* Debug */, 453 | 80365CED241A7CCE005B59B9 /* Release */, 454 | ); 455 | defaultConfigurationIsVisible = 0; 456 | defaultConfigurationName = Release; 457 | }; 458 | /* End XCConfigurationList section */ 459 | }; 460 | rootObject = 80365CCF241A7CCB005B59B9 /* Project object */; 461 | } 462 | --------------------------------------------------------------------------------