From 8c29268853e9c5b05423706df1586ad18dcaed92 Mon Sep 17 00:00:00 2001 From: Varun Santhanam Date: Sun, 2 Jul 2023 10:49:11 -0700 Subject: [PATCH] Add generic item presentation --- .swiftformat | 2 +- Sources/SafariView/SafariView.swift | 194 +++++++----------------- Sources/SafariView/ViewExtensions.swift | 103 +++++++++++-- 3 files changed, 139 insertions(+), 160 deletions(-) diff --git a/.swiftformat b/.swiftformat index 2220a747c..08ebe9ef3 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,4 +1,4 @@ --enable isEmpty --disable blankLinesAtEndOfScope, blankLinesAtStartOfScope, redundantNilInit, unusedArguments, redundantParens, wrapMultilineStatementBraces, trailingCommas, braces ---swiftversion 5.6 +--swiftversion 5.8 --header "SafariView\n{file}\n\nMIT License\n\nCopyright (c) 2021 Varun Santhanam\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the "Software"), to deal\n\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." \ No newline at end of file diff --git a/Sources/SafariView/SafariView.swift b/Sources/SafariView/SafariView.swift index c9f894c8a..27f3035c7 100644 --- a/Sources/SafariView/SafariView.swift +++ b/Sources/SafariView/SafariView.swift @@ -473,7 +473,7 @@ public struct SafariView: View { // MARK: - Private - struct Modifier: ViewModifier { + struct BoolModifier: ViewModifier { // MARK: - API @@ -594,7 +594,7 @@ public struct SafariView: View { } private func updateSafari() { - guard let safari = safari else { + guard let safari else { return } let rep = parent.build() @@ -607,7 +607,7 @@ public struct SafariView: View { } private func dismissSafari() { - guard let safari = safari else { + guard let safari else { return } @@ -761,7 +761,7 @@ public struct SafariView: View { } private func updateSafari(with item: Item) { - guard let safari = safari else { + guard let safari else { return } let rep = parent.build(item) @@ -774,7 +774,7 @@ public struct SafariView: View { } private func dismissSafari(completion: (() -> Void)? = nil) { - guard let safari = safari else { + guard let safari else { return } @@ -800,161 +800,69 @@ public struct SafariView: View { } } - struct URLModifier: ViewModifier { + struct GenericItemModifier: ViewModifier where Identifier: Hashable { - @Binding - var url: URL? - - var build: (URL) -> SafariView - var onDismiss: () -> Void + // MARK: - Initializers - func body(content: Content) -> some View { - content - .background( - Presenter(url: $url, - build: build, - onDismiss: onDismiss) - ) + init( + item: Binding, + id: KeyPath, + onDismiss: (() -> Void)? = nil, + @ViewBuilder safariView: @escaping (Item) -> SafariView + ) + { + self.item = item + self.id = id + self.onDismiss = onDismiss + self.safariView = safariView } - private struct Presenter: UIViewRepresentable { - - @Binding - var url: URL? - - var build: (URL) -> SafariView - var onDismiss: () -> Void - - final class Coordinator: NSObject, SFSafariViewControllerDelegate { - - // MARK: - Initializers - - init(parent: Presenter) { - self.parent = parent - } - - // MARK: - API - - let view = UIView() - - var parent: Presenter - - var url: URL? { - didSet { - switch (oldValue, url) { - case (.none, .none): - break - case let (.none, .some(new)): - presentSafari(with: new) - case let (.some(old), .some(new)) where old != new: - dismissSafari() { [presentSafari] in - presentSafari(new) - } - case let (.some, .some(new)): - updateSafari(with: new) - case (.some, .none): - dismissSafari() - } - } - } - - // MARK: - SFSafariViewControllerDelegate - - func safariViewControllerDidFinish(_ controller: SFSafariViewController) { - parent.url = 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] { - withActivityItems(URL, title) - } - - func safariViewController(_ controller: SFSafariViewController, excludedActivityTypesFor URL: URL, title: String?) -> [UIActivity.ActivityType] { - withoutActivityItems(URL, title) - } - - func safariViewControllerWillOpenInBrowser(_ controller: SFSafariViewController) { - willOpenInBrowser() - } - - // MARK: - Private - - private weak var safari: SFSafariViewController? - - private var onInitialLoad: (Bool) -> Void = { _ in } - private var onInitialRedirect: (URL) -> Void = { _ in } - private var withActivityItems: (URL, String?) -> [UIActivity] = { _, _ in [] } - private var withoutActivityItems: (URL, String?) -> [UIActivity.ActivityType] = { _, _ in [] } - private var willOpenInBrowser: () -> Void = {} - - private func presentSafari(with url: URL) { - let rep = parent.build(url) - onInitialLoad = rep.onInitialLoad - onInitialRedirect = rep.onInitialRedirect - withActivityItems = rep.withActivityItems - withoutActivityItems = rep.withoutActivityItems - willOpenInBrowser = rep.willOpenInBrowser - let vc = SFSafariViewController(url: rep.url, configuration: rep.configuration) - vc.delegate = self - rep.apply(to: vc) - guard let presenting = view.controller else { - parent.url = url - return - } - - presenting.present(vc, animated: true) + // MARK: - ViewModifier - safari = vc + @ViewBuilder + func body(content: Content) -> some View { + content + .safari(item: binding, onDismiss: onDismiss) { wrappedItem in + safariView(wrappedItem.item) } + } - private func updateSafari(with url: URL) { - guard let safari = safari else { - return - } - let rep = parent.build(url) - onInitialLoad = rep.onInitialLoad - onInitialRedirect = rep.onInitialRedirect - withActivityItems = rep.withActivityItems - withoutActivityItems = rep.withoutActivityItems - willOpenInBrowser = rep.willOpenInBrowser - rep.apply(to: safari) - } + // MARK: - Private - private func dismissSafari(_ completion: (() -> Void)? = nil) { - guard let safari = safari else { - return - } + private let item: Binding + private let id: KeyPath + private let onDismiss: (() -> Void)? + private let safariView: (Item) -> SafariView - safari.dismiss(animated: true) { - self.parent.onDismiss() - completion?() - } + private var binding: Binding { + Binding { + guard let item = item.wrappedValue else { + return nil } + return WrappedItem(item, id) + } set: { newValue in + item.wrappedValue = newValue?.item } + } - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) + private struct WrappedItem: Identifiable { + init( + _ item: Item, + _ keyPath: KeyPath + ) { + self.item = item + self.keyPath = keyPath } - func makeUIView(context: Context) -> UIView { - context.coordinator.view - } + let item: Item + private let keyPath: KeyPath - func updateUIView(_ uiView: UIView, context: Context) { - context.coordinator.parent = self - context.coordinator.url = url - } + typealias ID = Identifier + var id: ID { + item[keyPath: keyPath] + } } - } private struct Safari: UIViewControllerRepresentable { diff --git a/Sources/SafariView/ViewExtensions.swift b/Sources/SafariView/ViewExtensions.swift index dee0512fd..62cf7225e 100644 --- a/Sources/SafariView/ViewExtensions.swift +++ b/Sources/SafariView/ViewExtensions.swift @@ -67,12 +67,14 @@ public extension View { /// - 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: @escaping () -> SafariView) -> some View { - let modifier = SafariView.Modifier(isPresented: isPresented, - build: safariView, - onDismiss: onDismiss ?? {}) + 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) } @@ -127,15 +129,80 @@ public extension View { /// - 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 { + func safari( + item: Binding, + onDismiss: (() -> Void)? = nil, + @ViewBuilder safariView: @escaping (Item) -> SafariView + ) -> some View where Item: Identifiable { let modifier = SafariView.ItemModitifer(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 { + let modifier = SafariView.GenericItemModifier(item: item, id: id, onDismiss: onDismiss, safariView: 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: @@ -186,13 +253,17 @@ public extension View { /// - 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, - safariView: @escaping (URL) -> SafariView) -> some View { - let modifier = SafariView.URLModifier(url: url, - build: safariView, - onDismiss: onDismiss ?? {}) - return ModifiedContent(content: self, modifier: modifier) + func safari( + url: Binding, + onDismiss: (() -> Void)? = nil, + @ViewBuilder safariView: @escaping (URL) -> SafariView + ) -> some View { + safari( + item: url, + id: \.hashValue, + onDismiss: onDismiss, + safariView: safariView + ) } }