cockscomblog?

cockscomb on hatena blog

SwiftUIでSFSafariViewControllerを使う手法の探求

SwiftUIからSFSafariViewControllerを使いたい場面は多い。

SafariView

SFSafariViewControllerはビューコントローラーだから、UIViewControllerRepresentableを使ってSwiftUIのビューにしてしまうのが簡単か。

import SwiftUI
import SafariServices

struct SafariView: UIViewControllerRepresentable {
    typealias UIViewControllerType = SFSafariViewController

    typealias Configuration = SFSafariViewController.Configuration

    private let url: URL
    private let configuration: Configuration?

    init(url: URL, configuration: Configuration? = nil) {
        self.url = url
        self.configuration = configuration
    }

    func makeUIViewController(context: Context) -> SFSafariViewController {
        let safariViewController: SFSafariViewController
        if let configuration {
            safariViewController = SFSafariViewController(url: url, configuration: configuration)
        } else {
            safariViewController = SFSafariViewController(url: url)
        }
        return safariViewController
    }

    func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
    }
}

誰でもこういうコードを書いたことがあるんじゃないか。

あとは何らかのきっかけ‌fullScreenCover(isPresented:onDismiss:content:))なんかを使って、このSafariViewを表示してやればいい。 UIViewControllerpresent(_:animated:completion:)とは少し違うけど、他にいい方法を知らない。

じゃあ「何らかのきっかけ」ってなんだろう、というところが本題。

OpenURLAction

SwiftUIでは、TextのイニシャライザにAttributedStringを渡せば、文字列中にリンクを埋め込める。 あるいはそのものずばりLinkビューというのがあって、URLへのリンクを表現できる。 こういうのをクリックしたときもSFSafariViewControllerを表示したい。

実はLinkドキュメンテーションにいい例があった。

Link("Visit Our Site", destination: URL(string: "https://www.example.com")!)
    .environment(\.openURL, OpenURLAction { url in
        print("Open \(url)")
        return .handled
    })

LinkText内のリンクをクリックすると、EnvironmentからOpenURLActionが呼び出される。 そしてこれは上書き可能になっている。 OpenURLActionを上書きして、SFSafariViewControllerを開くようにフックしてやればよいらしい。

渡ってくるURLを状態として持って、URLが存在するときSafariViewを開く、という風にしたい。 何とか再利用性のあるコードにしたいので、ちょっと考える。

ViewModifier

ここで、ViewModifierを使えばモディファイア内に状態を持てることを思い出す。 ちょっと書いてみるとこういう感じになる。

import SwiftUI

struct OpenURLInSafariViewModifier: ViewModifier {
    @State private var url: URL? = nil
    private var isPresented: Binding<Bool> {
        Binding {
            url != nil
        } set: { newValue in
            if newValue == false {
                url = nil
            }
        }
    }

    private let configuration: SafariView.Configuration?

    init(configuration: SafariView.Configuration?) {
        self.configuration = configuration
    }

    func body(content: Content) -> some View {
        content
            .environment(\.openURL, OpenURLAction { url in
                switch url.scheme {
                case "https"?, "http"?:
                    self.url = url
                    return .handled
                default:
                    return .systemAction(url)
                }
            })
            .fullScreenCover(isPresented: isPresented) {
                if let url {
                    SafariView(url: url, configuration: configuration)
                        .edgesIgnoringSafeArea(.all)
                }
            }

    }
}

extension View {
   func openURLInSafariView(configuration: SafariView.Configuration? = nil) -> some View {
        return modifier(OpenURLInSafariViewModifier(configuration: configuration))
    }
}

いっけんよさそう。

SafariServicesUI

こういう感じで使う。

import SwiftUI

struct ContentView: View {
    var body: some View {
        Link("Open in SFSafariViewController", destination: URL(string: "https://developer.apple.com")!)
            .openURLInSafariView()
    }
}
import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Open in SFSafariViewController with [Attributed String](https://developer.apple.com)")
            .openURLInSafariView()
    }
}

状態が隠蔽されていて、シンプルだし、SwiftUIっぽいインターフェースだと思う。

ということでライブラリっぽくSwift Packageにしてみた。

どうでしょうか。