From 99f6c50dcfea68649bed63d963258840f51b43bf Mon Sep 17 00:00:00 2001 From: Varun Santhanam Date: Fri, 22 Mar 2024 23:17:45 -0700 Subject: [PATCH] Refactor SafariView presentation modifiers --- Package.swift | 27 +- Sources/SafariUI/SafariUI.docc/SafariView.md | 2 +- Sources/SafariUI/SafariUI.docc/View.md | 2 +- Sources/SafariView/BoolPresentation.swift | 419 +++++++++++++ Sources/SafariView/Environment.swift | 8 + Sources/SafariView/ItemPresentation.swift | 363 +++++++++++ Sources/SafariView/Modifiers.swift | 42 +- Sources/SafariView/Presentation.swift | 276 --------- Sources/SafariView/SafariView.swift | 585 +++--------------- .../SafariView/WrappedItemPresentation.swift | 172 +++++ 10 files changed, 1110 insertions(+), 786 deletions(-) create mode 100644 Sources/SafariView/BoolPresentation.swift create mode 100644 Sources/SafariView/ItemPresentation.swift delete mode 100644 Sources/SafariView/Presentation.swift create mode 100644 Sources/SafariView/WrappedItemPresentation.swift diff --git a/Package.swift b/Package.swift index 99d700161..5355f0593 100644 --- a/Package.swift +++ b/Package.swift @@ -12,25 +12,40 @@ let package = Package( products: [ .library( name: "SafariUI", - targets: ["SafariUI"] + targets: [ + "SafariUI" + ] ), .library( name: "SafariView", - targets: ["SafariView"] + targets: [ + "SafariView" + ] ), .library( name: "WebAuthentication", - targets: ["WebAuthentication"] + targets: [ + "WebAuthentication" + ] ), ], dependencies: [ - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), - .package(url: "https://github.com/nicklockwood/SwiftFormat", exact: "0.51.15") + .package( + url: "https://github.com/apple/swift-docc-plugin", + from: "1.0.0" + ), + .package( + url: "https://github.com/nicklockwood/SwiftFormat", + exact: "0.51.15" + ) ], targets: [ .target( name: "SafariUI", - dependencies: ["SafariView", "WebAuthentication"] + dependencies: [ + "SafariView", + "WebAuthentication" + ] ), .target( name: "SafariView", diff --git a/Sources/SafariUI/SafariUI.docc/SafariView.md b/Sources/SafariUI/SafariUI.docc/SafariView.md index 56a5d81b7..08cea8e6c 100644 --- a/Sources/SafariUI/SafariUI.docc/SafariView.md +++ b/Sources/SafariUI/SafariUI.docc/SafariView.md @@ -20,7 +20,7 @@ UI features include the following: You can present a `SafariView` using the built-in presentation view modifiers: - ``SwiftUI/View/safari(isPresented:onDismiss:safariView:)`` -- ``SwiftUI/View/safari(url:onDismiss:safariView:)`` +- ``SwiftUI/View/safari(isPresented:url:onDismiss:)`` - ``SwiftUI/View/safari(item:onDismiss:safariView:)`` - ``SwiftUI/View/safari(item:id:onDismiss:safariView:)`` diff --git a/Sources/SafariUI/SafariUI.docc/View.md b/Sources/SafariUI/SafariUI.docc/View.md index e3ec30ca3..d7c810845 100644 --- a/Sources/SafariUI/SafariUI.docc/View.md +++ b/Sources/SafariUI/SafariUI.docc/View.md @@ -18,7 +18,7 @@ SwiftUI view modifiers used to configure a ``SafariView`` or a ``WebAuthenticati ### SafariView Presentation - ``SwiftUI/View/safari(isPresented:onDismiss:safariView:)`` -- ``SwiftUI/View/safari(url:onDismiss:safariView:)`` +- ``SwiftUI/View/safari(isPresented:url:onDismiss:)`` - ``SwiftUI/View/safari(item:onDismiss:safariView:)`` - ``SwiftUI/View/safari(item:id:onDismiss:safariView:)`` diff --git a/Sources/SafariView/BoolPresentation.swift b/Sources/SafariView/BoolPresentation.swift new file mode 100644 index 000000000..3f1f476f4 --- /dev/null +++ b/Sources/SafariView/BoolPresentation.swift @@ -0,0 +1,419 @@ +// SafariUI +// BoolPresentation.swift +// +// MIT License +// +// Copyright (c) 2023 Varun Santhanam +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation +import SafariServices +import SwiftUI +import UIKit + +@available(iOS 14.0, macCatalyst 14.0, *) +public extension View { + + /// Presents a ``SafariView`` when a binding to a Boolean value that you provide is `true`. + /// + /// Use this method when you want to present a ``SafariView`` to the user when a Boolean value you provide is true. + /// The example below displays a modal view of the mockup for a software license agreement when the user toggles the `isShowingSafari` variable by clicking or tapping on the “Show License Agreement” button: + /// + /// ```swift + /// import Foundation + /// import SafariView + /// import SwiftUI + /// + /// struct ShowLicenseAgreement: View { + /// + /// let licenseAgreementURL: URL + /// + /// @State private var isShowingSafari = false + /// + /// var body: some View { + /// Button { + /// isShowingSafari.toggle() + /// } label: { + /// Text("Show License Agreement") + /// } + /// .safari(isPresented: $isShowingSafari, + /// onDismiss: didDismiss) { + /// SafariView(url: licenseAgreementURL) + /// } + /// } + /// + /// func didDismiss() { + /// // Handle the dismissing action. + /// } + /// + /// } + /// ``` + /// + /// - Parameters: + /// - isPresented: A binding to a Boolean value that determines whether to present the ``SafariView`` that you create in the modifier’s content closure. + /// - onDismiss: The closure to execute when dismissing the ``SafariView`` + /// - safariView: A closure that returns the ``SafariView`` to present + /// - Returns: The modified view + func safari( + isPresented: Binding, + onDismiss: (() -> Void)? = nil, + safariView: () -> SafariView + ) -> some View { + ModifiedContent( + content: self, + modifier: IsPresentedModifier( + isPresented: isPresented, + safariView: safariView(), + onDismiss: onDismiss + ) + ) + } + + /// Presents a ``SafariView`` when a binding to a Boolean value that you provide is `true`. + /// + /// Use this method when you want to present a ``SafariView`` to the user when a Boolean value you provide is true. + /// The example below displays a modal view of the mockup for a software license agreement when the user toggles the `isShowingSafari` variable by clicking or tapping on the “Show License Agreement” button: + /// + /// ```swift + /// import Foundation + /// import SafariView + /// import SwiftUI + /// + /// struct ShowLicenseAgreement: View { + /// + /// let licenseAgreementURL: URL + /// + /// @State private var isShowingSafari = false + /// + /// var body: some View { + /// Button { + /// isShowingSafari.toggle() + /// } label: { + /// Text("Show License Agreement") + /// } + /// .safari(isPresented: $isShowingSafari, + /// url: licenseAgreementURL + /// onDismiss: didDismiss) + /// } + /// + /// func didDismiss() { + /// // Handle the dismissing action. + /// } + /// + /// } + /// ``` + /// + /// - Parameters: + /// - isPresented: A binding to a Boolean value that determines whether to present the ``SafariView`` that you create in the modifier’s content closure. + /// - url: The URL to load in the presented ``SafariView`` + /// - onDismiss: The closure to execute when dismissing the ``SafariView`` + /// - Returns: The modified view + func safari( + isPresented: Binding, + url: URL, + onDismiss: (() -> Void)? = nil + ) -> some View { + ModifiedContent( + content: self, + modifier: IsPresentedModifier( + isPresented: isPresented, + safariView: SafariView( + url: url + ), + onDismiss: onDismiss + ) + ) + } + +} + +@available(iOS 14.0, macCatalyst 14.0, *) +private struct IsPresentedModifier: ViewModifier { + + // MARK: - Initializer + + init( + isPresented: Binding, + safariView: SafariView, + onDismiss: (() -> Void)? + ) { + self.isPresented = isPresented + self.safariView = safariView + self.onDismiss = onDismiss + } + + // MARK: - ViewModifier + + @MainActor + @ViewBuilder + func body(content: Content) -> some View { + content + .background( + Presenter( + isPresented: isPresented, + url: safariView.url, + onInitialLoad: safariView.onInitialLoad, + onInitialRedirect: safariView.onInitialRedirect, + onOpenInBrowser: safariView.onOpenInBrowser, + onDismiss: onDismiss, + activityButton: safariView.activityButton, + eventAttribution: safariView.eventAttribution + ) + ) + } + + // MARK: - Private + + private struct Presenter: UIViewRepresentable { + + init( + isPresented: Binding, + url: URL, + onInitialLoad: ((Bool) -> Void)?, + onInitialRedirect: ((URL) -> Void)?, + onOpenInBrowser: (() -> Void)?, + onDismiss: (() -> Void)?, + activityButton: AnyObject?, + eventAttribution: AnyObject? + ) { + _isPresented = isPresented + self.url = url + self.onInitialLoad = onInitialLoad + self.onInitialRedirect = onInitialRedirect + self.onOpenInBrowser = onOpenInBrowser + self.onDismiss = onDismiss + self.activityButton = activityButton + self.eventAttribution = eventAttribution + } + + typealias UIViewType = UIView + + func makeCoordinator() -> Coordinator { + .init( + isPresented: isPresented, + url: url, + bindingSetter: { newValue in isPresented = newValue }, + onInitialLoad: onInitialLoad, + onInitialRedirect: onInitialRedirect, + onOpenInBrowser: onOpenInBrowser, + onDismiss: onDismiss, + activityButton: activityButton, + eventAttribution: eventAttribution + ) + } + + func makeUIView(context: Context) -> UIViewType { + context.coordinator.entersReaderIfAvailable = entersReaderIfAvailable + context.coordinator.barCollapsingEnabled = barCollapsingEnabled + context.coordinator.barTintColor = barTintColor + context.coordinator.controlTintColor = controlTintColor + context.coordinator.dismissButtonStyle = dismissButtonStyle + context.coordinator.includedActivities = includedActivities + context.coordinator.excludedActivityTypes = excludedActivityTypes + context.coordinator.isPresented = isPresented + return context.coordinator.view + } + + func updateUIView(_ uiView: UIViewType, context: Context) { + context.coordinator.entersReaderIfAvailable = entersReaderIfAvailable + context.coordinator.barCollapsingEnabled = barCollapsingEnabled + context.coordinator.barTintColor = barTintColor + context.coordinator.controlTintColor = controlTintColor + context.coordinator.dismissButtonStyle = dismissButtonStyle + context.coordinator.includedActivities = includedActivities + context.coordinator.excludedActivityTypes = excludedActivityTypes + context.coordinator.isPresented = isPresented + } + + final class Coordinator: NSObject, SFSafariViewControllerDelegate { + + init( + isPresented: Bool, + url: URL, + bindingSetter: @escaping (Bool) -> Void, + onInitialLoad: ((Bool) -> Void)?, + onInitialRedirect: ((URL) -> Void)?, + onOpenInBrowser: (() -> Void)?, + onDismiss: (() -> Void)?, + activityButton: AnyObject?, + eventAttribution: AnyObject? + ) { + self.isPresented = isPresented + self.url = url + self.bindingSetter = bindingSetter + self.onInitialLoad = onInitialLoad + self.onInitialRedirect = onInitialRedirect + self.onOpenInBrowser = onOpenInBrowser + self.onDismiss = onDismiss + self.activityButton = activityButton + self.eventAttribution = eventAttribution + } + + let view = UIView() + + var isPresented: Bool = false { + didSet { + switch (oldValue, isPresented) { + case (false, false): + break + case (false, true): + presentSafari() + case (true, false): + dismissSafari() + case (true, true): + updateSafari() + } + } + } + + var entersReaderIfAvailable: Bool = false + var barCollapsingEnabled: Bool = false + var barTintColor: Color? = nil + var controlTintColor: Color = .accentColor + var dismissButtonStyle: SafariView.DismissButtonStyle = .default + var includedActivities: SafariView.IncludedActivities = [] + var excludedActivityTypes: SafariView.ExcludedActivityTypes = [] + + func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) { + onInitialLoad?(didLoadSuccessfully) + } + + func safariViewController(_ controller: SFSafariViewController, initialLoadDidRedirectTo URL: URL) { + onInitialRedirect?(URL) + } + + func safariViewControllerDidFinish(_ controller: SFSafariViewController) { + onDismiss?() + bindingSetter(false) + } + + func safariViewController( + _ controller: SFSafariViewController, + activityItemsFor URL: URL, + title: String? + ) -> [UIActivity] { + includedActivities(url: URL, pageTitle: title) + } + + func safariViewController( + _ controller: SFSafariViewController, + excludedActivityTypesFor URL: URL, + title: String? + ) -> [UIActivity.ActivityType] { + excludedActivityTypes(url: URL, pageTitle: title) + } + + func safariViewControllerWillOpenInBrowser(_ controller: SFSafariViewController) { + onOpenInBrowser?() + } + + // MARK: - Private + + private weak var safariViewController: SFSafariViewController? + private let url: URL + private let bindingSetter: (Bool) -> Void + private let onInitialLoad: ((Bool) -> Void)? + private let onInitialRedirect: ((URL) -> Void)? + private let onOpenInBrowser: (() -> Void)? + private let onDismiss: (() -> Void)? + private let activityButton: AnyObject? + private let eventAttribution: AnyObject? + + private func presentSafari() { + let vc = SFSafariViewController(url: url, configuration: buildConfiguration()) + vc.delegate = self + vc.preferredBarTintColor = barTintColor.map(UIColor.init) + vc.preferredControlTintColor = UIColor(controlTintColor) + vc.dismissButtonStyle = dismissButtonStyle.uikit + guard let presenting = view.controller else { + bindingSetter(false) + return + } + presenting.present(vc, animated: true) + safariViewController = vc + + } + + private func dismissSafari() { + safariViewController?.dismiss(animated: true) + } + + private func updateSafari() { + safariViewController?.preferredBarTintColor = barTintColor.map(UIColor.init) + safariViewController?.preferredControlTintColor = UIColor(controlTintColor) + safariViewController?.dismissButtonStyle = dismissButtonStyle.uikit + } + + private func buildConfiguration() -> SFSafariViewController.Configuration { + let configuration = SFSafariViewController.Configuration() + configuration.entersReaderIfAvailable = entersReaderIfAvailable + configuration.barCollapsingEnabled = barCollapsingEnabled + if #available(iOS 15.0, macCatalyst 15.0, *), + let activityButton { + configuration.activityButton = unsafeDowncast(activityButton, to: SafariView.ActivityButton.self) + } + if #available(iOS 15.2, *), + let eventAttribution { + configuration.eventAttribution = unsafeDowncast(eventAttribution, to: UIEventAttribution.self) + } + return configuration + } + + } + + @Binding + private var isPresented: Bool + + @Environment(\.safariViewEntersReaderIfAvailable) + private var entersReaderIfAvailable: Bool + + @Environment(\.safariViewBarCollapsingEnabled) + private var barCollapsingEnabled: Bool + + @Environment(\.safariViewBarTintColor) + private var barTintColor: Color? + + @Environment(\.safariViewControlTintColor) + private var controlTintColor: Color + + @Environment(\.safariViewDismissButtonStyle) + private var dismissButtonStyle: SafariView.DismissButtonStyle + + @Environment(\.safariViewIncludedActivities) + private var includedActivities: SafariView.IncludedActivities + + @Environment(\.safariViewExcludedActivityTypes) + private var excludedActivityTypes: SafariView.ExcludedActivityTypes + + private let url: URL + private let onInitialLoad: ((Bool) -> Void)? + private let onInitialRedirect: ((URL) -> Void)? + private let onOpenInBrowser: (() -> Void)? + private let onDismiss: (() -> Void)? + private let activityButton: AnyObject? + private let eventAttribution: AnyObject? + + } + + private let isPresented: Binding + private let safariView: SafariView + private let onDismiss: (() -> Void)? + +} diff --git a/Sources/SafariView/Environment.swift b/Sources/SafariView/Environment.swift index d905f53a7..5b476b9a8 100644 --- a/Sources/SafariView/Environment.swift +++ b/Sources/SafariView/Environment.swift @@ -64,6 +64,7 @@ public extension EnvironmentValues { } +@available(iOS 14.0, macCatalyst 14.0, *) extension EnvironmentValues { var safariViewEntersReaderIfAvailable: Bool { @@ -93,6 +94,7 @@ extension EnvironmentValues { } +@available(iOS 14.0, macCatalyst 14.0, *) private struct SafariViewEntersReaderIfAvailableEnvironmentKey: EnvironmentKey { // MARK: - EnvironmentKey @@ -103,6 +105,7 @@ private struct SafariViewEntersReaderIfAvailableEnvironmentKey: EnvironmentKey { } +@available(iOS 14.0, macCatalyst 14.0, *) private struct SafariViewBarCollapsingEnabledEnvironmentKey: EnvironmentKey { // MARK: - EnvironmentKey @@ -113,6 +116,7 @@ private struct SafariViewBarCollapsingEnabledEnvironmentKey: EnvironmentKey { } +@available(iOS 14.0, macCatalyst 14.0, *) private struct SafariViewControlTintColorEnvironmentKey: EnvironmentKey { // MARK: - EnvironmentKey @@ -123,6 +127,7 @@ private struct SafariViewControlTintColorEnvironmentKey: EnvironmentKey { } +@available(iOS 14.0, macCatalyst 14.0, *) private struct SafariViewBarTintColorEnvironmentKey: EnvironmentKey { // MARK: - EnvironmentKey @@ -133,6 +138,7 @@ private struct SafariViewBarTintColorEnvironmentKey: EnvironmentKey { } +@available(iOS 14.0, macCatalyst 14.0, *) private struct SafariViewDismissButtonStyleEnvironmentKey: EnvironmentKey { // MARK: - EnvironmentKey @@ -143,6 +149,7 @@ private struct SafariViewDismissButtonStyleEnvironmentKey: EnvironmentKey { } +@available(iOS 14.0, macCatalyst 14.0, *) private struct SafariViewIncludedActivitiesEnvironmentKey: EnvironmentKey { // MARK: - EnvironmentKey @@ -153,6 +160,7 @@ private struct SafariViewIncludedActivitiesEnvironmentKey: EnvironmentKey { } +@available(iOS 14.0, macCatalyst 14.0, *) private struct SafariViewExcludedActivityTypesEnvironmentKey: EnvironmentKey { // MARK: - EnvironmentKey diff --git a/Sources/SafariView/ItemPresentation.swift b/Sources/SafariView/ItemPresentation.swift new file mode 100644 index 000000000..caeee2ba3 --- /dev/null +++ b/Sources/SafariView/ItemPresentation.swift @@ -0,0 +1,363 @@ +// SafariUI +// ItemPresentation.swift +// +// MIT License +// +// Copyright (c) 2023 Varun Santhanam +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation +import SafariServices +import SwiftUI +import UIKit + +@available(iOS 14.0, macCatalyst 14.0, *) +public extension View { + + /// Presents a ``SafariView`` using the given item as a data source for the view’s content. + /// + /// Use this method when you need to present a ``SafariView`` with content from a custom data source. + /// The example below shows a custom data source `InventoryItem` that the closure uses to populate the ``SafariView`` before it is shown to the user: + /// + /// ```swift + /// import Foundation + /// import SafariView + /// import SwiftUI + /// + /// struct InventoryItem: Identifiable { + /// let id: Int + /// let title: String + /// let url: URL + /// } + /// + /// struct InventoryList: View { + /// + /// init(inventory: [InventoryItem]) { + /// self.inventory = inventory + /// } + /// + /// + /// var inventory: [InventoryItem] + /// + /// @State private var selectedItem: InventoryItem? + /// + /// var body: some View { + /// List(inventory) { inventoryItem in + /// Button(action: { + /// self.selectedItem = inventoryItem + /// }) { + /// Text(inventoryItem.title) + /// } + /// } + /// .safari(item: $selectedItem, + /// onDismiss: dismissAction) { item in + /// SafariView(url: item.url) + /// } + /// } + /// + /// + /// func didDismiss() { + /// // Handle the dismissing action. + /// } + /// + /// } + /// ``` + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the ``SafariView``. When item is non-nil, the system passes the item’s content to the modifier’s closure. You display this content in a ``SafariView`` that you create that the system displays to the user. If item changes, the system dismisses the ``SafariView`` and replaces it with a new one using the same process. + /// - onDismiss: The closure to execute when dismissing the ``SafariView`` + /// - safariView: A closure that returns the ``SafariView`` to present + /// - Returns: The modified view + func safari( + item: Binding, + onDismiss: (() -> Void)? = nil, + safariView: @escaping (Item) -> SafariView + ) -> some View where Item: Identifiable { + ModifiedContent( + content: self, + modifier: ItemModifier( + item: item, + safariView: safariView + ) + ) + } + +} + +@available(iOS 14.0, macCatalyst 14.0, *) +private struct ItemModifier: ViewModifier where Item: Identifiable { + + // MARK: - Initializers + + init( + item: Binding, + safariView: @escaping (Item) -> SafariView, + onDismiss: (() -> Void)? = nil + ) { + _item = item + self.safariView = safariView + self.onDismiss = onDismiss + } + + // MARK: - ViewModifier + + @MainActor + @ViewBuilder + func body(content: Content) -> some View { + content + .background( + Presenter( + item: $item, + safariView: safariView, + onDismiss: onDismiss + ) + ) + } + + // MARK: - Private + + private struct Presenter: UIViewRepresentable { + + // MARK: - Initializers + + init( + item: Binding, + safariView: @escaping (Item) -> SafariView, + onDismiss: (() -> Void)? + ) { + _item = item + self.safariView = safariView + self.onDismiss = onDismiss + } + + // MARK: - UIViewRepresentable + + typealias UIViewType = UIView + + final class Coordinator: NSObject, SFSafariViewControllerDelegate { + + // MARK: - Initializer + + init( + item: Item? = nil, + safariView: @escaping (Item) -> SafariView, + bindingSetter: @escaping (Item?) -> Void, + onDismiss: (() -> Void)? + ) { + self.item = item + self.safariView = safariView + self.bindingSetter = bindingSetter + self.onDismiss = onDismiss + } + + // MARK: - API + + var item: Item? = nil { + didSet { + switch (oldValue, item) { + case (.none, .none): + break + case let (.none, .some(item)): + presentSafari(item) + case (.some, .none): + dismissSafari() + case (.some, .some): + updateSafari() + } + } + } + + let view = UIView() + var entersReaderIfAvailable: Bool = false + var barCollapsingEnabled: Bool = false + var barTintColor: Color? = nil + var controlTintColor: Color = .accentColor + var dismissButtonStyle: SafariView.DismissButtonStyle = .default + var includedActivities: SafariView.IncludedActivities = [] + var excludedActivityTypes: SafariView.ExcludedActivityTypes = [] + + // MARK: - SFSafariViewDelegate + + func safariViewController( + _ controller: SFSafariViewController, + didCompleteInitialLoad didLoadSuccessfully: Bool + ) { + onInitialLoad?(didLoadSuccessfully) + } + + func safariViewController( + _ controller: SFSafariViewController, + initialLoadDidRedirectTo URL: URL + ) { + onInitialRedirect?(URL) + } + + func safariViewControllerDidFinish( + _ controller: SFSafariViewController + ) { + onDismiss?() + bindingSetter(nil) + } + + func safariViewController( + _ controller: SFSafariViewController, + activityItemsFor URL: URL, + title: String? + ) -> [UIActivity] { + includedActivities(url: URL, pageTitle: title) + } + + func safariViewController( + _ controller: SFSafariViewController, + excludedActivityTypesFor URL: URL, + title: String? + ) -> [UIActivity.ActivityType] { + excludedActivityTypes(url: URL, pageTitle: title) + } + + func safariViewControllerWillOpenInBrowser(_ controller: SFSafariViewController) { + onOpenInBrowser?() + } + + // MARK: - Private + + private weak var safariViewController: SFSafariViewController? + private let safariView: (Item) -> SafariView + private var bindingSetter: (Item?) -> Void + private var onInitialLoad: ((Bool) -> Void)? + private var onInitialRedirect: ((URL) -> Void)? + private var onOpenInBrowser: (() -> Void)? + private var onDismiss: (() -> Void)? + private var activityButton: AnyObject? + private var eventAttribution: AnyObject? + + private func presentSafari(_ item: Item) { + let safari = safariView(item) + onInitialLoad = safari.onInitialLoad + onInitialRedirect = safari.onInitialRedirect + onOpenInBrowser = safari.onOpenInBrowser + activityButton = safari.activityButton + eventAttribution = safari.eventAttribution + let vc = SFSafariViewController(url: safari.url, configuration: buildConfiguration()) + vc.delegate = self + vc.preferredBarTintColor = barTintColor.map(UIColor.init) + vc.preferredControlTintColor = UIColor(controlTintColor) + vc.dismissButtonStyle = dismissButtonStyle.uikit + guard let presenting = view.controller else { + bindingSetter(nil) + return + } + presenting.present(vc, animated: true) + safariViewController = vc + + } + + private func dismissSafari() { + safariViewController?.dismiss(animated: true) + } + + private func updateSafari() { + safariViewController?.preferredBarTintColor = barTintColor.map(UIColor.init) + safariViewController?.preferredControlTintColor = UIColor(controlTintColor) + safariViewController?.dismissButtonStyle = dismissButtonStyle.uikit + } + + private func buildConfiguration() -> SFSafariViewController.Configuration { + let configuration = SFSafariViewController.Configuration() + configuration.entersReaderIfAvailable = entersReaderIfAvailable + configuration.barCollapsingEnabled = barCollapsingEnabled + if #available(iOS 15.0, macCatalyst 15.0, *), + let activityButton { + configuration.activityButton = unsafeDowncast(activityButton, to: SafariView.ActivityButton.self) + } + if #available(iOS 15.2, *), + let eventAttribution { + configuration.eventAttribution = unsafeDowncast(eventAttribution, to: UIEventAttribution.self) + } + return configuration + } + } + + func makeCoordinator() -> Coordinator { + .init( + safariView: safariView, + bindingSetter: { newValue in item = newValue }, + onDismiss: onDismiss + ) + } + + func makeUIView(context: Context) -> UIViewType { + context.coordinator.view + } + + func updateUIView(_ uiView: UIViewType, context: Context) {} + + // MARK: - Private + + @Binding + private var item: Item? + + @Environment(\.safariViewEntersReaderIfAvailable) + private var entersReaderIfAvailable: Bool + + @Environment(\.safariViewBarCollapsingEnabled) + private var barCollapsingEnabled: Bool + + @Environment(\.safariViewBarTintColor) + private var barTintColor: Color? + + @Environment(\.safariViewControlTintColor) + private var controlTintColor: Color + + @Environment(\.safariViewDismissButtonStyle) + private var dismissButtonStyle: SafariView.DismissButtonStyle + + @Environment(\.safariViewIncludedActivities) + private var includedActivities: SafariView.IncludedActivities + + @Environment(\.safariViewExcludedActivityTypes) + private var excludedActivityTypes: SafariView.ExcludedActivityTypes + + private let safariView: (Item) -> SafariView + private let onDismiss: (() -> Void)? + + } + + @Binding + private var item: Item? + + private let safariView: (Item) -> SafariView + private let onDismiss: (() -> Void)? + +} + +extension UIView { + + var controller: UIViewController? { + if let nextResponder = next as? UIViewController { + nextResponder + } else if let nextResponder = next as? UIView { + nextResponder.controller + } else { + nil + } + } + +} diff --git a/Sources/SafariView/Modifiers.swift b/Sources/SafariView/Modifiers.swift index 4d5bace71..85ec7b008 100644 --- a/Sources/SafariView/Modifiers.swift +++ b/Sources/SafariView/Modifiers.swift @@ -204,6 +204,7 @@ public extension View { } +@available(iOS 14.0, macCatalyst 14.0, *) private struct SafariViewEntersReaderIfAvailableModifier: ViewModifier { // MARK: - Initializers @@ -217,7 +218,10 @@ private struct SafariViewEntersReaderIfAvailableModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { content - .environment(\.safariViewEntersReaderIfAvailable, entersReaderIfAvailable) + .environment( + \.safariViewEntersReaderIfAvailable, + entersReaderIfAvailable + ) } // MARK: - Private @@ -226,6 +230,7 @@ private struct SafariViewEntersReaderIfAvailableModifier: ViewModifier { } +@available(iOS 14.0, macCatalyst 14.0, *) private struct SafariViewBarCollapsingEnabledModifier: ViewModifier { // MARK: - Initializers @@ -239,7 +244,10 @@ private struct SafariViewBarCollapsingEnabledModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { content - .environment(\.safariViewBarCollapsingEnabled, barCollapsingEnabled) + .environment( + \.safariViewBarCollapsingEnabled, + barCollapsingEnabled + ) } // MARK: - Private @@ -247,6 +255,7 @@ private struct SafariViewBarCollapsingEnabledModifier: ViewModifier { private let barCollapsingEnabled: Bool } +@available(iOS 14.0, macCatalyst 14.0, *) private struct SafariViewControlTintColorModifier: ViewModifier { // MARK: - Initializers @@ -260,7 +269,10 @@ private struct SafariViewControlTintColorModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { content - .environment(\.safariViewControlTintColor, safariViewControlTintColor) + .environment( + \.safariViewControlTintColor, + safariViewControlTintColor + ) } // MARK: - Private @@ -269,6 +281,7 @@ private struct SafariViewControlTintColorModifier: ViewModifier { } +@available(iOS 14.0, macCatalyst 14.0, *) private struct SafariViewBarTintColorModifier: ViewModifier { // MARK: - Initializers @@ -282,7 +295,10 @@ private struct SafariViewBarTintColorModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { content - .environment(\.safariViewBarTintColor, safariViewBarTintColor) + .environment( + \.safariViewBarTintColor, + safariViewBarTintColor + ) } // MARK: - Private @@ -291,6 +307,7 @@ private struct SafariViewBarTintColorModifier: ViewModifier { } +@available(iOS 14.0, macCatalyst 14.0, *) private struct SafariViewDismissButtonStyleModifier: ViewModifier { // MARK: - Initializers @@ -304,7 +321,10 @@ private struct SafariViewDismissButtonStyleModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { content - .environment(\.safariViewDismissButtonStyle, safariViewDismissButtonStyle) + .environment( + \.safariViewDismissButtonStyle, + safariViewDismissButtonStyle + ) } // MARK: - Private @@ -313,6 +333,7 @@ private struct SafariViewDismissButtonStyleModifier: ViewModifier { } +@available(iOS 14.0, macCatalyst 14.0, *) private struct SafariViewIncludedActivitiesModifier: ViewModifier { // MARK: - Initializers @@ -326,7 +347,10 @@ private struct SafariViewIncludedActivitiesModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { content - .environment(\.safariViewIncludedActivities, activities) + .environment( + \.safariViewIncludedActivities, + activities + ) } // MARK: - Private @@ -335,6 +359,7 @@ private struct SafariViewIncludedActivitiesModifier: ViewModifier { } +@available(iOS 14.0, macCatalyst 14.0, *) private struct SafariViewExcludedActivityTypesModifier: ViewModifier { // MARK: - Initializers @@ -348,7 +373,10 @@ private struct SafariViewExcludedActivityTypesModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { content - .environment(\.safariViewExcludedActivityTypes, activityTypes) + .environment( + \.safariViewExcludedActivityTypes, + activityTypes + ) } // MARK: - Private diff --git a/Sources/SafariView/Presentation.swift b/Sources/SafariView/Presentation.swift deleted file mode 100644 index 0558ad194..000000000 --- a/Sources/SafariView/Presentation.swift +++ /dev/null @@ -1,276 +0,0 @@ -// SafariUI -// Presentation.swift -// -// MIT License -// -// Copyright (c) 2023 Varun Santhanam -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the Software), to deal -// -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import SwiftUI - -@available(iOS 14.0, macCatalyst 14.0, *) -public extension View { - - /// Presents a ``SafariView`` when a binding to a Boolean value that you provide is `true`. - /// - /// Use this method when you want to present a ``SafariView`` to the user when a Boolean value you provide is true. - /// The example below displays a modal view of the mockup for a software license agreement when the user toggles the `isShowingSafari` variable by clicking or tapping on the “Show License Agreement” button: - /// - /// ```swift - /// import Foundation - /// import SafariView - /// import SwiftUI - /// - /// struct ShowLicenseAgreement: View { - /// - /// let licenseAgreementURL: URL - /// - /// @State private var isShowingSafari = false - /// - /// var body: some View { - /// Button(action: { - /// isShowingSafari.toggle() - /// }) { - /// Text("Show License Agreement") - /// } - /// .safari(isPresented: $isShowingSafari, - /// onDismiss: didDismiss) { - /// SafariView(url: licenseAgreementURL) - /// } - /// } - /// - /// func didDismiss() { - /// // Handle the dismissing action. - /// } - /// - /// } - /// ``` - /// - /// - Parameters: - /// - isPresented: A binding to a Boolean value that determines whether to present the ``SafariView`` that you create in the modifier’s content closure. - /// - onDismiss: The closure to execute when dismissing the ``SafariView`` - /// - safariView: A closure that returns the ``SafariView`` to present - /// - Returns: The modified view - func safari( - isPresented: Binding, - onDismiss: (() -> Void)? = nil, - @ViewBuilder safariView: @escaping () -> SafariView - ) -> some View { - let modifier = SafariView.BoolModifier(isPresented: isPresented, - build: safariView, - onDismiss: onDismiss ?? {}) - return ModifiedContent(content: self, modifier: modifier) - } - - /// Presents a ``SafariView`` using the given item as a data source for the ``SafariView``'s content - /// - /// Use this method when you need to present a ``SafariView`` with content from a custom data source. The example below shows a custom data source `InventoryItem` that the closure uses to populate the ``SafariView`` before it is shown to the user: - /// - /// ```swift - /// import Foundation - /// import SafariView - /// import SwiftUI - /// - /// struct InventoryItem: Identifiable { - /// let id: Int - /// let title: String - /// let url: URL - /// } - /// - /// struct InventoryList: View { - /// - /// init(inventory: [InventoryItem]) { - /// self.inventory = inventory - /// } - /// - /// var inventory: [InventoryItem] - /// - /// @State private var selectedItem: InventoryItem? - /// - /// var body: some View { - /// List(inventory) { inventoryItem in - /// Button(action: { - /// self.selectedItem = inventoryItem - /// }) { - /// Text(inventoryItem.title) - /// } - /// } - /// .safari(item: $selectedItem, - /// onDismiss: dismissAction) { item in - /// SafariView(url: item.url) - /// } - /// } - /// - /// func didDismiss() { - /// // Handle the dismissing action. - /// } - /// - /// } - /// ``` - /// - /// - Parameters: - /// - item: A binding to an optional source of truth for the ``SafariView``. When item is non-nil, the system passes the item’s content to the modifier’s closure. You display this content in a ``SafariView`` that you create that the system displays to the user. If item changes, the system dismisses the ``SafariView`` and replaces it with a new one using the same process. - /// - onDismiss: The closure to execute when dismissing the ``SafariView`` - /// - safariView: A closure that returns the ``SafariView`` to present - /// - Returns: The modified view - func safari( - item: Binding, - onDismiss: (() -> Void)? = nil, - @ViewBuilder safariView: @escaping (Item) -> SafariView - ) -> some View where Item: Identifiable { - let modifier = SafariView.IdentifiableItemModitifer( - item: item, - build: safariView, - onDismiss: onDismiss ?? {} - ) - return ModifiedContent(content: self, modifier: modifier) - } - - /// Presents a ``SafariView`` using the given item as a data source for the ``SafariView``'s content - /// - /// Use this method when you need to present a ``SafariView`` with content from a custom data source. The example below shows a custom data source `InventoryItem` that the closure uses to populate the ``SafariView`` before it is shown to the user: - /// - /// ```swift - /// import Foundation - /// import SafariView - /// import SwiftUI - /// - /// struct InventoryItem { - /// let id: Int - /// let title: String - /// let url: URL - /// } - /// - /// struct InventoryList: View { - /// - /// init(inventory: [InventoryItem]) { - /// self.inventory = inventory - /// } - /// - /// var inventory: [InventoryItem] - /// - /// @State private var selectedItem: InventoryItem? - /// - /// var body: some View { - /// List(inventory) { inventoryItem in - /// Button(action: { - /// self.selectedItem = inventoryItem - /// }) { - /// Text(inventoryItem.title) - /// } - /// } - /// .safari(item: $selectedItem, - /// id: \.id, - /// onDismiss: dismissAction) { item in - /// SafariView(url: item.url) - /// } - /// } - /// - /// func didDismiss() { - /// // Handle the dismissing action. - /// } - /// - /// } - /// ``` - /// - /// - Parameters: - /// - item: A binding to an optional source of truth for the ``SafariView``. When item is non-nil, the system passes the item’s content to the modifier’s closure. You display this content in a ``SafariView`` that you create that the system displays to the user. If item changes, the system dismisses the ``SafariView`` and replaces it with a new one using the same process. - /// - id: A keypath used to generate stable identifier for instances of `Item`. - /// - onDismiss: The closure to execute when dismissing the ``SafariView`` - /// - safariView: A closure that returns the ``SafariView`` to present - /// - Returns: The modified view - func safari( - item: Binding, - id: KeyPath, - onDismiss: (() -> Void)? = nil, - @ViewBuilder safariView: @escaping (Item) -> SafariView - ) -> some View where Identifier: Hashable { - let modifier = SafariView.ItemModifier( - item: item, - id: id, - onDismiss: onDismiss, - build: safariView - ) - return ModifiedContent(content: self, modifier: modifier) - } - - /// Presents a ``SafariView`` using the given URL as a data source for the ``SafariView``'s content - /// - /// Use this method when you need to present a ``SafariView`` with content from a custom data source. The example below shows a custom data source `InventoryItem` that the closure uses to populate the ``SafariView`` before it is shown to the user: - /// - /// ```swift - /// import Foundation - /// import SafariView - /// import SwiftUI - /// - /// struct InventoryItem { - /// let title: String - /// let url: URL - /// } - /// - /// struct InventoryList: View { - /// - /// init(inventory: [InventoryItem]) { - /// self.inventory = inventory - /// } - /// - /// var inventory: [InventoryItem] - /// - /// @State private var selectedURL: URL? - /// - /// var body: some View { - /// List(inventory.indices, id: \.self) { index in - /// Button(action: { - /// self.selectedURL = inventory[index].url - /// }) { - /// Text(inventory[index].title) - /// } - /// } - /// .safari(item: $selectedURL, - /// onDismiss: dismissAction) { url in - /// SafariView(url: url) - /// } - /// } - /// - /// func didDismiss() { - /// // Handle the dismissing action. - /// } - /// - /// } - /// ``` - /// - /// - Parameters: - /// - url: A binding to an optional source of truth for the ``SafariView``. When the URL is non-nil, the system passes the URL to the modifier’s closure. You display this content in a ``SafariView`` that you create that the system displays to the user. If the URL changes, the system dismisses the ``SafariView`` and replaces it with a new one using the same process. - /// - onDismiss: The closure to execute when dismissing the ``SafariView`` - /// - safariView: A closure that returns the ``SafariView`` to present - /// - Returns: The modified view - func safari( - url: Binding, - onDismiss: (() -> Void)? = nil, - @ViewBuilder safariView: @escaping (URL) -> SafariView = { url in SafariView(url: url) } - ) -> some View { - safari( - item: url, - id: \.hashValue, - onDismiss: onDismiss, - safariView: safariView - ) - } -} diff --git a/Sources/SafariView/SafariView.swift b/Sources/SafariView/SafariView.swift index 71e01e1a8..ffef8fe97 100644 --- a/Sources/SafariView/SafariView.swift +++ b/Sources/SafariView/SafariView.swift @@ -23,6 +23,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import Foundation import SafariServices import SwiftUI import UIKit @@ -76,7 +77,7 @@ public struct SafariView: View { self.onOpenInBrowser = onOpenInBrowser } - /// Create a SafariView with tap attribution for Private Click Measurement + /// Create a `SafariView` with tap attribution for Private Click Measurement /// /// For more information about preparing event attribution data, see [`UIEventAttribution`](https://developer.apple.com/documentation/uikit/uieventattribution) /// - Parameters: @@ -103,8 +104,6 @@ public struct SafariView: View { self.onOpenInBrowser = onOpenInBrowser } - // MARK: - API - /// A convenience typealias for [`SFSafariViewController.ActivityButton`](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller/activitybutton) @available(iOS 15.0, macCatalyst 15.0, *) public typealias ActivityButton = SFSafariViewController.ActivityButton @@ -141,553 +140,149 @@ public struct SafariView: View { SFSafariViewController.DataStore.default.clearWebsiteData(completionHandler: completionHandler) } - // MARK: - View - @_documentation(visibility: internal) + @MainActor + @ViewBuilder public var body: some View { - Safari(parent: self) - .ignoresSafeArea(.container, edges: .all) + Safari( + url: url, + onInitialLoad: onInitialLoad, + onInitialRedirect: onInitialRedirect, + onOpenInBrowser: onOpenInBrowser, + activityButton: activityButton, + eventAttribution: eventAttribution + ) + .ignoresSafeArea( + .container, + edges: .all + ) } // MARK: - Private - @Environment(\.safariViewEntersReaderIfAvailable) - private var entersReaderIfAvailable: Bool - - @Environment(\.safariViewBarCollapsingEnabled) - private var barCollapsingEnabled: Bool - - @Environment(\.safariViewBarTintColor) - private var barTintColor: Color? - - @Environment(\.safariViewControlTintColor) - private var controlTintColor: Color - - @Environment(\.safariViewDismissButtonStyle) - private var dismissButtonStyle: DismissButtonStyle - - @Environment(\.safariViewIncludedActivities) - private var includedActivities: IncludedActivities - - @Environment(\.safariViewExcludedActivityTypes) - private var excludedActivityTypes: ExcludedActivityTypes - - private let activityButton: AnyObject? - private let eventAttribution: AnyObject? - private let url: URL - private let onInitialLoad: ((Bool) -> Void)? - private let onInitialRedirect: ((URL) -> Void)? - private let onOpenInBrowser: (() -> Void)? - - private func apply(to controller: SFSafariViewController) { - controller.preferredBarTintColor = barTintColor.map(UIColor.init) - controller.preferredControlTintColor = UIColor(controlTintColor) - controller.dismissButtonStyle = dismissButtonStyle.uikit - } - - private func buildConfiguration() -> SFSafariViewController.Configuration { - let configuration = SFSafariViewController.Configuration() - configuration.entersReaderIfAvailable = entersReaderIfAvailable - configuration.barCollapsingEnabled = barCollapsingEnabled - if #available(iOS 15.0, macCatalyst 15.0, *), - let activityButton { - configuration.activityButton = unsafeDowncast(activityButton, to: ActivityButton.self) - } - if #available(iOS 15.2, *), - let eventAttribution { - configuration.eventAttribution = unsafeDowncast(eventAttribution, to: UIEventAttribution.self) - } - return configuration - } - private struct Safari: UIViewControllerRepresentable { // MARK: - Initializers - init(parent: SafariView) { - self.parent = parent - delegate = Delegate( - onInitialLoad: parent.onInitialLoad, - onInitialRedirect: parent.onInitialRedirect, - onOpenInBrowser: parent.onOpenInBrowser, - includedActivities: parent.includedActivities, - excludedActivityTypes: parent.excludedActivityTypes + init( + url: URL, + onInitialLoad: ((Bool) -> Void)?, + onInitialRedirect: ((URL) -> Void)?, + onOpenInBrowser: (() -> Void)?, + activityButton: AnyObject?, + eventAttribution: AnyObject? + ) { + self.url = url + delegate = .init( + onInitialLoad: onInitialLoad, + onInitialRedirect: onInitialRedirect, + onOpenInBrowser: onOpenInBrowser ) + self.activityButton = activityButton + self.eventAttribution = eventAttribution } // MARK: - UIViewControllerRepresentable - func makeUIViewController(context: Context) -> SFSafariViewController { - let safari = SFSafariViewController(url: parent.url, - configuration: parent.buildConfiguration()) - safari.modalPresentationStyle = .none - safari.delegate = delegate - parent.apply(to: safari) - return safari + typealias UIViewControllerType = SFSafariViewController + + func makeUIViewController(context: Context) -> UIViewControllerType { + let controller = SFSafariViewController(url: url, configuration: buildConfiguration()) + controller.delegate = delegate + delegate.includedActivities = includedActivities + delegate.excludedActivityTypes = excludedActivityTypes + controller.modalPresentationStyle = .none + controller.preferredBarTintColor = barTintColor.map(UIColor.init) + controller.preferredControlTintColor = UIColor(controlTintColor) + controller.dismissButtonStyle = dismissButtonStyle.uikit + return controller } - func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) { - parent.apply(to: uiViewController) - uiViewController.delegate = delegate + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + delegate.includedActivities = includedActivities + delegate.excludedActivityTypes = excludedActivityTypes + uiViewController.preferredBarTintColor = barTintColor.map(UIColor.init) + uiViewController.preferredControlTintColor = UIColor(controlTintColor) + uiViewController.dismissButtonStyle = dismissButtonStyle.uikit } // MARK: - Private - private var parent: SafariView - private let delegate: Delegate - private final class Delegate: NSObject, SFSafariViewControllerDelegate { + // MARK: - Initializers + init( onInitialLoad: ((Bool) -> Void)?, onInitialRedirect: ((URL) -> Void)?, - onOpenInBrowser: (() -> Void)?, - includedActivities: IncludedActivities, - excludedActivityTypes: ExcludedActivityTypes + onOpenInBrowser: (() -> Void)? ) { self.onInitialLoad = onInitialLoad self.onInitialRedirect = onInitialRedirect self.onOpenInBrowser = onOpenInBrowser - self.includedActivities = includedActivities - self.excludedActivityTypes = excludedActivityTypes - } - - // MARK: - SFSafariViewControllerDelegate - - func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) { - onInitialLoad?(didLoadSuccessfully) } - func safariViewController(_ controller: SFSafariViewController, initialLoadDidRedirectTo URL: URL) { - onInitialRedirect?(URL) - } - - func safariViewController( - _ controller: SFSafariViewController, - activityItemsFor URL: URL, - title: String? - ) -> [UIActivity] { - includedActivities(url: URL, pageTitle: title) - } + // MARK: - API - func safariViewController( - _ controller: SFSafariViewController, - excludedActivityTypesFor URL: URL, - title: String? - ) -> [UIActivity.ActivityType] { - excludedActivityTypes(url: URL, pageTitle: title) - } - - func safariViewControllerWillOpenInBrowser(_ controller: SFSafariViewController) { - onOpenInBrowser?() - } + var includedActivities: SafariView.IncludedActivities = [] + var excludedActivityTypes: SafariView.ExcludedActivityTypes = [] // MARK: - Private private let onInitialLoad: ((Bool) -> Void)? private let onInitialRedirect: ((URL) -> Void)? private let onOpenInBrowser: (() -> Void)? - private var includedActivities: IncludedActivities - private let excludedActivityTypes: ExcludedActivityTypes - - } - - } - - struct BoolModifier: ViewModifier { - - // MARK: - API - - @Binding - var isPresented: Bool - - var build: () -> SafariView - var onDismiss: () -> Void - - // MARK: - ViewModifier - - func body(content: Content) -> some View { - content - .background( - Presenter(isPresented: $isPresented, - build: build, - onDismiss: onDismiss) - ) - } - - // MARK: - Private - - private struct Presenter: UIViewRepresentable { - - // MARK: - API - - @Binding - var isPresented: Bool - - var build: () -> SafariView - var onDismiss: () -> Void - - // MARK: - UIViewRepresentable - - final class Coordinator: NSObject, SFSafariViewControllerDelegate { - - // MARK: - Initialziers - - init(parent: Presenter) { - self.parent = parent - } - - // MARK: - API - - let view = UIView() - - var parent: Presenter - - var isPresented: Bool = false { - didSet { - switch (oldValue, isPresented) { - case (false, false): - break - case (false, true): - presentSafari() - case (true, false): - dismissSafari() - case (true, true): - updateSafari() - } - } - } - - // MARK: - SFSafariViewControllerDelegate - - func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) { - onInitialLoad?(didLoadSuccessfully) - } - - func safariViewController(_ controller: SFSafariViewController, initialLoadDidRedirectTo URL: URL) { - onInitialRedirect?(URL) - } - - func safariViewControllerDidFinish(_ controller: SFSafariViewController) { - parent.isPresented = false - parent.onDismiss() - } - - func safariViewController( - _ controller: SFSafariViewController, - activityItemsFor URL: URL, - title: String? - ) -> [UIActivity] { - includedActivities?(url: URL, pageTitle: title) ?? [] - } - - func safariViewController( - _ controller: SFSafariViewController, - excludedActivityTypesFor URL: URL, - title: String? - ) -> [UIActivity.ActivityType] { - excludedActivityTypes?(url: URL, pageTitle: title) ?? [] - } - - func safariViewControllerWillOpenInBrowser(_ controller: SFSafariViewController) { - onOpenInBrowser?() - } - - // MARK: - Private - - private weak var safari: SFSafariViewController? - - private var onInitialLoad: ((Bool) -> Void)? - private var onInitialRedirect: ((URL) -> Void)? - private var onOpenInBrowser: (() -> Void)? - private var includedActivities: IncludedActivities? - private var excludedActivityTypes: ExcludedActivityTypes? - - private func presentSafari() { - let rep = parent.build() - onInitialLoad = rep.onInitialLoad - onInitialRedirect = rep.onInitialRedirect - onOpenInBrowser = rep.onOpenInBrowser - includedActivities = rep.includedActivities - excludedActivityTypes = rep.excludedActivityTypes - let vc = SFSafariViewController(url: rep.url, configuration: rep.buildConfiguration()) - vc.delegate = self - rep.apply(to: vc) - - guard let presenting = view.controller else { - parent.isPresented = false - return - } - presenting.present(vc, animated: true) - safari = vc - } - - private func updateSafari() { - guard let safari else { - return - } - let rep = parent.build() - onInitialLoad = rep.onInitialLoad - onInitialRedirect = rep.onInitialRedirect - onOpenInBrowser = rep.onOpenInBrowser - includedActivities = rep.includedActivities - excludedActivityTypes = rep.excludedActivityTypes - rep.apply(to: safari) - } - - private func dismissSafari() { - guard let safari else { - return - } - safari.dismiss(animated: true) { - self.parent.onDismiss() - } - } - - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - func makeUIView(context: Context) -> UIView { - context.coordinator.view - } - - func updateUIView(_ uiView: UIView, context: Context) { - context.coordinator.parent = self - context.coordinator.isPresented = isPresented - } } - } - - struct IdentifiableItemModitifer: ViewModifier where Item: Identifiable { - - // MARK: - API + private let url: URL + private let delegate: Delegate + private let activityButton: AnyObject? + private let eventAttribution: AnyObject? - @Binding - var item: Item? + @Environment(\.safariViewEntersReaderIfAvailable) + private var entersReaderIfAvailable: Bool - var build: (Item) -> SafariView - var onDismiss: () -> Void + @Environment(\.safariViewBarCollapsingEnabled) + private var barCollapsingEnabled: Bool - // MARK: - ViewModifier + @Environment(\.safariViewBarTintColor) + private var barTintColor: Color? - func body(content: Content) -> some View { - content.background( - Presenter(item: $item, - build: build, - onDismiss: onDismiss) - ) - } + @Environment(\.safariViewControlTintColor) + private var controlTintColor: Color - // MARK: - Private - - private struct Presenter: UIViewRepresentable { - - // MARK: - API - - @Binding - var item: Item? - - var build: (Item) -> SafariView - var onDismiss: () -> Void - - // MARK: - UIViewRepresentable - - final class Coordinator: NSObject, SFSafariViewControllerDelegate { - - // MARK: - Initializers - - init(parent: Presenter) { - self.parent = parent - } - - // MARK: - API - - let view = UIView() - - var parent: Presenter - - var item: Item? { - didSet { - switch (oldValue, item) { - case (.none, .none): - break - case let (.none, .some(newItem)): - presentSafari(with: newItem) - case let (.some(oldItem), .some(newItem)) where oldItem.id != newItem.id: - dismissSafari() { - self.presentSafari(with: newItem) - } - case let (.some, .some(newItem)): - updateSafari(with: newItem) - case (.some, .none): - dismissSafari() - } - } - } - - // MARK: - SFSafariViewControllerDelegate - - func safariViewControllerDidFinish(_ controller: SFSafariViewController) { - parent.item = nil - parent.onDismiss() - } - - func safariViewController(_ controller: SFSafariViewController, initialLoadDidRedirectTo URL: URL) { - onInitialRedirect?(URL) - } - - func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) { - onInitialLoad?(didLoadSuccessfully) - } - - func safariViewController( - _ controller: SFSafariViewController, - activityItemsFor URL: URL, - title: String? - ) -> [UIActivity] { - includedActivities?(url: URL, pageTitle: title) ?? [] - } - - func safariViewController( - _ controller: SFSafariViewController, - excludedActivityTypesFor URL: URL, - title: String? - ) -> [UIActivity.ActivityType] { - excludedActivityTypes?(url: URL, pageTitle: title) ?? [] - } - - func safariViewControllerWillOpenInBrowser(_ controller: SFSafariViewController) { - onOpenInBrowser?() - } - - // MARK: - Private - - private weak var safari: SFSafariViewController? - - private var onInitialLoad: ((Bool) -> Void)? - private var onInitialRedirect: ((URL) -> Void)? - private var onOpenInBrowser: (() -> Void)? - private var includedActivities: IncludedActivities? - private var excludedActivityTypes: ExcludedActivityTypes? - - private func presentSafari(with item: Item) { - let rep = parent.build(item) - onInitialLoad = rep.onInitialLoad - onInitialRedirect = rep.onInitialRedirect - onOpenInBrowser = rep.onOpenInBrowser - includedActivities = rep.includedActivities - excludedActivityTypes = rep.excludedActivityTypes - let vc = SFSafariViewController(url: rep.url, configuration: rep.buildConfiguration()) - vc.delegate = self - rep.apply(to: vc) - guard let presenting = view.controller else { - parent.item = nil - return - } - - presenting.present(vc, animated: true) - - safari = vc - } - - private func updateSafari(with item: Item) { - guard let safari else { - return - } - let rep = parent.build(item) - onInitialLoad = rep.onInitialLoad - onInitialRedirect = rep.onInitialRedirect - onOpenInBrowser = rep.onOpenInBrowser - includedActivities = rep.includedActivities - excludedActivityTypes = rep.excludedActivityTypes - rep.apply(to: safari) - } - - private func dismissSafari(completion: (() -> Void)? = nil) { - guard let safari else { - return - } - - safari.dismiss(animated: true) { - self.parent.onDismiss() - completion?() - } - } - } + @Environment(\.safariViewDismissButtonStyle) + private var dismissButtonStyle: SafariView.DismissButtonStyle - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } + @Environment(\.safariViewIncludedActivities) + private var includedActivities: SafariView.IncludedActivities - func makeUIView(context: Context) -> UIView { - context.coordinator.view - } + @Environment(\.safariViewExcludedActivityTypes) + private var excludedActivityTypes: SafariView.ExcludedActivityTypes - func updateUIView(_ uiView: UIView, context: Context) { - context.coordinator.parent = self - context.coordinator.item = item + private func buildConfiguration() -> SFSafariViewController.Configuration { + let configuration = SFSafariViewController.Configuration() + configuration.entersReaderIfAvailable = entersReaderIfAvailable + configuration.barCollapsingEnabled = barCollapsingEnabled + if #available(iOS 15.0, macCatalyst 15.0, *), + let activityButton { + configuration.activityButton = unsafeDowncast(activityButton, to: SafariView.ActivityButton.self) } - } - } - - struct ItemModifier: ViewModifier where Identifier: Hashable { - - @Binding - var item: Item? - - let id: KeyPath - let onDismiss: (() -> Void)? - let build: (Item) -> SafariView - - // MARK: - ViewModifier - - @ViewBuilder - func body(content: Content) -> some View { - content - .safari(item: wrapped, onDismiss: onDismiss) { item in - build(item.wrapped) - } - } - - // MARK: - Private - - private struct WrappedItem: Identifiable { - let wrapped: Item - let path: KeyPath - var id: Identifier { wrapped[keyPath: path] } - } - - private var wrapped: Binding { - Binding { - item.map(wrap) - } set: { newValue in - item = newValue?.wrapped + if #available(iOS 15.2, *), + let eventAttribution { + configuration.eventAttribution = unsafeDowncast(eventAttribution, to: UIEventAttribution.self) } - } - - private func wrap(_ item: Item) -> WrappedItem { - .init(wrapped: item, path: id) + return configuration } } -} - -extension UIView { - - var controller: UIViewController? { - if let nextResponder = next as? UIViewController { - nextResponder - } else if let nextResponder = next as? UIView { - nextResponder.controller - } else { - nil - } - } + package let url: URL + package let onInitialLoad: ((Bool) -> Void)? + package let onInitialRedirect: ((URL) -> Void)? + package let onOpenInBrowser: (() -> Void)? + package let activityButton: AnyObject? + package let eventAttribution: AnyObject? } diff --git a/Sources/SafariView/WrappedItemPresentation.swift b/Sources/SafariView/WrappedItemPresentation.swift new file mode 100644 index 000000000..3cd45c349 --- /dev/null +++ b/Sources/SafariView/WrappedItemPresentation.swift @@ -0,0 +1,172 @@ +// SafariUI +// WrappedItemPresentation.swift +// +// MIT License +// +// Copyright (c) 2023 Varun Santhanam +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SwiftUI + +@available(iOS 14.0, macCatalyst 14.0, *) +public extension View { + + /// Presents a ``SafariView`` using the given item as a data source for the view’s content. + /// + /// Use this method when you need to present a ``SafariView`` with content from a custom data source. + /// The example below shows a custom data source `InventoryItem` that the closure uses to populate the ``SafariView`` before it is shown to the user: + /// + /// ```swift + /// import Foundation + /// import SafariView + /// import SwiftUI + /// + /// struct InventoryItem: Identifiable { + /// let id: Int + /// let title: String + /// let url: URL + /// } + /// + /// struct InventoryList: View { + /// + /// init(inventory: [InventoryItem]) { + /// self.inventory = inventory + /// } + /// + /// + /// var inventory: [InventoryItem] + /// + /// @State private var selectedItem: InventoryItem? + /// + /// var body: some View { + /// List(inventory) { inventoryItem in + /// Button(action: { + /// self.selectedItem = inventoryItem + /// }) { + /// Text(inventoryItem.title) + /// } + /// } + /// .safari(item: $selectedItem, + /// onDismiss: dismissAction) { item in + /// SafariView(url: item.url) + /// } + /// } + /// + /// + /// func didDismiss() { + /// // Handle the dismissing action. + /// } + /// + /// } + /// ``` + /// + /// - Parameters: + /// - item: A binding to an optional source of truth for the ``SafariView``. When item is non-nil, the system passes the item’s content to the modifier’s closure. You display this content in a ``SafariView`` that you create that the system displays to the user. If item changes, the system dismisses the ``SafariView`` and replaces it with a new one using the same process. + /// - id: A keypath used to generate stable identifier for instances of Item. + /// - onDismiss: The closure to execute when dismissing the ``SafariView`` + /// - safariView: A closure that returns the ``SafariView`` to present + /// - Returns: The modified view + func safari( + item: Binding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + safariView: @escaping (Item) -> SafariView + ) -> some View where ID: Hashable { + ModifiedContent( + content: self, + modifier: WrappedItemPresentation( + item: item, + id: id, + onDismiss: onDismiss, + safariView: safariView + ) + ) + } + +} + +@available(iOS 14.0, macCatalyst 14.0, *) +private struct WrappedItemPresentation: ViewModifier where ID: Hashable { + + // MARK: - Initializer + + init( + item: Binding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + safariView: @escaping (Item) -> SafariView + ) { + _item = item + self.id = id + self.onDismiss = onDismiss + self.safariView = safariView + } + + // MARK: - ViewModifier + + @MainActor + @ViewBuilder + func body(content: Content) -> some View { + content + .safari(item: wrappedItem) { item in + safariView(item.value) + } + } + + // MARK: - Private + + @Binding + private var item: Item? + private let id: KeyPath + private let onDismiss: (() -> Void)? + private let safariView: (Item) -> SafariView + + private var wrappedItem: Binding?> { + .init { + if let item { + .init(value: item, path: id) + } else { + nil + } + } set: { newValue in + item = newValue?.value + } + } + + struct WrappedIdentifiable: Identifiable where ID: Hashable { + + init( + value: Wrapped, + path: KeyPath + ) { + self.value = value + self.path = path + } + + let value: Wrapped + let path: KeyPath + + var id: ID { + value[keyPath: path] + } + + } + +}