mirror of
https://github.com/ivanvorobei/SwiftUI.git
synced 2026-03-31 14:33:39 +02:00
@@ -1,34 +1,57 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
enum RequestError: Error {
|
enum URLSessionError: Error {
|
||||||
case request(code: Int, error: Error?)
|
case invalidResponse
|
||||||
case unknown
|
case serverErrorMessage(statusCode: Int, data: Data)
|
||||||
|
case urlError(URLError)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension URLSession {
|
extension URLSession {
|
||||||
func send(request: URLRequest) -> AnyPublisher<(data: Data, response: HTTPURLResponse), RequestError> {
|
func send(request: URLRequest) -> AnyPublisher<Data, URLSessionError> {
|
||||||
AnyPublisher<(data: Data, response: HTTPURLResponse), RequestError> { subscriber in
|
|
||||||
let task = self.dataTask(with: request) { data, response, error in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let httpReponse = response as? HTTPURLResponse
|
|
||||||
if let data = data, let httpReponse = httpReponse, 200..<300 ~= httpReponse.statusCode {
|
|
||||||
_ = subscriber.receive((data, httpReponse))
|
|
||||||
subscriber.receive(completion: .finished)
|
|
||||||
}
|
|
||||||
else if let httpReponse = httpReponse {
|
|
||||||
subscriber.receive(completion: .failure(.request(code: httpReponse.statusCode, error: error)))
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
subscriber.receive(completion: .failure(.unknown))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriber.receive(subscription: AnySubscription(task.cancel))
|
dataTaskPublisher(for: request)
|
||||||
task.resume()
|
.mapError { URLSessionError.urlError($0) }
|
||||||
}
|
.flatMap { data, response -> AnyPublisher<Data, URLSessionError> in
|
||||||
|
guard let response = response as? HTTPURLResponse else {
|
||||||
|
return .fail(.invalidResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard 200..<300 ~= response.statusCode else {
|
||||||
|
return .fail(.serverErrorMessage(statusCode: response.statusCode,
|
||||||
|
data: data))
|
||||||
|
}
|
||||||
|
|
||||||
|
return .just(data)
|
||||||
|
}.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension JSONDecoder: TopLevelDecoder {}
|
extension JSONDecoder: TopLevelDecoder {}
|
||||||
|
|
||||||
|
extension Publisher {
|
||||||
|
|
||||||
|
/// - seealso: https://twitter.com/peres/status/1136132104020881408
|
||||||
|
func flatMapLatest<T: Publisher>(_ transform: @escaping (Self.Output) -> T) -> Publishers.SwitchToLatest<T, Publishers.Map<Self, T>> where T.Failure == Self.Failure {
|
||||||
|
map(transform).switchToLatest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Publisher {
|
||||||
|
|
||||||
|
static func empty() -> AnyPublisher<Output, Failure> {
|
||||||
|
return Publishers.Empty()
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func just(_ output: Output) -> AnyPublisher<Output, Failure> {
|
||||||
|
return Just(output)
|
||||||
|
.catch { _ in AnyPublisher<Output, Failure>.empty() }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fail(_ error: Failure) -> AnyPublisher<Output, Failure> {
|
||||||
|
return Publishers.Fail(error: error)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,5 +11,11 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
)
|
)
|
||||||
self.window = window
|
self.window = window
|
||||||
window.makeKeyAndVisible()
|
window.makeKeyAndVisible()
|
||||||
|
if let windowScene = scene as? UIWindowScene {
|
||||||
|
let window = UIWindow(windowScene: windowScene)
|
||||||
|
window.rootViewController = UIHostingController(rootView: SearchUserView().environmentObject(SearchUserViewModel()))
|
||||||
|
self.window = window
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ final class SearchUserViewModel: BindableObject {
|
|||||||
cancellable = assign
|
cancellable = assign
|
||||||
|
|
||||||
URLSession.shared.send(request: request)
|
URLSession.shared.send(request: request)
|
||||||
.map { $0.data }
|
|
||||||
.decode(type: SearchUserResponse.self, decoder: JSONDecoder())
|
.decode(type: SearchUserResponse.self, decoder: JSONDecoder())
|
||||||
.map { $0.items }
|
.map { $0.items }
|
||||||
.replaceError(with: [])
|
.replaceError(with: [])
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
.receive(subscriber: assign)
|
.receive(subscriber: assign)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,10 +50,11 @@ final class SearchUserViewModel: BindableObject {
|
|||||||
|
|
||||||
let request = URLRequest(url: user.avatar_url)
|
let request = URLRequest(url: user.avatar_url)
|
||||||
URLSession.shared.send(request: request)
|
URLSession.shared.send(request: request)
|
||||||
.map { UIImage(data: $0.data) }
|
.map { UIImage(data: $0) }
|
||||||
.replaceError(with: nil)
|
.replaceError(with: nil)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
.receive(subscriber: Subscribers.Sink<AnyPublisher<UIImage?, Never>> { [weak self] image in
|
.receive(on: DispatchQueue.main)
|
||||||
|
.receive(subscriber: Subscribers.Sink<UIImage?, Never> { [weak self] image in
|
||||||
self?.userImages[user] = image
|
self?.userImages[user] = image
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
BB3DC0CE22D54717006F0587 /* AnySubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3DC0CD22D54717006F0587 /* AnySubscription.swift */; };
|
||||||
|
BB3DC0D022D54737006F0587 /* ErrorResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3DC0CF22D54737006F0587 /* ErrorResponse.swift */; };
|
||||||
|
BB3DC0D222D54826006F0587 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3DC0D122D54826006F0587 /* WebView.swift */; };
|
||||||
|
BB3DC0D422D54927006F0587 /* Publisher.Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3DC0D322D54927006F0587 /* Publisher.Extension.swift */; };
|
||||||
ED73146222A6F228004F8DA0 /* RepositoryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED73146122A6F228004F8DA0 /* RepositoryListView.swift */; };
|
ED73146222A6F228004F8DA0 /* RepositoryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED73146122A6F228004F8DA0 /* RepositoryListView.swift */; };
|
||||||
EDDADAB122A808D500FEDC01 /* Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDDADAB022A808D500FEDC01 /* Combine.swift */; };
|
EDDADAB122A808D500FEDC01 /* Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDDADAB022A808D500FEDC01 /* Combine.swift */; };
|
||||||
EDDADAB322A809E600FEDC01 /* URLSession.Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDDADAB222A809E600FEDC01 /* URLSession.Combine.swift */; };
|
EDDADAB322A809E600FEDC01 /* URLSession.Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDDADAB222A809E600FEDC01 /* URLSession.Combine.swift */; };
|
||||||
@@ -24,6 +28,10 @@
|
|||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
BB3DC0CD22D54717006F0587 /* AnySubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnySubscription.swift; sourceTree = "<group>"; };
|
||||||
|
BB3DC0CF22D54737006F0587 /* ErrorResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorResponse.swift; sourceTree = "<group>"; };
|
||||||
|
BB3DC0D122D54826006F0587 /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = "<group>"; };
|
||||||
|
BB3DC0D322D54927006F0587 /* Publisher.Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publisher.Extension.swift; sourceTree = "<group>"; };
|
||||||
ED73146122A6F228004F8DA0 /* RepositoryListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RepositoryListView.swift; sourceTree = "<group>"; };
|
ED73146122A6F228004F8DA0 /* RepositoryListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RepositoryListView.swift; sourceTree = "<group>"; };
|
||||||
EDDADAB022A808D500FEDC01 /* Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Combine.swift; sourceTree = "<group>"; };
|
EDDADAB022A808D500FEDC01 /* Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Combine.swift; sourceTree = "<group>"; };
|
||||||
EDDADAB222A809E600FEDC01 /* URLSession.Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSession.Combine.swift; sourceTree = "<group>"; };
|
EDDADAB222A809E600FEDC01 /* URLSession.Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSession.Combine.swift; sourceTree = "<group>"; };
|
||||||
@@ -59,6 +67,7 @@
|
|||||||
EDDADAB022A808D500FEDC01 /* Combine.swift */,
|
EDDADAB022A808D500FEDC01 /* Combine.swift */,
|
||||||
EDDADAB222A809E600FEDC01 /* URLSession.Combine.swift */,
|
EDDADAB222A809E600FEDC01 /* URLSession.Combine.swift */,
|
||||||
EDDADABA22A82C2500FEDC01 /* JSONDecoder.Extension.swift */,
|
EDDADABA22A82C2500FEDC01 /* JSONDecoder.Extension.swift */,
|
||||||
|
BB3DC0D322D54927006F0587 /* Publisher.Extension.swift */,
|
||||||
);
|
);
|
||||||
path = Extension;
|
path = Extension;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -109,6 +118,7 @@
|
|||||||
ED73146122A6F228004F8DA0 /* RepositoryListView.swift */,
|
ED73146122A6F228004F8DA0 /* RepositoryListView.swift */,
|
||||||
EDDF06B622A6CD8300B23D44 /* RepositoryView.swift */,
|
EDDF06B622A6CD8300B23D44 /* RepositoryView.swift */,
|
||||||
EDDADAB822A82C0400FEDC01 /* RepositoryListViewModel.swift */,
|
EDDADAB822A82C0400FEDC01 /* RepositoryListViewModel.swift */,
|
||||||
|
BB3DC0D122D54826006F0587 /* WebView.swift */,
|
||||||
);
|
);
|
||||||
path = View;
|
path = View;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -119,6 +129,8 @@
|
|||||||
EDDF06B922A6D78200B23D44 /* Repository.swift */,
|
EDDF06B922A6D78200B23D44 /* Repository.swift */,
|
||||||
EDDADAB422A821CD00FEDC01 /* ItemResponse.swift */,
|
EDDADAB422A821CD00FEDC01 /* ItemResponse.swift */,
|
||||||
EDDADAB622A829A300FEDC01 /* RepositoryAPI.swift */,
|
EDDADAB622A829A300FEDC01 /* RepositoryAPI.swift */,
|
||||||
|
BB3DC0CD22D54717006F0587 /* AnySubscription.swift */,
|
||||||
|
BB3DC0CF22D54737006F0587 /* ErrorResponse.swift */,
|
||||||
);
|
);
|
||||||
path = Model;
|
path = Model;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -195,8 +207,10 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
ED73146222A6F228004F8DA0 /* RepositoryListView.swift in Sources */,
|
ED73146222A6F228004F8DA0 /* RepositoryListView.swift in Sources */,
|
||||||
|
BB3DC0CE22D54717006F0587 /* AnySubscription.swift in Sources */,
|
||||||
EDDADAB722A829A300FEDC01 /* RepositoryAPI.swift in Sources */,
|
EDDADAB722A829A300FEDC01 /* RepositoryAPI.swift in Sources */,
|
||||||
EDDF06A222A6C61C00B23D44 /* AppDelegate.swift in Sources */,
|
EDDF06A222A6C61C00B23D44 /* AppDelegate.swift in Sources */,
|
||||||
|
BB3DC0D422D54927006F0587 /* Publisher.Extension.swift in Sources */,
|
||||||
EDDADAB522A821CD00FEDC01 /* ItemResponse.swift in Sources */,
|
EDDADAB522A821CD00FEDC01 /* ItemResponse.swift in Sources */,
|
||||||
EDDF06A422A6C61C00B23D44 /* SceneDelegate.swift in Sources */,
|
EDDF06A422A6C61C00B23D44 /* SceneDelegate.swift in Sources */,
|
||||||
EDDADAB922A82C0400FEDC01 /* RepositoryListViewModel.swift in Sources */,
|
EDDADAB922A82C0400FEDC01 /* RepositoryListViewModel.swift in Sources */,
|
||||||
@@ -205,6 +219,8 @@
|
|||||||
EDDADAB322A809E600FEDC01 /* URLSession.Combine.swift in Sources */,
|
EDDADAB322A809E600FEDC01 /* URLSession.Combine.swift in Sources */,
|
||||||
EDDADABB22A82C2500FEDC01 /* JSONDecoder.Extension.swift in Sources */,
|
EDDADABB22A82C2500FEDC01 /* JSONDecoder.Extension.swift in Sources */,
|
||||||
EDDADAB122A808D500FEDC01 /* Combine.swift in Sources */,
|
EDDADAB122A808D500FEDC01 /* Combine.swift in Sources */,
|
||||||
|
BB3DC0D022D54737006F0587 /* ErrorResponse.swift in Sources */,
|
||||||
|
BB3DC0D222D54826006F0587 /* WebView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//
|
||||||
|
// Publisher.Extension.swift
|
||||||
|
// GitHubSearchWithSwiftUI
|
||||||
|
//
|
||||||
|
// Created by John Holdsworth on 09/07/2019.
|
||||||
|
// Copyright © 2019 jp.marty-suzuki. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
extension Publisher {
|
||||||
|
|
||||||
|
/// - seealso: https://twitter.com/peres/status/1136132104020881408
|
||||||
|
func flatMapLatest<T: Publisher>(_ transform: @escaping (Self.Output) -> T) -> Publishers.SwitchToLatest<T, Publishers.Map<Self, T>> where T.Failure == Self.Failure {
|
||||||
|
map(transform).switchToLatest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Publisher {
|
||||||
|
|
||||||
|
static func empty() -> AnyPublisher<Output, Failure> {
|
||||||
|
return Publishers.Empty()
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func just(_ output: Output) -> AnyPublisher<Output, Failure> {
|
||||||
|
return Just(output)
|
||||||
|
.catch { _ in AnyPublisher<Output, Failure>.empty() }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fail(_ error: Failure) -> AnyPublisher<Output, Failure> {
|
||||||
|
return Publishers.Fail(error: error)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,44 +15,26 @@ extension CombineExtension where Base == URLSession {
|
|||||||
|
|
||||||
func send(request: URLRequest) -> AnyPublisher<Data, URLSessionError> {
|
func send(request: URLRequest) -> AnyPublisher<Data, URLSessionError> {
|
||||||
|
|
||||||
AnyPublisher<Data, URLSessionError> { [base] subscriber in
|
base.dataTaskPublisher(for: request)
|
||||||
|
.mapError { URLSessionError.urlError($0) }
|
||||||
let task = base.dataTask(with: request) { data, response, error in
|
.flatMap { data, response -> AnyPublisher<Data, URLSessionError> in
|
||||||
|
|
||||||
guard let response = response as? HTTPURLResponse else {
|
guard let response = response as? HTTPURLResponse else {
|
||||||
subscriber.receive(completion: .failure(.invalidResponse))
|
return .fail(.invalidResponse)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard 200..<300 ~= response.statusCode else {
|
guard 200..<300 ~= response.statusCode else {
|
||||||
let e = URLSessionError.serverError(statusCode: response.statusCode,
|
return .fail(.serverErrorMessage(statusCode: response.statusCode,
|
||||||
error: error)
|
data: data))
|
||||||
subscriber.receive(completion: .failure(e))
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = data else {
|
return .just(data)
|
||||||
subscriber.receive(completion: .failure(.noData))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let error = error {
|
|
||||||
subscriber.receive(completion: .failure(.unknown(error)))
|
|
||||||
} else {
|
|
||||||
_ = subscriber.receive(data)
|
|
||||||
subscriber.receive(completion: .finished)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
// TODO: cancel task when subscriber cancelled
|
|
||||||
task.resume()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum URLSessionError: Error {
|
enum URLSessionError: Error {
|
||||||
case invalidResponse
|
case invalidResponse
|
||||||
case noData
|
case serverErrorMessage(statusCode: Int, data: Data)
|
||||||
case serverError(statusCode: Int, error: Error?)
|
case urlError(URLError)
|
||||||
case unknown(Error)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// AnySubscription.swift
|
||||||
|
// GitHubSearchWithSwiftUI
|
||||||
|
//
|
||||||
|
// Created by John Holdsworth on 09/07/2019.
|
||||||
|
// Copyright © 2019 jp.marty-suzuki. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// - seealso: https://twitter.com/peres/status/1135970931153821696
|
||||||
|
final class AnySubscription: Subscription {
|
||||||
|
|
||||||
|
private let cancellable: AnyCancellable
|
||||||
|
|
||||||
|
init(_ cancel: @escaping () -> Void) {
|
||||||
|
self.cancellable = AnyCancellable(cancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func request(_ demand: Subscribers.Demand) {}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
cancellable.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//
|
||||||
|
// ErrorResponse.swift
|
||||||
|
// GitHubSearchWithSwiftUI
|
||||||
|
//
|
||||||
|
// Created by John Holdsworth on 09/07/2019.
|
||||||
|
// Copyright © 2019 jp.marty-suzuki. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ErrorResponse: Decodable, Error {
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
@@ -10,27 +10,42 @@ import Combine
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum RepositoryAPI {
|
enum RepositoryAPI {
|
||||||
|
typealias SearchResponse = AnyPublisher<Result<[Repository], ErrorResponse>, Never>
|
||||||
|
typealias SendRequest = (URLRequest) -> AnyPublisher<Data, URLSessionError>
|
||||||
|
|
||||||
static func search(query: String) -> AnyPublisher<[Repository], Error> {
|
static func search(query: String) -> SearchResponse {
|
||||||
|
search(query: query, sendRequest: URLSession.shared.combine.send)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func search(query: String, sendRequest: SendRequest) -> SearchResponse {
|
||||||
|
|
||||||
guard var components = URLComponents(string: "https://api.github.com/search/repositories") else {
|
guard var components = URLComponents(string: "https://api.github.com/search/repositories") else {
|
||||||
return Publishers.Empty<[Repository], Error>().eraseToAnyPublisher()
|
return .empty()
|
||||||
}
|
}
|
||||||
components.queryItems = [URLQueryItem(name: "q", value: query)]
|
components.queryItems = [URLQueryItem(name: "q", value: query)]
|
||||||
|
|
||||||
guard let url = components.url else {
|
guard let url = components.url else {
|
||||||
return Publishers.Empty<[Repository], Error>().eraseToAnyPublisher()
|
return .empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
return URLSession.shared.combine.send(request: request)
|
return sendRequest(request)
|
||||||
.decode(type: ItemResponse<Repository>.self, decoder: decoder)
|
.decode(type: ItemResponse<Repository>.self, decoder: decoder)
|
||||||
.map { $0.items }
|
.map { Result<[Repository], ErrorResponse>.success($0.items) }
|
||||||
.handleEvents(receiveOutput: { print($0) },
|
.catch { error -> SearchResponse in
|
||||||
receiveCompletion: { print($0)})
|
guard case let .serverErrorMessage(_, data)? = error as? URLSessionError else {
|
||||||
|
return .just(.success([]))
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let response = try JSONDecoder().decode(ErrorResponse.self, from: data)
|
||||||
|
return .just(.failure(response))
|
||||||
|
} catch _ {
|
||||||
|
return .just(.success([]))
|
||||||
|
}
|
||||||
|
}
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
|
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
|
||||||
|
|
||||||
// Use a UIHostingController as window root view controller
|
// Use a UIHostingController as window root view controller
|
||||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
if let windowScene = scene as? UIWindowScene {
|
||||||
window.rootViewController = UIHostingController(rootView:
|
let window = UIWindow(windowScene: windowScene)
|
||||||
RepositoryListView().environmentObject(RepositoryListViewModel())
|
window.rootViewController = UIHostingController(rootView: RepositoryListView(viewModel: RepositoryListViewModel(mainScheduler: DispatchQueue.main)))
|
||||||
)
|
self.window = window
|
||||||
self.window = window
|
window.makeKeyAndVisible()
|
||||||
window.makeKeyAndVisible()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneDidDisconnect(_ scene: UIScene) {
|
func sceneDidDisconnect(_ scene: UIScene) {
|
||||||
|
|||||||
@@ -10,27 +10,51 @@ import SwiftUI
|
|||||||
|
|
||||||
struct RepositoryListView : View {
|
struct RepositoryListView : View {
|
||||||
|
|
||||||
@EnvironmentObject private var viewModel: RepositoryListViewModel
|
@ObjectBinding
|
||||||
@State private var text: String = ""
|
private(set) var viewModel: RepositoryListViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
||||||
NavigationView {
|
NavigationView {
|
||||||
|
|
||||||
TextField($text,
|
VStack {
|
||||||
placeholder: Text("Search reposipories..."),
|
HStack {
|
||||||
onCommit: { self.viewModel.search(query: self.text) })
|
|
||||||
|
TextField($viewModel.text,
|
||||||
|
placeholder: Text("Search reposipories..."),
|
||||||
|
onCommit: { self.viewModel.search() })
|
||||||
|
.frame(height: 40)
|
||||||
|
.padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8))
|
||||||
|
.border(Color.gray, cornerRadius: 8)
|
||||||
|
.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||||||
|
|
||||||
|
Button(action: { self.viewModel.search() }) {
|
||||||
|
Text("Search")
|
||||||
|
}
|
||||||
.frame(height: 40)
|
.frame(height: 40)
|
||||||
.padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8))
|
.padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8))
|
||||||
.border(Color.gray, cornerRadius: 8)
|
.border(Color.blue, cornerRadius: 8)
|
||||||
.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
|
||||||
|
}
|
||||||
|
|
||||||
List {
|
List {
|
||||||
|
|
||||||
|
viewModel.errorMessage.map(Text.init)?
|
||||||
|
.lineLimit(nil)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
ForEach(viewModel.repositories.identified(by: \.id)) { repository in
|
ForEach(viewModel.repositories.identified(by: \.id)) { repository in
|
||||||
RepositoryView(repository: repository)
|
|
||||||
|
NavigationLink(destination:
|
||||||
|
WebView(url: repository.htmlUrl)
|
||||||
|
.navigationBarTitle(Text(repository.fullName))
|
||||||
|
) {
|
||||||
|
|
||||||
|
RepositoryView(repository: repository)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.navigationBarTitle(Text("Search🔍"))
|
.navigationBarTitle(Text("Search🔍"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,39 +11,60 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
final class RepositoryListViewModel: BindableObject {
|
final class RepositoryListViewModel: BindableObject {
|
||||||
|
typealias SearchRepositories = (String) -> AnyPublisher<Result<[Repository], ErrorResponse>, Never>
|
||||||
|
|
||||||
let didChange: AnyPublisher<RepositoryListViewModel, Never>
|
let didChange: AnyPublisher<RepositoryListViewModel, Never>
|
||||||
private let _didChange = PassthroughSubject<RepositoryListViewModel, Never>()
|
private let _didChange = PassthroughSubject<RepositoryListViewModel, Never>()
|
||||||
|
|
||||||
private let _searchWithQuery = PassthroughSubject<String, Never>()
|
private let _searchWithQuery = PassthroughSubject<String, Never>()
|
||||||
private lazy var repositoriesAssign = Subscribers.Assign(object: self, keyPath: \.repositories)
|
private var cancellables: [AnyCancellable] = []
|
||||||
|
|
||||||
private(set) var repositories: [Repository] = [] {
|
private(set) var repositories: [Repository] = [] {
|
||||||
didSet {
|
didSet {
|
||||||
// TODO: Do not want to use DispatchQueue.main here
|
_didChange.send(self)
|
||||||
DispatchQueue.main.async {
|
|
||||||
self._didChange.send(self)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private(set) var errorMessage: String? {
|
||||||
deinit {
|
didSet {
|
||||||
repositoriesAssign.cancel()
|
_didChange.send(self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
var text: String = ""
|
||||||
|
|
||||||
|
init<S: Scheduler>(searchRepositories: @escaping SearchRepositories = RepositoryAPI.search,
|
||||||
|
mainScheduler: S) {
|
||||||
|
|
||||||
init() {
|
|
||||||
self.didChange = _didChange.eraseToAnyPublisher()
|
self.didChange = _didChange.eraseToAnyPublisher()
|
||||||
|
|
||||||
_searchWithQuery
|
let response = _searchWithQuery
|
||||||
.flatMap { query -> AnyPublisher<[Repository], Never> in
|
.filter { !$0.isEmpty }
|
||||||
RepositoryAPI.search(query: query)
|
.debounce(for: .milliseconds(300), scheduler: mainScheduler)
|
||||||
.replaceError(with: [])
|
.flatMapLatest { query -> AnyPublisher<([Repository], String?), Never> in
|
||||||
|
searchRepositories(query)
|
||||||
|
.map { result -> ([Repository], String?) in
|
||||||
|
switch result {
|
||||||
|
case let .success(repositories):
|
||||||
|
return (repositories, nil)
|
||||||
|
case let .failure(response):
|
||||||
|
return ([], response.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
.receive(subscriber: repositoriesAssign)
|
.receive(on: mainScheduler)
|
||||||
|
.share()
|
||||||
|
|
||||||
|
cancellables += [
|
||||||
|
response
|
||||||
|
.map { $0.0 }
|
||||||
|
.assign(to: \.repositories, on: self),
|
||||||
|
response
|
||||||
|
.map { $0.1 }
|
||||||
|
.assign(to: \.errorMessage, on: self)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
func search(query: String) {
|
func search() {
|
||||||
_searchWithQuery.send(query)
|
_searchWithQuery.send(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//
|
||||||
|
// WebView.swift
|
||||||
|
// GitHubSearchWithSwiftUI
|
||||||
|
//
|
||||||
|
// Created by marty-suzuki on 2019/06/11.
|
||||||
|
// Copyright © 2019 jp.marty-suzuki. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SafariServices
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WebView: UIViewControllerRepresentable {
|
||||||
|
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
func makeUIViewController(context: UIViewControllerRepresentableContext<WebView>) -> SFSafariViewController {
|
||||||
|
return SFSafariViewController(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: SFSafariViewController, context: UIViewControllerRepresentableContext<WebView>) {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user