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

View File

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

View File

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

View File

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

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

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

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). // 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) {

View File

@@ -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🔍"))
} }
} }

View File

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

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