diff --git a/CHANGELOG.md b/CHANGELOG.md index 22fbb36..9a0f59e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [v2.1.0](https://github.com/stleamist/BetterSafariView/releases/tag/v2.1.0) (2020-08-24) +### Changed +- Coordinators are now in charge of view controller presentations, following the structure of [VisualEffects](https://github.com/twostraws/VisualEffects). + ## [v2.0.1](https://github.com/stleamist/BetterSafariView/releases/tag/v2.0.1) (2020-08-22) ### Fixed - Fixed typos on markup diff --git a/README.md b/README.md index 7a3aba1..f8ebc2b 100644 --- a/README.md +++ b/README.md @@ -47,14 +47,14 @@ A better way to present a SFSafariViewController or start a ASWebAuthenticationS SwiftUI is a strong, intuitive way to build user interfaces, but was released with some part of existing elements missing. One example of those missing elements is the [`SFSafariViewController`](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller). -Fortunately, Apple provides a way to wrap UIKit elements into SwiftUI views. A common approach to place the `SFSafariViewController` inside SwiftUI is to create [a simple view representing a `SFSafariViewController`](/Demo/BetterSafariViewDemo/NaiveSafariView.swift), then present it with a [`sheet(isPresented:onDismiss:content:)`](https://developer.apple.com/documentation/swiftui/view/3352791-sheet) modifier or a [`NavigationLink`](https://developer.apple.com/documentation/swiftui/navigationlink) button (See [`ContentView.swift`](/Demo/BetterSafariViewDemo/ContentView.swift) in the demo project). +Fortunately, Apple provides a way to wrap UIKit elements into SwiftUI views. A common approach to place the `SFSafariViewController` inside SwiftUI is to create [a simple view representing a `SFSafariViewController`](/Demo/BetterSafariViewDemo/NaiveSafariView.swift), then present it with a [`sheet(isPresented:onDismiss:content:)`](https://developer.apple.com/documentation/swiftui/view/3352791-sheet) modifier or a [`NavigationLink`](https://developer.apple.com/documentation/swiftui/navigationlink) button (See [`RootView.swift`](/Demo/BetterSafariViewDemo/RootView.swift) in the demo project). However, there’s a problem in this approach: it can’t present the `SFSafariViewController` with its default presentation style — a push transition covers full screen. A sheet modifier can present the view only in a modal sheet, and a navigation link shows the two navigation bars at the top so we have to deal with them. This comes down to the conclusion that there’s no option to present it the right way except for using [`present(_:animated:completion:)`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621380-present) method of a [`UIViewController`](https://developer.apple.com/documentation/uikit/uiviewcontroller) instance, but it is prohibited and not a good design to access the [`UIHostingController`](https://developer.apple.com/documentation/swiftui/uihostingcontroller) directly from the SwiftUI view. `BetterSafariView` clearly achieves this goal by hosting a simple `UIViewController` to present a `SFSafariViewController` as a view’s background. In this way, a [`ASWebAuthenticationSession`](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) is also able to be started without any issue in SwiftUI. ## Usage -You can use it easily with the following modifiers in a similar way to presenting a sheet. +With the following modifiers, you can use it in a similar way to present a sheet. ### SafariView #### Modifiers @@ -147,7 +147,7 @@ struct ContentView: View { Add the following line to the `dependencies` in your [`Package.swift`](https://developer.apple.com/documentation/swift_packages/package) file: ```swift -.package(url: "https://github.com/stleamist/BetterSafariView.git", .upToNextMajor(from: "2.0.1")) +.package(url: "https://github.com/stleamist/BetterSafariView.git", .upToNextMajor(from: "2.1.0")) ``` Next, add `BetterSafariView` as a dependency for your targets: @@ -166,7 +166,7 @@ import PackageDescription let package = Package( name: "MyPackage", dependencies: [ - .package(url: "https://github.com/stleamist/BetterSafariView.git", .upToNextMajor(from: "2.0.1")) + .package(url: "https://github.com/stleamist/BetterSafariView.git", .upToNextMajor(from: "2.1.0")) ], targets: [ .target(name: "MyTarget", dependencies: ["BetterSafariView"]) diff --git a/Sources/BetterSafariView/SafariView.swift b/Sources/BetterSafariView/SafariView/SafariView.swift similarity index 51% rename from Sources/BetterSafariView/SafariView.swift rename to Sources/BetterSafariView/SafariView/SafariView.swift index 3c09726..8ccbc74 100644 --- a/Sources/BetterSafariView/SafariView.swift +++ b/Sources/BetterSafariView/SafariView/SafariView.swift @@ -162,214 +162,3 @@ public extension SafariView.Configuration { self.barCollapsingEnabled = barCollapsingEnabled } } - -struct SafariViewHosting: UIViewControllerRepresentable { - - // MARK: Representation - - @Binding var item: Item? - var onDismiss: (() -> Void)? = nil - var representationBuilder: (Item) -> SafariView - - // MARK: UIViewControllerRepresentable - - func makeUIViewController(context: Context) -> UIViewController { - return UIViewController() - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) { - // Ensure the following statements are executed once only after the `item` is changed - // by comparing current item to old one during frequent view updates. - let itemUpdateChange = context.coordinator.itemStorage.updateItem(item) - - switch itemUpdateChange { // (oldItem, newItem) - case (.none, .none): - () - case let (.none, .some(newItem)): - presentSafariViewController(from: uiViewController, in: context, using: newItem) - case let (.some(oldItem), .some(newItem)) where oldItem.id != newItem.id: - dismissSafariViewController(from: uiViewController) { - self.presentSafariViewController(from: uiViewController, in: context, using: newItem) - } - case let (.some, .some(newItem)): - updateSafariViewController(presentedBy: uiViewController, using: newItem) - case (.some, .none): - dismissSafariViewController(from: uiViewController) - } - } - - // MARK: Update Handlers - - private func presentSafariViewController(from uiViewController: UIViewController, in context: Context, using item: Item) { - let representation = representationBuilder(item) - let safariViewController = SFSafariViewController(url: representation.url, configuration: representation.configuration) - safariViewController.delegate = context.coordinator.safariViewControllerFinishDelegate - representation.applyModification(to: safariViewController) - - // There is a problem that page loading and parallel push animation are not working when a modifier is attached to the view in a `List`. - // As a workaround, use a `rootViewController` of the `window` for presenting. - // (Unlike the other view controllers, a view controller hosted by a cell doesn't have a parent, but has the same window.) - let presentingViewController = uiViewController.view.window?.rootViewController ?? uiViewController - presentingViewController.present(safariViewController, animated: true) - } - - private func updateSafariViewController(presentedBy uiViewController: UIViewController, using item: Item) { - guard let safariViewController = uiViewController.presentedViewController as? SFSafariViewController else { - return - } - let representation = representationBuilder(item) - representation.applyModification(to: safariViewController) - } - - private func dismissSafariViewController(from uiViewController: UIViewController, completion: (() -> Void)? = nil) { - - // Check if the `uiViewController` is a instance of the `SFSafariViewController` - // to prevent other controllers presented by the container view from being dismissed unintentionally. - guard uiViewController.presentedViewController is SFSafariViewController else { - return - } - uiViewController.dismiss(animated: true) { - self.handleDismissalWithoutResettingItemBinding() - completion?() - } - } - - // MARK: Dismissal Handlers - - // Used when the Safari view controller is finished by an item change during view update. - private func handleDismissalWithoutResettingItemBinding() { - self.onDismiss?() - } - - // Used when the Safari view controller is finished by a user interaction. - private func resetItemBindingAndHandleDismissal() { - self.item = nil - self.onDismiss?() - } - - // MARK: Coordinator - - func makeCoordinator() -> Coordinator { - return Coordinator(onFinished: resetItemBindingAndHandleDismissal) - } - - class Coordinator { - - var itemStorage: ItemStorage - let safariViewControllerFinishDelegate: SafariViewControllerFinishDelegate - - init(onFinished: @escaping () -> Void) { - self.itemStorage = ItemStorage() - self.safariViewControllerFinishDelegate = SafariViewControllerFinishDelegate(onFinished: onFinished) - } - } - - class SafariViewControllerFinishDelegate: NSObject, SFSafariViewControllerDelegate { - - private let onFinished: () -> Void - - init(onFinished: @escaping () -> Void) { - self.onFinished = onFinished - } - - func safariViewControllerDidFinish(_ controller: SFSafariViewController) { - onFinished() - } - } -} - -struct SafariViewPresentationModifier: ViewModifier { - - @Binding var isPresented: Bool - var onDismiss: (() -> Void)? = nil - var representationBuilder: () -> SafariView - - private var item: Binding { - .init( - get: { self.isPresented ? true : nil }, - set: { self.isPresented = ($0 != nil) } - ) - } - - // Converts `() -> Void` closure to `(Bool) -> Void` - private func itemRepresentationBuilder(bool: Bool) -> SafariView { - return representationBuilder() - } - - func body(content: Content) -> some View { - content.background( - SafariViewHosting( - item: item, - onDismiss: onDismiss, - representationBuilder: itemRepresentationBuilder - ) - ) - } -} - -struct ItemSafariViewPresentationModifier: ViewModifier { - - @Binding var item: Item? - var onDismiss: (() -> Void)? = nil - var representationBuilder: (Item) -> SafariView - - func body(content: Content) -> some View { - content.background( - SafariViewHosting( - item: $item, - onDismiss: onDismiss, - representationBuilder: representationBuilder - ) - ) - } -} - -public extension View { - - /// Presents a Safari view when a given condition is true. - /// - /// - Parameters: - /// - isPresented: A binding to whether the Safari view is presented. - /// - onDismiss: A closure executed when the Safari view dismisses. - /// - content: A closure returning the `SafariView` to present. - /// - func safariView( - isPresented: Binding, - onDismiss: (() -> Void)? = nil, - content representationBuilder: @escaping () -> SafariView - ) -> some View { - self.modifier( - SafariViewPresentationModifier( - isPresented: isPresented, - onDismiss: onDismiss, - representationBuilder: representationBuilder - ) - ) - } - - /// Presents a Safari view using the given item as a data source - /// for the `SafariView` to present. - /// - /// - Parameters: - /// - item: A binding to an optional source of truth for the Safari view. - /// When representing a non-`nil` item, the system uses `content` to - /// create a `SafariView` of the item. - /// If the identity changes, the system dismisses a - /// currently-presented Safari view and replace it by a new Safari view. - /// - onDismiss: A closure executed when the Safari view dismisses. - /// - content: A closure returning the `SafariView` to present. - /// - func safariView( - item: Binding, - onDismiss: (() -> Void)? = nil, - content representationBuilder: @escaping (Item) -> SafariView - ) -> some View { - self.modifier( - ItemSafariViewPresentationModifier( - item: item, - onDismiss: onDismiss, - representationBuilder: representationBuilder - ) - ) - } -} diff --git a/Sources/BetterSafariView/SafariView/SafariViewPresentationModifier.swift b/Sources/BetterSafariView/SafariView/SafariViewPresentationModifier.swift new file mode 100644 index 0000000..ce2dff2 --- /dev/null +++ b/Sources/BetterSafariView/SafariView/SafariViewPresentationModifier.swift @@ -0,0 +1,97 @@ +import SwiftUI + +struct SafariViewPresentationModifier: ViewModifier { + + @Binding var isPresented: Bool + var onDismiss: (() -> Void)? = nil + var representationBuilder: () -> SafariView + + private var item: Binding { + .init( + get: { self.isPresented ? true : nil }, + set: { self.isPresented = ($0 != nil) } + ) + } + + // Converts `() -> Void` closure to `(Bool) -> Void` + private func itemRepresentationBuilder(bool: Bool) -> SafariView { + return representationBuilder() + } + + func body(content: Content) -> some View { + content.background( + SafariViewPresenter( + item: item, + onDismiss: onDismiss, + representationBuilder: itemRepresentationBuilder + ) + ) + } +} + +struct ItemSafariViewPresentationModifier: ViewModifier { + + @Binding var item: Item? + var onDismiss: (() -> Void)? = nil + var representationBuilder: (Item) -> SafariView + + func body(content: Content) -> some View { + content.background( + SafariViewPresenter( + item: $item, + onDismiss: onDismiss, + representationBuilder: representationBuilder + ) + ) + } +} + +public extension View { + + /// Presents a Safari view when a given condition is true. + /// + /// - Parameters: + /// - isPresented: A binding to whether the Safari view is presented. + /// - onDismiss: A closure executed when the Safari view dismisses. + /// - content: A closure returning the `SafariView` to present. + /// + func safariView( + isPresented: Binding, + onDismiss: (() -> Void)? = nil, + content representationBuilder: @escaping () -> SafariView + ) -> some View { + self.modifier( + SafariViewPresentationModifier( + isPresented: isPresented, + onDismiss: onDismiss, + representationBuilder: representationBuilder + ) + ) + } + + /// Presents a Safari view using the given item as a data source + /// for the `SafariView` to present. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the Safari view. + /// When representing a non-`nil` item, the system uses `content` to + /// create a `SafariView` of the item. + /// If the identity changes, the system dismisses a + /// currently-presented Safari view and replace it by a new Safari view. + /// - onDismiss: A closure executed when the Safari view dismisses. + /// - content: A closure returning the `SafariView` to present. + /// + func safariView( + item: Binding, + onDismiss: (() -> Void)? = nil, + content representationBuilder: @escaping (Item) -> SafariView + ) -> some View { + self.modifier( + ItemSafariViewPresentationModifier( + item: item, + onDismiss: onDismiss, + representationBuilder: representationBuilder + ) + ) + } +} diff --git a/Sources/BetterSafariView/SafariView/SafariViewPresenter.swift b/Sources/BetterSafariView/SafariView/SafariViewPresenter.swift new file mode 100644 index 0000000..3f291c3 --- /dev/null +++ b/Sources/BetterSafariView/SafariView/SafariViewPresenter.swift @@ -0,0 +1,125 @@ +import SwiftUI +import SafariServices + +struct SafariViewPresenter: UIViewControllerRepresentable { + + // MARK: Representation + + @Binding var item: Item? + var onDismiss: (() -> Void)? = nil + var representationBuilder: (Item) -> SafariView + + // MARK: UIViewControllerRepresentable + + func makeCoordinator() -> Coordinator { + return Coordinator(parent: self) + } + + func makeUIViewController(context: Context) -> UIViewController { + return context.coordinator.uiViewController + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + context.coordinator.item = item + } +} + +extension SafariViewPresenter { + + class Coordinator: NSObject, SFSafariViewControllerDelegate { + + // MARK: Parent Copying + + private var parent: SafariViewPresenter + + init(parent: SafariViewPresenter) { + self.parent = parent + } + + // MARK: View Controller Holding + + let uiViewController = UIViewController() + + // MARK: Item Handling + + var item: Item? { + didSet(oldItem) { + handleItemChange(from: oldItem, to: item) + } + } + + // Ensure the proper presentation handler is executed only once + // during a one SwiftUI view update life cycle. + private func handleItemChange(from oldItem: Item?, to newItem: Item?) { + switch (oldItem, newItem) { + case (.none, .none): + () + case let (.none, .some(newItem)): + presentSafariViewController(with: newItem) + case let (.some(oldItem), .some(newItem)) where oldItem.id != newItem.id: + dismissSafariViewController(completion: { + self.presentSafariViewController(with: newItem) + }) + case let (.some, .some(newItem)): + updateSafariViewController(with: newItem) + case (.some, .none): + dismissSafariViewController() + } + } + + // MARK: Presentation Handlers + + private func presentSafariViewController(with item: Item) { + let representation = parent.representationBuilder(item) + let safariViewController = SFSafariViewController(url: representation.url, configuration: representation.configuration) + safariViewController.delegate = self + representation.applyModification(to: safariViewController) + + // There is a problem that page loading and parallel push animation are not working when a modifier is attached to the view in a `List`. + // As a workaround, use a `rootViewController` of the `window` for presenting. + // (Unlike the other view controllers, a view controller hosted by a cell doesn't have a parent, but has the same window.) + let presentingViewController = uiViewController.view.window?.rootViewController ?? uiViewController + presentingViewController.present(safariViewController, animated: true) + } + + private func updateSafariViewController(with item: Item) { + guard let safariViewController = uiViewController.presentedViewController as? SFSafariViewController else { + return + } + let representation = parent.representationBuilder(item) + representation.applyModification(to: safariViewController) + } + + private func dismissSafariViewController(completion: (() -> Void)? = nil) { + + // Check if the `uiViewController` is a instance of the `SFSafariViewController` + // to prevent other controllers presented by the container view from being dismissed unintentionally. + guard uiViewController.presentedViewController is SFSafariViewController else { + return + } + uiViewController.dismiss(animated: true) { + self.handleDismissalWithoutResettingItemBinding() + completion?() + } + } + + // MARK: Dismissal Handlers + + // Used when the Safari view controller is finished by an item change during view update. + private func handleDismissalWithoutResettingItemBinding() { + parent.onDismiss?() + } + + // Used when the Safari view controller is finished by a user interaction. + private func resetItemBindingAndHandleDismissal() { + parent.item = nil + parent.onDismiss?() + } + + // MARK: SFSafariViewControllerDelegate + + func safariViewControllerDidFinish(_ controller: SFSafariViewController) { + resetItemBindingAndHandleDismissal() + } + } +} diff --git a/Sources/BetterSafariView/Shared.swift b/Sources/BetterSafariView/Shared.swift index 5324844..3445401 100644 --- a/Sources/BetterSafariView/Shared.swift +++ b/Sources/BetterSafariView/Shared.swift @@ -7,14 +7,3 @@ extension Bool: Identifiable { extension URL: Identifiable { public var id: String { self.absoluteString } } - -struct ItemStorage { - - private var item: Item? - - mutating func updateItem(_ newItem: Item?) -> (oldItem: Item?, newItem: Item?) { - let oldItem = self.item - self.item = newItem - return (oldItem: oldItem, newItem: newItem) - } -} diff --git a/Sources/BetterSafariView/WebAuthenticationSession.swift b/Sources/BetterSafariView/WebAuthenticationSession.swift deleted file mode 100644 index 7812d7a..0000000 --- a/Sources/BetterSafariView/WebAuthenticationSession.swift +++ /dev/null @@ -1,283 +0,0 @@ -import SwiftUI -import SafariServices -import AuthenticationServices - -public typealias WebAuthenticationSessionError = ASWebAuthenticationSessionError - -// Used for getting a public completion handler to inject an assignment that sets `item` to `nil`. -// INFO: It's not possible to access a completion handler from an `ASWebAuthenticationSession` instance -// because it has no public getter and setter for that. -// -/// A session that an app uses to authenticate a user through a web service. -/// -/// Use a `WebAuthenticationSession` instance to authenticate a user through a web service, including one run by a third party. Initialize the session with a URL that points to the authentication webpage. A browser loads and displays the page, from which the user can authenticate. In iOS, the browser is a secure, embedded web view. In macOS, the system opens the user’s default browser if it supports web authentication sessions, or Safari otherwise. -/// -/// On completion, the service sends a callback URL to the session with an authentication token, and the session passes this URL back to the app through a completion handler. -/// -/// For more details, see [Authenticating a User Through a Web Service](https://developer.apple.com/documentation/authenticationservices/authenticating_a_user_through_a_web_service). -/// -public struct WebAuthenticationSession { - - public typealias CompletionHandler = ASWebAuthenticationSession.CompletionHandler - - // MARK: Representation Properties - - let url: URL - let callbackURLScheme: String? - let completionHandler: CompletionHandler - - /// Creates a web authentication session instance. - /// - /// - Parameters: - /// - URL: A URL with the `http` or `https` scheme pointing to the authentication webpage. - /// - callbackURLScheme: The custom URL scheme that the app expects in the callback URL. - /// - completionHandler: A completion handler the session calls when it completes successfully, or when the user cancels the session. - /// - public init( - url: URL, - callbackURLScheme: String?, - completionHandler: @escaping CompletionHandler - ) { - self.url = url - self.callbackURLScheme = callbackURLScheme - self.completionHandler = completionHandler - } - - // MARK: Modifiers - - var prefersEphemeralWebBrowserSession: Bool = false - - /// Configures whether the session should ask the browser for a private authentication session. - /// - /// Use `prefersEphemeralWebBrowserSession` to request that the browser doesn’t share cookies or other browsing data between the authentication session and the user’s normal browser session. Whether the request is honored depends on the user’s default web browser. Safari always honors the request. - /// - /// - Parameters: - /// - prefersEphemeralWebBrowserSession: A Boolean value that indicates whether the session should ask the browser for a private authentication session. - /// - public func prefersEphemeralWebBrowserSession(_ prefersEphemeralWebBrowserSession: Bool) -> Self { - var modified = self - modified.prefersEphemeralWebBrowserSession = prefersEphemeralWebBrowserSession - return modified - } - - // MARK: Modification Applier - - func applyModification(to webAuthenticationSession: ASWebAuthenticationSession) { - webAuthenticationSession.prefersEphemeralWebBrowserSession = self.prefersEphemeralWebBrowserSession - } -} - -// Used for providing `presentationContextProvider`, which is needed for `ASWebAuthenticationSession` to start its session. -// INFO: `ASWebAuthenticationPresentationContextProviding` provides an window -// to present an `SFAuthenticationViewController`, and usually presents the `SFAuthenticationViewController` -// by calling `present(_:animated:completion:)` method from a root view controller of the window. -class WebAuthenticationSessionViewController: UIViewController, ASWebAuthenticationPresentationContextProviding { - - // MARK: ASWebAuthenticationPresentationContextProviding - - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - return view.window! - } -} - -struct WebAuthenticationSessionHosting: UIViewControllerRepresentable { - - // MARK: Representation - - @Binding var item: Item? - var representationBuilder: (Item) -> WebAuthenticationSession - - // MARK: UIViewControllerRepresentable - - func makeUIViewController(context: Context) -> WebAuthenticationSessionViewController { - return WebAuthenticationSessionViewController() - } - - func updateUIViewController(_ uiViewController: WebAuthenticationSessionViewController, context: Context) { - - // To set a delegate for the presentation controller of an `SFAuthenticationViewController` as soon as possible, - // check the view controller presented by `uiViewController` then set it as a delegate on every view updates. - // INFO: `SFAuthenticationViewController` is a private subclass of `SFSafariViewController`. - setInteractiveDismissalDelegateToSafariViewController(presentedBy: uiViewController, in: context) - - // Ensure the following statements are executed once only after the item is changed - // by comparing current item to old one during frequent view updates. - let itemUpdateChange = context.coordinator.itemStorage.updateItem(item) - - switch itemUpdateChange { // (oldItem, newItem) - case (.none, .none): - () - case let (.none, .some(newItem)): - startWebAuthenticationSession(on: uiViewController, in: context, using: newItem) - case (.some, .some): - () - case (.some, .none): - cancelWebAuthenticationSession(in: context) - } - } - - // MARK: Update Handlers - - // There is a problem that `item` is not set to `nil` after the sheet is dismissed with pulling down - // because the completion handler is not called on this case due to a system bug. - // To resolve this issue, it sets `PresentationControllerDismissalDelegate` of `Coordinator` - // as a presentation controller delegate of `SFAuthenticationViewController` - // so that ensures the completion handler is always called. - private func setInteractiveDismissalDelegateToSafariViewController(presentedBy uiViewController: UIViewController, in context: Context) { - guard let safariViewController = uiViewController.presentedViewController as? SFSafariViewController else { - return - } - safariViewController.presentationController?.delegate = context.coordinator.interactiveDismissalDelegate - } - - private func startWebAuthenticationSession(on presentationContextProvider: ASWebAuthenticationPresentationContextProviding, in context: Context, using item: Item) { - let representation = representationBuilder(item) - let session = ASWebAuthenticationSession( - url: representation.url, - callbackURLScheme: representation.callbackURLScheme, - completionHandler: { (callbackURL, error) in - self.resetItemBinding() - representation.completionHandler(callbackURL, error) - } - ) - representation.applyModification(to: session) - session.presentationContextProvider = presentationContextProvider - - context.coordinator.session = session - session.start() - } - - private func cancelWebAuthenticationSession(in context: Context) { - context.coordinator.session?.cancel() - context.coordinator.session = nil - } - - // MARK: Dismissal Handlers - - private func resetItemBinding() { - self.item = nil - } - - // MARK: Coordinator - - func makeCoordinator() -> Coordinator { - return Coordinator(onInteractiveDismiss: resetItemBinding) - } - - class Coordinator { - - var session: ASWebAuthenticationSession? - var itemStorage: ItemStorage - let interactiveDismissalDelegate: InteractiveDismissalDelegate - - init(onInteractiveDismiss: @escaping () -> Void) { - self.itemStorage = ItemStorage() - self.interactiveDismissalDelegate = InteractiveDismissalDelegate(onInteractiveDismiss: onInteractiveDismiss) - } - } - - class InteractiveDismissalDelegate: NSObject, UIAdaptivePresentationControllerDelegate { - - private let onInteractiveDismiss: () -> Void - - init(onInteractiveDismiss: @escaping () -> Void) { - self.onInteractiveDismiss = onInteractiveDismiss - } - - func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - onInteractiveDismiss() - } - } -} - -struct WebAuthenticationSessionPresentationModifier: ViewModifier { - - @Binding var isPresented: Bool - var representationBuilder: () -> WebAuthenticationSession - - private var item: Binding { - .init( - get: { self.isPresented ? true : nil }, - set: { self.isPresented = ($0 != nil) } - ) - } - - // Converts `() -> Void` closure to `(Bool) -> Void` - private func itemRepresentationBuilder(bool: Bool) -> WebAuthenticationSession { - return representationBuilder() - } - - func body(content: Content) -> some View { - content.background( - WebAuthenticationSessionHosting( - item: item, - representationBuilder: itemRepresentationBuilder - ) - ) - } -} - -struct ItemWebAuthenticationSessionPresentationModifier: ViewModifier { - - @Binding var item: Item? - var representationBuilder: (Item) -> WebAuthenticationSession - - func body(content: Content) -> some View { - content.background( - WebAuthenticationSessionHosting( - item: $item, - representationBuilder: representationBuilder - ) - ) - } -} - -public extension View { - - /// Starts a web authentication session when a given condition is true. - /// - /// - Parameters: - /// - isPresented: A binding to whether the web authentication session should be started. - /// - content: A closure returning the `WebAuthenticationSession` to start. - /// - func webAuthenticationSession( - isPresented: Binding, - content representationBuilder: @escaping () -> WebAuthenticationSession - ) -> some View { - self.modifier( - WebAuthenticationSessionPresentationModifier( - isPresented: isPresented, - representationBuilder: representationBuilder - ) - ) - } - - // FIXME: Dismiss and replace the view if the identity changes - - /// Starts a web authentication session using the given item as a data source - /// for the `WebAuthenticationSession` to start. - /// - /// - Parameters: - /// - item: A binding to an optional source of truth for the web authentication session. - /// When representing a non-`nil` item, the system uses `content` to - /// create a session representation of the item. - /// If the identity changes, the system cancels a - /// currently-started session and replace it by a new session. - /// - content: A closure returning the `WebAuthenticationSession` to start. - /// - /// - Experiment: - /// The functionality that replaces a session on the `item`'s identity change is **not implemented**, - /// as there is no non-hacky way to be notified when the session's dismissal animation is completed. - /// - func webAuthenticationSession( - item: Binding, - content representationBuilder: @escaping (Item) -> WebAuthenticationSession - ) -> some View { - self.modifier( - ItemWebAuthenticationSessionPresentationModifier( - item: item, - representationBuilder: representationBuilder - ) - ) - } -} diff --git a/Sources/BetterSafariView/WebAuthenticationSession/WebAuthenticationPresentationModifier.swift b/Sources/BetterSafariView/WebAuthenticationSession/WebAuthenticationPresentationModifier.swift new file mode 100644 index 0000000..000fa56 --- /dev/null +++ b/Sources/BetterSafariView/WebAuthenticationSession/WebAuthenticationPresentationModifier.swift @@ -0,0 +1,93 @@ +import SwiftUI + +struct WebAuthenticationPresentationModifier: ViewModifier { + + @Binding var isPresented: Bool + var representationBuilder: () -> WebAuthenticationSession + + private var item: Binding { + .init( + get: { self.isPresented ? true : nil }, + set: { self.isPresented = ($0 != nil) } + ) + } + + // Converts `() -> Void` closure to `(Bool) -> Void` + private func itemRepresentationBuilder(bool: Bool) -> WebAuthenticationSession { + return representationBuilder() + } + + func body(content: Content) -> some View { + content.background( + WebAuthenticationPresenter( + item: item, + representationBuilder: itemRepresentationBuilder + ) + ) + } +} + +struct ItemWebAuthenticationPresentationModifier: ViewModifier { + + @Binding var item: Item? + var representationBuilder: (Item) -> WebAuthenticationSession + + func body(content: Content) -> some View { + content.background( + WebAuthenticationPresenter( + item: $item, + representationBuilder: representationBuilder + ) + ) + } +} + +public extension View { + + /// Starts a web authentication session when a given condition is true. + /// + /// - Parameters: + /// - isPresented: A binding to whether the web authentication session should be started. + /// - content: A closure returning the `WebAuthenticationSession` to start. + /// + func webAuthenticationSession( + isPresented: Binding, + content representationBuilder: @escaping () -> WebAuthenticationSession + ) -> some View { + self.modifier( + WebAuthenticationPresentationModifier( + isPresented: isPresented, + representationBuilder: representationBuilder + ) + ) + } + + // FIXME: Dismiss and replace the view if the identity changes + + /// Starts a web authentication session using the given item as a data source + /// for the `WebAuthenticationSession` to start. + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the web authentication session. + /// When representing a non-`nil` item, the system uses `content` to + /// create a session representation of the item. + /// If the identity changes, the system cancels a + /// currently-started session and replace it by a new session. + /// - content: A closure returning the `WebAuthenticationSession` to start. + /// + /// - Experiment: + /// The functionality that replaces a session on the `item`'s identity change is **not implemented**, + /// as there is no non-hacky way to be notified when the session's dismissal animation is completed. + /// + func webAuthenticationSession( + item: Binding, + content representationBuilder: @escaping (Item) -> WebAuthenticationSession + ) -> some View { + self.modifier( + ItemWebAuthenticationPresentationModifier( + item: item, + representationBuilder: representationBuilder + ) + ) + } +} diff --git a/Sources/BetterSafariView/WebAuthenticationSession/WebAuthenticationPresenter.swift b/Sources/BetterSafariView/WebAuthenticationSession/WebAuthenticationPresenter.swift new file mode 100644 index 0000000..31c8a1f --- /dev/null +++ b/Sources/BetterSafariView/WebAuthenticationSession/WebAuthenticationPresenter.swift @@ -0,0 +1,131 @@ +import SwiftUI +import SafariServices +import AuthenticationServices + +struct WebAuthenticationPresenter: UIViewControllerRepresentable { + + // MARK: Representation + + @Binding var item: Item? + var representationBuilder: (Item) -> WebAuthenticationSession + + // MARK: UIViewControllerRepresentable + + func makeCoordinator() -> Coordinator { + return Coordinator(parent: self) + } + + func makeUIViewController(context: Context) -> UIViewController { + return context.coordinator.uiViewController + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + + // To set a delegate for the presentation controller of an `SFAuthenticationViewController` as soon as possible, + // check the view controller presented by `uiViewController` then set it as a delegate on every view updates. + // INFO: `SFAuthenticationViewController` is a private subclass of `SFSafariViewController`. + context.coordinator.setInteractiveDismissalDelegateIfPossible() + + context.coordinator.item = item + } +} + +extension WebAuthenticationPresenter { + + class Coordinator: NSObject, ASWebAuthenticationPresentationContextProviding, UIAdaptivePresentationControllerDelegate { + + // MARK: Parent Copying + + private var parent: WebAuthenticationPresenter + + init(parent: WebAuthenticationPresenter) { + self.parent = parent + } + + // MARK: View Controller Holding + + let uiViewController = UIViewController() + private var session: ASWebAuthenticationSession? + + // MARK: Item Handling + + var item: Item? { + didSet(oldItem) { + handleItemChange(from: oldItem, to: item) + } + } + + // Ensure the proper presentation handler is executed only once + // during a one SwiftUI view update life cycle. + private func handleItemChange(from oldItem: Item?, to newItem: Item?) { + switch (oldItem, newItem) { + case (.none, .none): + () + case let (.none, .some(newItem)): + startWebAuthenticationSession(with: newItem) + case (.some, .some): + () + case (.some, .none): + cancelWebAuthenticationSession() + } + } + + // MARK: Presentation Handlers + + private func startWebAuthenticationSession(with item: Item) { + let representation = parent.representationBuilder(item) + let session = ASWebAuthenticationSession( + url: representation.url, + callbackURLScheme: representation.callbackURLScheme, + completionHandler: { (callbackURL, error) in + self.resetItemBinding() + representation.completionHandler(callbackURL, error) + } + ) + session.presentationContextProvider = self + representation.applyModification(to: session) + + self.session = session + session.start() + } + + private func cancelWebAuthenticationSession() { + session?.cancel() + session = nil + } + + // MARK: Dismissal Handlers + + private func resetItemBinding() { + parent.item = nil + } + + // MARK: ASWebAuthenticationPresentationContextProviding + + // INFO: `ASWebAuthenticationPresentationContextProviding` provides an window + // to present an `SFAuthenticationViewController`, and usually presents the `SFAuthenticationViewController` + // by calling `present(_:animated:completion:)` method from a root view controller of the window. + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return uiViewController.view.window! + } + + // MARK: UIAdaptivePresentationControllerDelegate + + // There is a problem that `item` is not set to `nil` after the sheet is dismissed with pulling down + // because the completion handler is not called on this case due to a system bug. + // To resolve this issue, set `Coordinator` as a presentation controller delegate of `SFAuthenticationViewController` + // so that ensures the completion handler is always called. + + func setInteractiveDismissalDelegateIfPossible() { + guard let safariViewController = uiViewController.presentedViewController as? SFSafariViewController else { + return + } + safariViewController.presentationController?.delegate = self + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + resetItemBinding() + } + } +} diff --git a/Sources/BetterSafariView/WebAuthenticationSession/WebAuthenticationSession.swift b/Sources/BetterSafariView/WebAuthenticationSession/WebAuthenticationSession.swift new file mode 100644 index 0000000..8b724d9 --- /dev/null +++ b/Sources/BetterSafariView/WebAuthenticationSession/WebAuthenticationSession.swift @@ -0,0 +1,68 @@ +import SwiftUI +import SafariServices +import AuthenticationServices + +// Used for getting a public completion handler to inject an assignment that sets `item` to `nil`. +// INFO: It's not possible to access a completion handler from an `ASWebAuthenticationSession` instance +// because it has no public getter and setter for that. +// +/// A session that an app uses to authenticate a user through a web service. +/// +/// Use a `WebAuthenticationSession` instance to authenticate a user through a web service, including one run by a third party. Initialize the session with a URL that points to the authentication webpage. A browser loads and displays the page, from which the user can authenticate. In iOS, the browser is a secure, embedded web view. In macOS, the system opens the user’s default browser if it supports web authentication sessions, or Safari otherwise. +/// +/// On completion, the service sends a callback URL to the session with an authentication token, and the session passes this URL back to the app through a completion handler. +/// +/// For more details, see [Authenticating a User Through a Web Service](https://developer.apple.com/documentation/authenticationservices/authenticating_a_user_through_a_web_service). +/// +public struct WebAuthenticationSession { + + public typealias CompletionHandler = ASWebAuthenticationSession.CompletionHandler + + // MARK: Representation Properties + + let url: URL + let callbackURLScheme: String? + let completionHandler: CompletionHandler + + /// Creates a web authentication session instance. + /// + /// - Parameters: + /// - URL: A URL with the `http` or `https` scheme pointing to the authentication webpage. + /// - callbackURLScheme: The custom URL scheme that the app expects in the callback URL. + /// - completionHandler: A completion handler the session calls when it completes successfully, or when the user cancels the session. + /// + public init( + url: URL, + callbackURLScheme: String?, + completionHandler: @escaping CompletionHandler + ) { + self.url = url + self.callbackURLScheme = callbackURLScheme + self.completionHandler = completionHandler + } + + // MARK: Modifiers + + var prefersEphemeralWebBrowserSession: Bool = false + + /// Configures whether the session should ask the browser for a private authentication session. + /// + /// Use `prefersEphemeralWebBrowserSession` to request that the browser doesn’t share cookies or other browsing data between the authentication session and the user’s normal browser session. Whether the request is honored depends on the user’s default web browser. Safari always honors the request. + /// + /// - Parameters: + /// - prefersEphemeralWebBrowserSession: A Boolean value that indicates whether the session should ask the browser for a private authentication session. + /// + public func prefersEphemeralWebBrowserSession(_ prefersEphemeralWebBrowserSession: Bool) -> Self { + var modified = self + modified.prefersEphemeralWebBrowserSession = prefersEphemeralWebBrowserSession + return modified + } + + // MARK: Modification Applier + + func applyModification(to webAuthenticationSession: ASWebAuthenticationSession) { + webAuthenticationSession.prefersEphemeralWebBrowserSession = self.prefersEphemeralWebBrowserSession + } +} + +public typealias WebAuthenticationSessionError = ASWebAuthenticationSessionError