diff --git a/Other Projects/Combine using GitHub API/SwiftUI-Combine-Example/FoundationExtensions.swift b/Other Projects/Combine using GitHub API/SwiftUI-Combine-Example/FoundationExtensions.swift index 6fecb93..72073c6 100755 --- a/Other Projects/Combine using GitHub API/SwiftUI-Combine-Example/FoundationExtensions.swift +++ b/Other Projects/Combine using GitHub API/SwiftUI-Combine-Example/FoundationExtensions.swift @@ -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 { - subscriber.receive(subscription: AnySubscription(task.cancel)) - task.resume() - } + dataTaskPublisher(for: request) + .mapError { URLSessionError.urlError($0) } + .flatMap { data, response -> AnyPublisher 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(_ transform: @escaping (Self.Output) -> T) -> Publishers.SwitchToLatest> where T.Failure == Self.Failure { + map(transform).switchToLatest() + } +} + +extension Publisher { + + static func empty() -> AnyPublisher { + return Publishers.Empty() + .eraseToAnyPublisher() + } + + static func just(_ output: Output) -> AnyPublisher { + return Just(output) + .catch { _ in AnyPublisher.empty() } + .eraseToAnyPublisher() + } + + static func fail(_ error: Failure) -> AnyPublisher { + return Publishers.Fail(error: error) + .eraseToAnyPublisher() + } +} diff --git a/Other Projects/Combine using GitHub API/SwiftUI-Combine-Example/SceneDelegate.swift b/Other Projects/Combine using GitHub API/SwiftUI-Combine-Example/SceneDelegate.swift index f41d993..e4db875 100755 --- a/Other Projects/Combine using GitHub API/SwiftUI-Combine-Example/SceneDelegate.swift +++ b/Other Projects/Combine using GitHub API/SwiftUI-Combine-Example/SceneDelegate.swift @@ -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() + } } } diff --git a/Other Projects/Combine using GitHub API/SwiftUI-Combine-Example/SearchUserViewModel.swift b/Other Projects/Combine using GitHub API/SwiftUI-Combine-Example/SearchUserViewModel.swift index b302b8a..0b71bb6 100755 --- a/Other Projects/Combine using GitHub API/SwiftUI-Combine-Example/SearchUserViewModel.swift +++ b/Other Projects/Combine using GitHub API/SwiftUI-Combine-Example/SearchUserViewModel.swift @@ -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> { [weak self] image in + .receive(on: DispatchQueue.main) + .receive(subscriber: Subscribers.Sink { [weak self] image in self?.userImages[user] = image }) } diff --git a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI.xcodeproj/project.pbxproj b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI.xcodeproj/project.pbxproj index f3f2403..15040d2 100755 --- a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI.xcodeproj/project.pbxproj +++ b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI.xcodeproj/project.pbxproj @@ -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 = ""; }; + BB3DC0CF22D54737006F0587 /* ErrorResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorResponse.swift; sourceTree = ""; }; + BB3DC0D122D54826006F0587 /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; + BB3DC0D322D54927006F0587 /* Publisher.Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publisher.Extension.swift; sourceTree = ""; }; ED73146122A6F228004F8DA0 /* RepositoryListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RepositoryListView.swift; sourceTree = ""; }; EDDADAB022A808D500FEDC01 /* Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Combine.swift; sourceTree = ""; }; EDDADAB222A809E600FEDC01 /* URLSession.Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSession.Combine.swift; sourceTree = ""; }; @@ -59,6 +67,7 @@ EDDADAB022A808D500FEDC01 /* Combine.swift */, EDDADAB222A809E600FEDC01 /* URLSession.Combine.swift */, EDDADABA22A82C2500FEDC01 /* JSONDecoder.Extension.swift */, + BB3DC0D322D54927006F0587 /* Publisher.Extension.swift */, ); path = Extension; sourceTree = ""; @@ -109,6 +118,7 @@ ED73146122A6F228004F8DA0 /* RepositoryListView.swift */, EDDF06B622A6CD8300B23D44 /* RepositoryView.swift */, EDDADAB822A82C0400FEDC01 /* RepositoryListViewModel.swift */, + BB3DC0D122D54826006F0587 /* WebView.swift */, ); path = View; sourceTree = ""; @@ -119,6 +129,8 @@ EDDF06B922A6D78200B23D44 /* Repository.swift */, EDDADAB422A821CD00FEDC01 /* ItemResponse.swift */, EDDADAB622A829A300FEDC01 /* RepositoryAPI.swift */, + BB3DC0CD22D54717006F0587 /* AnySubscription.swift */, + BB3DC0CF22D54737006F0587 /* ErrorResponse.swift */, ); path = Model; sourceTree = ""; @@ -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; }; diff --git a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Extension/Publisher.Extension.swift b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Extension/Publisher.Extension.swift new file mode 100644 index 0000000..4ea80f8 --- /dev/null +++ b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Extension/Publisher.Extension.swift @@ -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(_ transform: @escaping (Self.Output) -> T) -> Publishers.SwitchToLatest> where T.Failure == Self.Failure { + map(transform).switchToLatest() + } +} + +extension Publisher { + + static func empty() -> AnyPublisher { + return Publishers.Empty() + .eraseToAnyPublisher() + } + + static func just(_ output: Output) -> AnyPublisher { + return Just(output) + .catch { _ in AnyPublisher.empty() } + .eraseToAnyPublisher() + } + + static func fail(_ error: Failure) -> AnyPublisher { + return Publishers.Fail(error: error) + .eraseToAnyPublisher() + } +} diff --git a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Extension/URLSession.Combine.swift b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Extension/URLSession.Combine.swift index bd6a46f..5d14f14 100755 --- a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Extension/URLSession.Combine.swift +++ b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Extension/URLSession.Combine.swift @@ -15,44 +15,26 @@ extension CombineExtension where Base == URLSession { func send(request: URLRequest) -> AnyPublisher { - AnyPublisher { [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 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) } diff --git a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Model/AnySubscription.swift b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Model/AnySubscription.swift new file mode 100644 index 0000000..f9776b8 --- /dev/null +++ b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Model/AnySubscription.swift @@ -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() + } +} diff --git a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Model/ErrorResponse.swift b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Model/ErrorResponse.swift new file mode 100644 index 0000000..44d6ff7 --- /dev/null +++ b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Model/ErrorResponse.swift @@ -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 +} diff --git a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Model/RepositoryAPI.swift b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Model/RepositoryAPI.swift index f0fb5ae..a056450 100755 --- a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Model/RepositoryAPI.swift +++ b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/Model/RepositoryAPI.swift @@ -10,27 +10,42 @@ import Combine import Foundation enum RepositoryAPI { + typealias SearchResponse = AnyPublisher, Never> + typealias SendRequest = (URLRequest) -> AnyPublisher - 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.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() } } diff --git a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/SceneDelegate.swift b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/SceneDelegate.swift index 3a4a981..b49e39b 100755 --- a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/SceneDelegate.swift +++ b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/SceneDelegate.swift @@ -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) { diff --git a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/View/RepositoryListView.swift b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/View/RepositoryListView.swift index d47a5f3..a9a5086 100755 --- a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/View/RepositoryListView.swift +++ b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/View/RepositoryListView.swift @@ -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🔍")) } } diff --git a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/View/RepositoryListViewModel.swift b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/View/RepositoryListViewModel.swift index f5e7360..21a709a 100755 --- a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/View/RepositoryListViewModel.swift +++ b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/View/RepositoryListViewModel.swift @@ -11,39 +11,60 @@ import Foundation import SwiftUI final class RepositoryListViewModel: BindableObject { + typealias SearchRepositories = (String) -> AnyPublisher, Never> let didChange: AnyPublisher private let _didChange = PassthroughSubject() private let _searchWithQuery = PassthroughSubject() - 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(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) } } diff --git a/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/View/WebView.swift b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/View/WebView.swift new file mode 100644 index 0000000..61d764a --- /dev/null +++ b/Other Projects/GitHub Search/GitHubSearchWithSwiftUI/View/WebView.swift @@ -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) -> SFSafariViewController { + return SFSafariViewController(url: url) + } + + func updateUIViewController(_ uiViewController: SFSafariViewController, context: UIViewControllerRepresentableContext) {} +}