Merge pull request #8 from johnno1962/xcode-11-beta3

Combine examples
This commit is contained in:
Ivan Vorobei
2019-07-10 12:20:04 +03:00
committed by GitHub
13 changed files with 274 additions and 91 deletions

View File

@@ -1,34 +1,57 @@
import SwiftUI
import Combine
enum RequestError: Error {
case request(code: Int, error: Error?)
case unknown
enum URLSessionError: Error {
case invalidResponse
case serverErrorMessage(statusCode: Int, data: Data)
case urlError(URLError)
}
extension URLSession {
func send(request: URLRequest) -> AnyPublisher<(data: Data, response: HTTPURLResponse), RequestError> {
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))
}
}
}
func send(request: URLRequest) -> AnyPublisher<Data, URLSessionError> {
subscriber.receive(subscription: AnySubscription(task.cancel))
task.resume()
}
dataTaskPublisher(for: request)
.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 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()
}
}

View File

@@ -11,5 +11,11 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
)
self.window = window
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()
}
}
}

View File

@@ -36,10 +36,10 @@ final class SearchUserViewModel: BindableObject {
cancellable = assign
URLSession.shared.send(request: request)
.map { $0.data }
.decode(type: SearchUserResponse.self, decoder: JSONDecoder())
.map { $0.items }
.replaceError(with: [])
.receive(on: DispatchQueue.main)
.receive(subscriber: assign)
}
@@ -50,10 +50,11 @@ final class SearchUserViewModel: BindableObject {
let request = URLRequest(url: user.avatar_url)
URLSession.shared.send(request: request)
.map { UIImage(data: $0.data) }
.map { UIImage(data: $0) }
.replaceError(with: nil)
.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
})
}

View File

@@ -7,6 +7,10 @@
objects = {
/* 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 */; };
EDDADAB122A808D500FEDC01 /* Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDDADAB022A808D500FEDC01 /* Combine.swift */; };
EDDADAB322A809E600FEDC01 /* URLSession.Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDDADAB222A809E600FEDC01 /* URLSession.Combine.swift */; };
@@ -24,6 +28,10 @@
/* End PBXBuildFile 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>"; };
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>"; };
@@ -59,6 +67,7 @@
EDDADAB022A808D500FEDC01 /* Combine.swift */,
EDDADAB222A809E600FEDC01 /* URLSession.Combine.swift */,
EDDADABA22A82C2500FEDC01 /* JSONDecoder.Extension.swift */,
BB3DC0D322D54927006F0587 /* Publisher.Extension.swift */,
);
path = Extension;
sourceTree = "<group>";
@@ -109,6 +118,7 @@
ED73146122A6F228004F8DA0 /* RepositoryListView.swift */,
EDDF06B622A6CD8300B23D44 /* RepositoryView.swift */,
EDDADAB822A82C0400FEDC01 /* RepositoryListViewModel.swift */,
BB3DC0D122D54826006F0587 /* WebView.swift */,
);
path = View;
sourceTree = "<group>";
@@ -119,6 +129,8 @@
EDDF06B922A6D78200B23D44 /* Repository.swift */,
EDDADAB422A821CD00FEDC01 /* ItemResponse.swift */,
EDDADAB622A829A300FEDC01 /* RepositoryAPI.swift */,
BB3DC0CD22D54717006F0587 /* AnySubscription.swift */,
BB3DC0CF22D54737006F0587 /* ErrorResponse.swift */,
);
path = Model;
sourceTree = "<group>";
@@ -195,8 +207,10 @@
buildActionMask = 2147483647;
files = (
ED73146222A6F228004F8DA0 /* RepositoryListView.swift in Sources */,
BB3DC0CE22D54717006F0587 /* AnySubscription.swift in Sources */,
EDDADAB722A829A300FEDC01 /* RepositoryAPI.swift in Sources */,
EDDF06A222A6C61C00B23D44 /* AppDelegate.swift in Sources */,
BB3DC0D422D54927006F0587 /* Publisher.Extension.swift in Sources */,
EDDADAB522A821CD00FEDC01 /* ItemResponse.swift in Sources */,
EDDF06A422A6C61C00B23D44 /* SceneDelegate.swift in Sources */,
EDDADAB922A82C0400FEDC01 /* RepositoryListViewModel.swift in Sources */,
@@ -205,6 +219,8 @@
EDDADAB322A809E600FEDC01 /* URLSession.Combine.swift in Sources */,
EDDADABB22A82C2500FEDC01 /* JSONDecoder.Extension.swift in Sources */,
EDDADAB122A808D500FEDC01 /* Combine.swift in Sources */,
BB3DC0D022D54737006F0587 /* ErrorResponse.swift in Sources */,
BB3DC0D222D54826006F0587 /* WebView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -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()
}
}

View File

@@ -15,44 +15,26 @@ extension CombineExtension where Base == URLSession {
func send(request: URLRequest) -> AnyPublisher<Data, URLSessionError> {
AnyPublisher<Data, URLSessionError> { [base] subscriber in
let task = base.dataTask(with: request) { data, response, error in
base.dataTaskPublisher(for: request)
.mapError { URLSessionError.urlError($0) }
.flatMap { data, response -> AnyPublisher<Data, URLSessionError> in
guard let response = response as? HTTPURLResponse else {
subscriber.receive(completion: .failure(.invalidResponse))
return
return .fail(.invalidResponse)
}
guard 200..<300 ~= response.statusCode else {
let e = URLSessionError.serverError(statusCode: response.statusCode,
error: error)
subscriber.receive(completion: .failure(e))
return
return .fail(.serverErrorMessage(statusCode: response.statusCode,
data: data))
}
guard let data = data else {
subscriber.receive(completion: .failure(.noData))
return
}
if let error = error {
subscriber.receive(completion: .failure(.unknown(error)))
} else {
_ = subscriber.receive(data)
subscriber.receive(completion: .finished)
}
return .just(data)
}
// TODO: cancel task when subscriber cancelled
task.resume()
}
.eraseToAnyPublisher()
}
}
enum URLSessionError: Error {
case invalidResponse
case noData
case serverError(statusCode: Int, error: Error?)
case unknown(Error)
case serverErrorMessage(statusCode: Int, data: Data)
case urlError(URLError)
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -10,27 +10,42 @@ import Combine
import Foundation
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 {
return Publishers.Empty<[Repository], Error>().eraseToAnyPublisher()
return .empty()
}
components.queryItems = [URLQueryItem(name: "q", value: query)]
guard let url = components.url else {
return Publishers.Empty<[Repository], Error>().eraseToAnyPublisher()
return .empty()
}
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return URLSession.shared.combine.send(request: request)
return sendRequest(request)
.decode(type: ItemResponse<Repository>.self, decoder: decoder)
.map { $0.items }
.handleEvents(receiveOutput: { print($0) },
receiveCompletion: { print($0)})
.map { Result<[Repository], ErrorResponse>.success($0.items) }
.catch { error -> SearchResponse in
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()
}
}

View File

@@ -20,12 +20,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Use a UIHostingController as window root view controller
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UIHostingController(rootView:
RepositoryListView().environmentObject(RepositoryListViewModel())
)
self.window = window
window.makeKeyAndVisible()
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: RepositoryListView(viewModel: RepositoryListViewModel(mainScheduler: DispatchQueue.main)))
self.window = window
window.makeKeyAndVisible()
}
}
func sceneDidDisconnect(_ scene: UIScene) {

View File

@@ -10,27 +10,51 @@ import SwiftUI
struct RepositoryListView : View {
@EnvironmentObject private var viewModel: RepositoryListViewModel
@State private var text: String = ""
@ObjectBinding
private(set) var viewModel: RepositoryListViewModel
var body: some View {
NavigationView {
TextField($text,
placeholder: Text("Search reposipories..."),
onCommit: { self.viewModel.search(query: self.text) })
VStack {
HStack {
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)
.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))
.border(Color.blue, cornerRadius: 8)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
}
List {
viewModel.errorMessage.map(Text.init)?
.lineLimit(nil)
.multilineTextAlignment(.center)
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🔍"))
}
}

View File

@@ -11,39 +11,60 @@ import Foundation
import SwiftUI
final class RepositoryListViewModel: BindableObject {
typealias SearchRepositories = (String) -> AnyPublisher<Result<[Repository], ErrorResponse>, Never>
let didChange: AnyPublisher<RepositoryListViewModel, Never>
private let _didChange = PassthroughSubject<RepositoryListViewModel, 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] = [] {
didSet {
// TODO: Do not want to use DispatchQueue.main here
DispatchQueue.main.async {
self._didChange.send(self)
}
_didChange.send(self)
}
}
deinit {
repositoriesAssign.cancel()
private(set) var errorMessage: String? {
didSet {
_didChange.send(self)
}
}
var text: String = ""
init<S: Scheduler>(searchRepositories: @escaping SearchRepositories = RepositoryAPI.search,
mainScheduler: S) {
init() {
self.didChange = _didChange.eraseToAnyPublisher()
_searchWithQuery
.flatMap { query -> AnyPublisher<[Repository], Never> in
RepositoryAPI.search(query: query)
.replaceError(with: [])
let response = _searchWithQuery
.filter { !$0.isEmpty }
.debounce(for: .milliseconds(300), scheduler: mainScheduler)
.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()
}
.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) {
_searchWithQuery.send(query)
func search() {
_searchWithQuery.send(text)
}
}

View File

@@ -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>) {}
}