Skip to content

Commit

Permalink
More proper view model sample
Browse files Browse the repository at this point in the history
  • Loading branch information
vinhnx committed Jul 2, 2019
1 parent 9f49dc0 commit af71387
Show file tree
Hide file tree
Showing 11 changed files with 310 additions and 193 deletions.
20 changes: 12 additions & 8 deletions CombineUnsplash.xcodeproj/project.pbxproj
Expand Up @@ -7,10 +7,11 @@
objects = {

/* Begin PBXBuildFile section */
A31D060422ABD56F00D3CB7A /* UIImageViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31D060322ABD56F00D3CB7A /* UIImageViewWrapper.swift */; };
A3488D8322ACA4D700D053D5 /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3488D8222ACA4D700D053D5 /* ImageView.swift */; };
A3669C7422CB3CCD00FB6947 /* Splash.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3669C7322CB3CCD00FB6947 /* Splash.swift */; };
A367153022AB889B00EEECA1 /* SplashError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A367152F22AB889B00EEECA1 /* SplashError.swift */; };
A367153222AB88AD00EEECA1 /* SplashViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A367153122AB88AD00EEECA1 /* SplashViewModel.swift */; };
A39A0C8D22CB594500658696 /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A0C8C22CB594500658696 /* DetailView.swift */; };
A39A0C8F22CB596C00658696 /* LinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A0C8E22CB596C00658696 /* LinkView.swift */; };
A3FB0B5122AB720000C3D0B1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FB0B5022AB720000C3D0B1 /* AppDelegate.swift */; };
A3FB0B5322AB720000C3D0B1 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FB0B5222AB720000C3D0B1 /* SceneDelegate.swift */; };
A3FB0B5722AB720400C3D0B1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A3FB0B5622AB720400C3D0B1 /* Assets.xcassets */; };
Expand Down Expand Up @@ -41,10 +42,11 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
A31D060322ABD56F00D3CB7A /* UIImageViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImageViewWrapper.swift; sourceTree = "<group>"; };
A3488D8222ACA4D700D053D5 /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = "<group>"; };
A3669C7322CB3CCD00FB6947 /* Splash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Splash.swift; sourceTree = "<group>"; };
A367152F22AB889B00EEECA1 /* SplashError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashError.swift; sourceTree = "<group>"; };
A367153122AB88AD00EEECA1 /* SplashViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashViewModel.swift; sourceTree = "<group>"; };
A39A0C8C22CB594500658696 /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = "<group>"; };
A39A0C8E22CB596C00658696 /* LinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkView.swift; sourceTree = "<group>"; };
A3FB0B4D22AB720000C3D0B1 /* CombineUnsplash.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CombineUnsplash.app; sourceTree = BUILT_PRODUCTS_DIR; };
A3FB0B5022AB720000C3D0B1 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
A3FB0B5222AB720000C3D0B1 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -93,6 +95,7 @@
children = (
A367152F22AB889B00EEECA1 /* SplashError.swift */,
A367153122AB88AD00EEECA1 /* SplashViewModel.swift */,
A3669C7322CB3CCD00FB6947 /* Splash.swift */,
);
path = Model;
sourceTree = "<group>";
Expand Down Expand Up @@ -172,8 +175,8 @@
isa = PBXGroup;
children = (
A3FB0B8222AB724900C3D0B1 /* MainView.swift */,
A31D060322ABD56F00D3CB7A /* UIImageViewWrapper.swift */,
A3488D8222ACA4D700D053D5 /* ImageView.swift */,
A39A0C8C22CB594500658696 /* DetailView.swift */,
A39A0C8E22CB596C00658696 /* LinkView.swift */,
);
path = View;
sourceTree = "<group>";
Expand Down Expand Up @@ -326,13 +329,14 @@
buildActionMask = 2147483647;
files = (
A3FB0B8922AB73C200C3D0B1 /* URLBuilder.swift in Sources */,
A31D060422ABD56F00D3CB7A /* UIImageViewWrapper.swift in Sources */,
A3FB0B8322AB724900C3D0B1 /* MainView.swift in Sources */,
A3FB0B5122AB720000C3D0B1 /* AppDelegate.swift in Sources */,
A3669C7422CB3CCD00FB6947 /* Splash.swift in Sources */,
A39A0C8D22CB594500658696 /* DetailView.swift in Sources */,
A367153222AB88AD00EEECA1 /* SplashViewModel.swift in Sources */,
A3FB0B8522AB729500C3D0B1 /* NetworkRequest.swift in Sources */,
A3488D8322ACA4D700D053D5 /* ImageView.swift in Sources */,
A367153022AB889B00EEECA1 /* SplashError.swift in Sources */,
A39A0C8F22CB596C00658696 /* LinkView.swift in Sources */,
A3FB0B5322AB720000C3D0B1 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
66 changes: 21 additions & 45 deletions CombineUnsplash/API/NetworkRequest.swift
Expand Up @@ -6,64 +6,40 @@
// Copyright © 2019 Vinh Nguyen. All rights reserved.
//

import Combine
import Foundation

/// Basic URLSession data task request
final class NetworkRequest {

// MARK: - Aliasing

typealias SplashRequestResult = (Result<Data, SplashError>) -> Void

// MARK: - Data

/// Private dataTask instance to resume or cancel, given owner's life cycle
typealias SplashPubliser = AnyPublisher<[Splash], SplashError>
private var dataTask: URLSessionTask?

// MARK: - Life Cycle
private let backgroundQueue = DispatchQueue(label: "NetworkRequest.queue", qos: .background)

deinit {
self.dataTask?.cancel()
}

// MARK: - Public

/// Fetch Unplash image
/// - Parameter category: any category
/// - Parameter completion: request result
func fetch(category: String, completion: @escaping SplashRequestResult) {
let session = URLSession(configuration: .default)
guard let url = URLBuilder.buildRequestURL(category) else {
DispatchQueue.main.async {
completion(.failure(.unableToMapRequestURL))
}

return
}

let request = URLRequest(url: url)
self.dataTask = session.dataTask(with: request) { data, _, error in
if let error = error {
DispatchQueue.main.async {
completion(.failure(.mappedFromRawError(error)))
}

return
}

guard let data = data else {
DispatchQueue.main.async {
completion(.failure(.invalidData))
}

return
}

DispatchQueue.main.async {
completion(.success(data))
}
func fetchListSignal() -> SplashPubliser {
guard let url = URLBuilder.buildListRequestURL() else {
return Publishers.Empty().eraseToAnyPublisher()
}

self.dataTask?.resume()
var request = URLRequest(url: url)
request.addValue(
"application/json",
forHTTPHeaderField: "Content-Type"
)

return URLSession.shared
.dataTaskPublisher(for: request)
.map { $0.data }
.mapError(SplashError.mappedFromRawError)
.decode(type: [Splash].self, decoder: JSONDecoder())
.mapError(SplashError.jsonDecoderError)
.subscribe(on: self.backgroundQueue) // process on background/private queue
.receive(on: DispatchQueue.main) // send result on main queue
.eraseToAnyPublisher() // IMPORTANT
}
}
17 changes: 3 additions & 14 deletions CombineUnsplash/App/SceneDelegate.swift
Expand Up @@ -10,24 +10,13 @@ import SwiftUI
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UIHostingController(
rootView: MainView().environmentObject(SplashViewModel())
)

let view = MainView()
let viewModel = SplashViewModel()

// NOTE: https://github.com/vinhnx/notes/issues/270
// + use @EnvironmentObject if you want to pass data to another view hierarchy directly
// + use @ObjectBinding to pass data from superview to nearest child view

// NOTE: .environementObject() is required to supply a `BindableObject` (SplashViewModel)
// to our `MainView`
let rootView = view.environmentObject(viewModel)
window.rootViewController = UIHostingController(rootView: rootView)

self.window = window
window.makeKeyAndVisible()
}
Expand Down
17 changes: 12 additions & 5 deletions CombineUnsplash/Helper/URLBuilder.swift
Expand Up @@ -10,10 +10,17 @@ import Foundation
import UIKit

struct URLBuilder {
static func buildRequestURL(_ category: String) -> URL? {
// https://source.unsplash.com/{width}x{height}/?{urlString}
var components = URLComponents(string: "https://source.unsplash.com/500x500/")
components?.query = category
return components?.url
static func build(_ urlString: String) -> URL {
let https = "https://"
if urlString.hasPrefix(https) {
return URL(string: urlString)!
}

return URL(string: (https + urlString))!
}

static func buildListRequestURL() -> URL? {
let comp = URLComponents(string: "https://picsum.photos/v2/list")
return comp?.host == nil ? nil : comp?.url
}
}
100 changes: 100 additions & 0 deletions CombineUnsplash/Model/Splash.swift
@@ -0,0 +1,100 @@
//
// Splash.swift
// CombineUnsplash
//
// Created by Vinh Nguyen on 7/2/19.
// Copyright © 2019 Vinh Nguyen. All rights reserved.
//

import Foundation
import SwiftUI

typealias TopLevel = [Splash]

struct Splash: Codable, Hashable, Identifiable {
let id, width, height: Int
let author, url, downloadURL: String

enum CodingKeys: String, CodingKey {
case id, author, width, height, url
case downloadURL = "download_url"
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id).toInt
self.width = try container.decode(Int.self, forKey: .width)
self.height = try container.decode(Int.self, forKey: .height)
self.author = try container.decode(String.self, forKey: .author)
self.url = try container.decode(String.self, forKey: .url)
self.downloadURL = try container.decode(String.self, forKey: .downloadURL)
}
}

extension String {
var toInt: Int {
return Int(self) ?? 0
}
}

extension Int {
var toString: String {
return String(self)
}
}

// MARK: Convenience initializers

extension Splash {
init?(data: Data) {
guard let me = try? JSONDecoder().decode(Splash.self, from: data) else { return nil }
self = me
}

init?(_ json: String, using encoding: String.Encoding = .utf8) {
guard let data = json.data(using: encoding) else { return nil }
self.init(data: data)
}

init?(fromURL url: String) {
guard let url = URL(string: url) else { return nil }
guard let data = try? Data(contentsOf: url) else { return nil }
self.init(data: data)
}

var jsonData: Data? {
return try? JSONEncoder().encode(self)
}

var json: String? {
guard let data = self.jsonData else { return nil }
return String(data: data, encoding: .utf8)
}
}

extension Array where Element == TopLevel.Element {
init?(data: Data) {
guard let me = try? JSONDecoder().decode(TopLevel.self, from: data) else { return nil }
self = me
}

init?(_ json: String, using encoding: String.Encoding = .utf8) {
guard let data = json.data(using: encoding) else { return nil }
self.init(data: data)
}

init?(fromURL url: String) {
guard let url = URL(string: url) else { return nil }
guard let data = try? Data(contentsOf: url) else { return nil }
self.init(data: data)
}

var jsonData: Data? {
return try? JSONEncoder().encode(self)
}

var json: String? {
guard let data = self.jsonData else { return nil }
return String(data: data, encoding: .utf8)
}
}

0 comments on commit af71387

Please sign in to comment.