Skip to content

Commit

Permalink
Bump to v2.1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
stleamist committed Aug 24, 2020
2 parents daeac97 + a6c27e4 commit 2efb01a
Show file tree
Hide file tree
Showing 10 changed files with 522 additions and 509 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,214 +162,3 @@ public extension SafariView.Configuration {
self.barCollapsingEnabled = barCollapsingEnabled
}
}

struct SafariViewHosting<Item: Identifiable>: 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<Item>
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<Bool?> {
.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<Item: Identifiable>: 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<Bool>,
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: Identifiable>(
item: Binding<Item?>,
onDismiss: (() -> Void)? = nil,
content representationBuilder: @escaping (Item) -> SafariView
) -> some View {
self.modifier(
ItemSafariViewPresentationModifier(
item: item,
onDismiss: onDismiss,
representationBuilder: representationBuilder
)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import SwiftUI

struct SafariViewPresentationModifier: ViewModifier {

@Binding var isPresented: Bool
var onDismiss: (() -> Void)? = nil
var representationBuilder: () -> SafariView

private var item: Binding<Bool?> {
.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<Item: Identifiable>: 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<Bool>,
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: Identifiable>(
item: Binding<Item?>,
onDismiss: (() -> Void)? = nil,
content representationBuilder: @escaping (Item) -> SafariView
) -> some View {
self.modifier(
ItemSafariViewPresentationModifier(
item: item,
onDismiss: onDismiss,
representationBuilder: representationBuilder
)
)
}
}
Loading

0 comments on commit 2efb01a

Please sign in to comment.