├── 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 |
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 |
--------------------------------------------------------------------------------