diff --git a/App/UI/Settings/SettingsAboutView.swift b/App/UI/Settings/SettingsAboutView.swift index 2f841b7..e499404 100644 --- a/App/UI/Settings/SettingsAboutView.swift +++ b/App/UI/Settings/SettingsAboutView.swift @@ -23,8 +23,7 @@ struct SettingsAboutView: View { @State private var showingHashbangProductions = false var body: some View { - let safariConfig = SafariView.Configuration(entersReaderIfAvailable: false, - barCollapsingEnabled: false) + let safariConfig = SafariView.Configuration(entersReaderIfAvailable: false, barCollapsingEnabled: true) let guts = ScrollView { VStack(spacing: 15) { diff --git a/App/UI/SwiftUI Helpers/SafariView.swift b/App/UI/SwiftUI Helpers/SafariView.swift index 6c5a491..c5b60f4 100644 --- a/App/UI/SwiftUI Helpers/SafariView.swift +++ b/App/UI/SwiftUI Helpers/SafariView.swift @@ -2,34 +2,36 @@ // SafariView.swift // NewTerm (iOS) // -// Created by Chris Harper on 4/17/21. +// Created by Chris Harper on 11/20/21. // +#if os(iOS) + import SwiftUI import SafariServices public struct SafariView { - public typealias Configuration = SFSafariViewController.Configuration public typealias DismissButtonStyle = SFSafariViewController.DismissButtonStyle - - // MARK: - Representation Properties - + + public enum PresentationMode { + case navigationLink + case sheet + } + let url: URL let configuration: Configuration - + public init(url: URL, configuration: Configuration = .init()) { self.url = url self.configuration = configuration } - - // MARK: - Modifiers - + var preferredBarTintColor: UIColor? var preferredControlTintColor: UIColor? var dismissButtonStyle: DismissButtonStyle = .done - - @available(iOS 14, *) + + @available(iOS 14.0, *) public func preferredBarAccentColor(_ color: Color?) -> Self { var modified = self if let color = color { @@ -39,8 +41,8 @@ public struct SafariView { } return modified } - - @available(iOS 14, *) + + @available(iOS 14.0, *) public func preferredControlAccentColor(_ color: Color?) -> Self { var modified = self if let color = color { @@ -50,75 +52,67 @@ public struct SafariView { } return modified } - + @available(iOS, introduced: 13.0, deprecated: 14.0, renamed: "preferredBarAccentColor(_:)") public func preferredBarTintColor(_ color: UIColor?) -> Self { var modified = self modified.preferredBarTintColor = color return modified } - + @available(iOS, introduced: 13.0, deprecated: 14.0, renamed: "preferredControlAccentColor(_:)") public func preferredControlTintColor(_ color: UIColor?) -> Self { var modified = self modified.preferredControlTintColor = color return modified } - + public func dismissButtonStyle(_ style: DismissButtonStyle) -> Self { var modified = self modified.dismissButtonStyle = style return modified } - - // MARK: - Modification Applier - + func applyModification(to safariViewController: SFSafariViewController) { safariViewController.preferredBarTintColor = self.preferredBarTintColor safariViewController.preferredControlTintColor = self.preferredControlTintColor safariViewController.dismissButtonStyle = self.dismissButtonStyle } - } public extension SafariView.Configuration { - convenience init(entersReaderIfAvailable: Bool = false, barCollapsingEnabled: Bool = true) { self.init() self.entersReaderIfAvailable = entersReaderIfAvailable self.barCollapsingEnabled = barCollapsingEnabled } - } - extension SafariView: View { - public var body: some View { - Representable(parent: self) - .edgesIgnoringSafeArea(.all) + if #available(iOS 14.0, *) { + Representable(parent: self) + .ignoresSafeArea(.container, edges: .all) + } else { + Representable(parent: self) + .edgesIgnoringSafeArea(.all) + } } - @available(iOS 14.0, *) public func accentColor(_ accentColor: Color?) -> Self { return self.preferredControlAccentColor(accentColor) } - } extension SafariView { + struct Representable: UIViewControllerRepresentable { - - // MARK: - Parent Copying - private var parent: SafariView - + init(parent: SafariView) { self.parent = parent } - - // MARK: - UIViewControllerRepresentable - + func makeUIViewController(context: Context) -> SFSafariViewController { let safariViewController = SFSafariViewController( url: parent.url, @@ -129,145 +123,230 @@ extension SafariView { parent.applyModification(to: safariViewController) return safariViewController } - + func updateUIViewController(_ safariViewController: SFSafariViewController, context: Context) { parent.applyModification(to: safariViewController) } + } +} +struct SafariViewPresenter: UIViewRepresentable { + @Binding var item: Item? + var onDismiss: (() -> Void)? = nil + var representationBuilder: (Item) -> SafariView + + func makeCoordinator() -> Coordinator { + return Coordinator(parent: self) + } + + func makeUIView(context: Context) -> UIView { + return context.coordinator.uiView + } + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.parent = self + context.coordinator.item = item } } -struct SafariViewPresentationModifier: ViewModifier { +extension SafariViewPresenter { + + class Coordinator: NSObject, SFSafariViewControllerDelegate { + + // MARK: Parent Copying + + var parent: SafariViewPresenter + + init(parent: SafariViewPresenter) { + self.parent = parent + } + + let uiView = UIView() + private weak var safariViewController: SFSafariViewController? + + 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() { + self.presentSafariViewController(with: newItem) + } + case let (.some, .some(newItem)): + updateSafariViewController(with: newItem) + case (.some, .none): + dismissSafariViewController() + } + } + + 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) + + // Present a Safari view controller from the `viewController` of `UIViewRepresentable`, instead of `UIViewControllerRepresentable`. + // This fixes an issue where the Safari view controller is not presented properly + // when the `UIViewControllerRepresentable` is detached from the root view controller (e.g. `UIViewController` contained in `UITableViewCell`) + // while allowing it to be presented even on the modal sheets. + // Thanks to: Bohdan Hernandez Navia (@boherna) + guard let presentingViewController = uiView.viewController else { + self.resetItemBinding() + return + } + + presentingViewController.present(safariViewController, animated: true) + + self.safariViewController = safariViewController + } + + private func updateSafariViewController(with item: Item) { + guard let safariViewController = safariViewController else { + return + } + let representation = parent.representationBuilder(item) + representation.applyModification(to: safariViewController) + } + + private func dismissSafariViewController(completion: (() -> Void)? = nil) { + guard let safariViewController = safariViewController else { + return + } + + safariViewController.dismiss(animated: true) { + self.handleDismissal() + completion?() + } + } + + // MARK: Dismissal Handlers + + // Used when the `viewController` of `uiView` does not exist during the preparation of presentation. + private func resetItemBinding() { + parent.item = nil + } + + // Used when the Safari view controller is finished by an item change during view update. + private func handleDismissal() { + 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() + } + } +} +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( - onDismiss: onDismiss + item: item, + onDismiss: onDismiss, + representationBuilder: itemRepresentationBuilder ) ) } - } -struct ItemSafariViewPresentationModifier: ViewModifier { - +struct ItemSafariViewPresentationModifier: ViewModifier { + @Binding var item: Item? var onDismiss: (() -> Void)? = nil -// var representationBuilder: (Item) -> SafariView - + var representationBuilder: (Item) -> SafariView + func body(content: Content) -> some View { content.background( SafariViewPresenter( - onDismiss: onDismiss -// representationBuilder: representationBuilder + item: $item, + onDismiss: onDismiss, + representationBuilder: representationBuilder ) ) } - } -struct SafariViewPresenter: UIViewControllerRepresentable { - - // MARK: - Representation - 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 +public extension View { + func safariView(item: Binding, presentationMode: SafariView.PresentationMode = .navigationLink, url: URL, configuration: SafariView.Configuration, onDismiss: (() -> Void)? = nil) -> some View { + switch presentationMode { + case .sheet: + return AnyView(self.sheet(item: item, onDismiss: onDismiss) {_ in + SafariView(url: url, configuration: configuration) + }) + case .navigationLink: + return AnyView(self.modifier(ItemSafariViewPresentationModifier(item: item, onDismiss: onDismiss, representationBuilder: { _ in + SafariView(url: url, configuration: configuration) + }))) + } } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) { - context.coordinator.parent = self + + func safariView(isPresented: Binding, presentationMode: SafariView.PresentationMode = .navigationLink, url: URL, configuration: SafariView.Configuration, onDismiss: (() -> Void)? = nil) -> some View { + switch presentationMode { + case .sheet: + return AnyView(self.sheet(isPresented: isPresented) { + SafariView(url: url, configuration: configuration) + }) + case .navigationLink: + return AnyView(self.modifier(SafariViewPresentationModifier(isPresented: isPresented, onDismiss: onDismiss, representationBuilder: { + SafariView(url: url, configuration: configuration) + }))) + } } - } -extension SafariViewPresenter { - class Coordinator: NSObject, SFSafariViewControllerDelegate { - - // MARK: - Parent Copying - - var parent: SafariViewPresenter - - init(parent: SafariViewPresenter) { - self.parent = parent - } - - // MARK: - View Controller Holding - - let uiViewController = UIViewController() - - private func dismissSafariViewController(completion: (() -> Void)? = nil) { - let dismissCompletion: () -> Void = { - self.handleDismissalWithoutResettingItemBinding() - completion?() - } - - guard uiViewController.presentedViewController != nil else { - dismissCompletion() - return - } - - guard let safariViewController = uiViewController.presentedViewController as? SFSafariViewController else { - return - } - safariViewController.dismiss(animated: true, completion: dismissCompletion) - } - - // MARK: - Dismissal Handlers - - private func handleDismissalWithoutResettingItemBinding() { - parent.onDismiss?() - } - - private func resetItemBindingAndHandleDismissal() { - parent.onDismiss?() - } - - // MARK: - SFSafariViewControllerDelegate - - func safariViewControllerDidFinish(_ controller: SFSafariViewController) { - resetItemBindingAndHandleDismissal() +extension UIView { + var viewController: UIViewController? { + if let nextResponder = self.next as? UIViewController { + return nextResponder + } else if let nextResponder = self.next as? UIView { + return nextResponder.viewController + } else { + return nil } - } } -extension View { - - func safariView(isPresented: Binding, url: URL, configuration: SafariView.Configuration) -> some View { - #if targetEnvironment(macCatalyst) - return self.onTapGesture { - UIApplication.shared.open(url, - options: [:], - completionHandler: nil) - } - #else - return self.sheet(isPresented: isPresented) { - SafariView(url: url, - configuration: configuration) - } - #endif - } +extension Bool: Identifiable { + public var id: Bool { self } +} +extension URL: Identifiable { + public var id: String { self.absoluteString } } + +#endif +