Last active
February 14, 2025 11:13
-
-
Save JamesSedlacek/c1d215bab0610b3d2c2aea062de5e565 to your computer and use it in GitHub Desktop.
This file provides a safe way to open URLs in SwiftUI applications.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// View+OpenUrl.swift | |
// | |
// Created by James Sedlacek on 11/26/24. | |
// | |
/// This file provides a safe way to open URLs in SwiftUI applications. | |
/// It adds a view modifier that handles URL opening with user confirmation | |
/// and multiple opening options (browser, in-app, or copy to clipboard). | |
/// | |
/// Usage example: | |
/// ```swift | |
/// struct ContentView: View { | |
/// @State private var urlToOpen: URL? | |
/// | |
/// var body: some View { | |
/// Button("Open Website") { | |
/// urlToOpen = URL(string: "https://example.com") | |
/// } | |
/// .open(url: $urlToOpen) | |
/// } | |
/// } | |
/// ``` | |
import SwiftUI | |
import WebKit | |
/// Provides safe URL opening functionality to any SwiftUI View. | |
@MainActor | |
public extension View { | |
/// Adds safe URL opening functionality with user confirmation. | |
/// - Parameter url: A binding to an optional URL that should be opened. | |
/// - Returns: A view with URL opening capabilities. | |
func open(url: Binding<URL?>) -> some View { | |
modifier(OpenURLModifier(url: url)) | |
} | |
} | |
@MainActor | |
struct OpenURLModifier { | |
@Environment(\.openURL) private var openURL | |
@Binding private var urlToOpen: URL? | |
@State private var isConfirmationPresented = false | |
@State private var isWebViewPresented = false | |
@State private var isCopyAlertPresented = false | |
init(url: Binding<URL?>) { | |
self._urlToOpen = url | |
} | |
private func onURLChange(_ oldUrl: URL?, _ newUrl: URL?) { | |
guard newUrl != nil else { return } | |
isConfirmationPresented = true | |
} | |
private func copyLinkAction() { | |
guard let linkString = urlToOpen?.absoluteString else { return } | |
ClipboardService.copy(linkString) | |
isCopyAlertPresented = true | |
} | |
private func openInBrowserAction() { | |
guard let urlToOpen else { return } | |
openURL(urlToOpen) | |
resetURLAction() | |
} | |
private func openInAppAction() { | |
isWebViewPresented = true | |
} | |
private func resetURLAction() { | |
Task { | |
// Add a small delay to allow animations to complete | |
// and provide a better user experience when transitioning | |
// between different states (alerts, sheets, etc.) | |
try? await Task.sleep(for: .seconds(1)) | |
urlToOpen = nil | |
} | |
} | |
} | |
extension OpenURLModifier: ViewModifier { | |
func body(content: Content) -> some View { | |
content | |
.onChange(of: urlToOpen, onURLChange) | |
.confirmationDialog( | |
.confirmationTitle, | |
isPresented: $isConfirmationPresented, | |
titleVisibility: .visible, | |
actions: confirmationButtons | |
) | |
.alert( | |
.copiedToClipboard, | |
isPresented: $isCopyAlertPresented, | |
actions: alertButtons | |
) | |
.sheet( | |
isPresented: $isWebViewPresented, | |
onDismiss: resetURLAction, | |
content: webView | |
) | |
} | |
@ViewBuilder | |
private func confirmationButtons() -> some View { | |
Button(.openInBrowser, action: openInBrowserAction) | |
Button(.openInApp, action: openInAppAction) | |
Button(.copyLink, action: copyLinkAction) | |
Button(.cancel, role: .cancel, action: resetURLAction) | |
} | |
private func alertButtons() -> some View { | |
Button(.ok, role: .cancel, action: resetURLAction) | |
} | |
@ViewBuilder | |
private func webView() -> some View { | |
if let urlToOpen { | |
WebView(url: urlToOpen) | |
} | |
} | |
} | |
#if os(iOS) | |
typealias ViewRepresentable = UIViewRepresentable | |
#elseif os(macOS) | |
typealias ViewRepresentable = NSViewRepresentable | |
#endif | |
struct WebView: ViewRepresentable { | |
let url: URL | |
#if os(iOS) | |
func makeUIView(context: Context) -> WKWebView { | |
WKWebView() | |
} | |
func updateUIView(_ webView: WKWebView, context: Context) { | |
webView.load(URLRequest(url: url)) | |
} | |
#elseif os(macOS) | |
func makeNSView(context: Context) -> WKWebView { | |
WKWebView() | |
} | |
func updateNSView(_ webView: WKWebView, context: Context) { | |
webView.load(URLRequest(url: url)) | |
} | |
#endif | |
} | |
#if os(macOS) | |
import AppKit | |
#endif | |
struct ClipboardService { | |
static func copy(_ string: String) { | |
#if os(iOS) | |
UIPasteboard.general.string = string | |
#elseif os(macOS) | |
NSPasteboard.general.clearContents() | |
NSPasteboard.general.setString(string, forType: .string) | |
#endif | |
} | |
} | |
@MainActor | |
extension LocalizedStringKey { | |
static let confirmationTitle = LocalizedStringKey("How would you like to handle this link?") | |
static let openInBrowser = LocalizedStringKey("Open in Browser") | |
static let openInApp = LocalizedStringKey("Open in App") | |
static let copyLink = LocalizedStringKey("Copy Link") | |
static let cancel = LocalizedStringKey("Cancel") | |
static let copiedToClipboard = LocalizedStringKey("Copied to Clipboard") | |
static let ok = LocalizedStringKey("OK") | |
} |
extension OpenURLModifier {
private func binding(for option: PresentedOption) -> Binding<Bool> {
Binding(
get: { presentedOption == option },
set: { presentedOption = $0 ? option : .none }
)
}
}
I think it's nice to model state in a enum but the closure based Binding has to be evaluated all the time which is ineffecient.
Yeah I just messed around with the enum
idea and I think it makes sense to stick with what I have.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I really like this idea! I'll work on updating it later today.