cockscomblog?

cockscomb on hatena blog

SwiftUIにおけるWebViewの実装

来週のWWDC25で発表されるSwiftUIの新機能に、WebViewがあるだろう、ということが話題になった。OSSであるWebKitリポジトリから、それが容易に伺える。このSwiftUIのWebViewのコードを読むと、よくできている。これがなぜよくできているのか、宣言的UIフレームワークとしてのSwiftUIという観点から、説明を試みたい。

説明にあたって、回り道ではあるが、まずはSwiftUIでWebViewを作ることを考える。WebViewの実態は、WebKitフレームワークWKWebViewを使えば良い。これをどうやってSwiftUIのViewにするのか。

WebViewのAPI

どうやってと言いながら、いったん実装のことを忘れて、WebViewのインターフェースを考える。最初に思いつくのは、最も単純なパターンで、URLを与えてページを読み込むものだ。

WebView(url: URL(string: "https://example.com")!)

これはよさそうだ。次に、このWebViewを使ったUIを考える。

アドレスバー

簡単なWebブラウザを作るとして、まずはアドレスバーから考えてみる。

struct BrowserView: View {
    @State var url: URL = URL(string: "about:blank")!
    var body: some View {
        VStack {
            WebView(url: url)
            TextField("Enter URL", text: Binding(
                get: { url.absoluteString },
                set: { url = URL(string: $0) ?? url }
            ))
        }
    }

WebViewの外部に置かれたTextFieldでURLを入力させる。WebViewにはそれを与えることで、ユーザーの入力したページを開ける。

ところが、少し考えてみると、これはうまく機能しない。ユーザーがWebViewを操作して別のページへ移動したとき、TextFieldにそれが反映されない。反映させるために、例えばWebViewはBinding<URL>を受け取るとよいだろうか。

struct WebView: View {
    @Binding var url: URL
    ...
}

struct BrowserView: View {
    @State var url: URL = URL(string: "about:blank")!
    var body: some View {
        VStack {
            WebView(url: $url)
            ...
        }
    }
}

ではページのタイトルを参照したいときはどうか。実装上はWKWebViewtitle propertyを読み取ればよいが、これをBinding<String>として扱うのはおかしい。ページのタイトルは外から与えられない。SwiftUIのデータフローでは、Preferencesを使うこともできるが、間接的なアプローチになる。

「戻る」ボタン

Webブラウザには必ずある「戻る」ボタンはどうやって作るとよいのだろう。WebViewにgoBack()メソッドを作ったとして、Viewをどう参照すればよいのか。また、「戻る」ボタンは、back-forward list上に戻ることのできる要素がある時だけ有効であるべきだ。戻ることができるかどうか、どう判別するとよいのか。

戻ることができるかどうかは、やはりPreferencesを使うこともできるだろうが、別なアイデアとして、ScrollViewReaderのように、プロキシを介してWebViewの情報を読み取らせることも考えられる。この手段は、これまで出てきた中で最もユニバーサルな解決策になり得る。プロキシにgoBack()メソッドを持たせることで、実際に「戻る」ボタンを機能させられる。

このアプローチは実際にcybozu/WebUIで用いられている。

Single Source of Truth

ここで基本に立ち返って、SwiftUIの(あるいは宣言的UIフレームワークの)重要な考え方である、Source of Truthとしてのステートがあり、ビューはそれの写像である、ということを考える。ビューから状態を得るのではなく、ビューの外から状態を与える。

これに従って「戻る」機能を考え直すと、BackForwardListがWebViewの外にあって、それをWebViewに与えるのがよいはずだ。

@Observable
class BackForwardList {
    var canGoBack: Bool = false
    func goBack() {
        ...
    }
}

struct WebView: View {
    @Binding var url: URL
    @Binding var backForwardList: BackForwardList
    ...
}

struct BrowserView: View {
    @State var url: URL = URL(string: "about:blank")!
    @State var backForwardList = BackForwardList()
    var body: some View {
        VStack {
            WebView(url: $url, backForwardList: $backForwardList)
            ...
        }
    }
}

これはまさに、正しいAPIじゃないだろうか。

ステートとしてのWebView

ここでようやく冒頭の話に戻って、SwiftUIの新しいWebViewがどのようなものか見てみる。

SwiftUIのWebViewには、ふたつのイニシャライザがある。ひとつはinit(_ page: WebPage)で、もうひとつはinit(url: URL?)となっている。URLを受け取る方も、内部的にはWebPageを作っているので、このWebPageこそが重要だとわかる。

WebPageのコードを見てみると、まさにvar url: URL?var title: Stringのようなcomputed propertyがある。もちろんvar backForwardList: BackForwardList = BackForwardList()というstored propertyもある。なるほど、WebViewに与えるステートの塊がWebPageということだ。

さらにWebPageをよくみると、lazy var backingWebView: WebPageWebViewというのがある。このWebPageWebViewというのはWKWebViewのサブクラスだ。そう、何を隠そう、WebPageの側がWKWebViewというWebViewの本体を保持しているのだ。この意味において、WKWebViewはビューではなくステートになっている。

実際のところ、WebViewRepresentableを見ると、WebPageが保持するWKWebViewがそのまま表示されていることもわかる。

実際にこの新しいWebViewを使ったコードがリポジトリに置かれている。WebPageをView Modelに持たせているが、基本的な発想はここまで書いた通りのものと思う。

まとめます

ここまで、新しいWebViewのAPIを説明した。SwiftUIの観点からみて、とてもよく設計されているのがわかったと思う。

SwiftUIにWebViewが追加されるのは、WebViewの使われる頻度からしても、ようやくか、という見方もある。しかし時間を要しただけのことはあるのではないか。