Skip to content

Instantly share code, notes, and snippets.

@JamesSedlacek
Last active February 14, 2025 11:13
Show Gist options
  • Save JamesSedlacek/c1d215bab0610b3d2c2aea062de5e565 to your computer and use it in GitHub Desktop.
Save JamesSedlacek/c1d215bab0610b3d2c2aea062de5e565 to your computer and use it in GitHub Desktop.
This file provides a safe way to open URLs in SwiftUI applications.
//
// 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")
}
@gtokman
Copy link

gtokman commented Jan 30, 2025

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.

@JamesSedlacek
Copy link
Author

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