来週のWWDC25で発表されるSwiftUIの新機能に、WebViewがあるだろう、ということが話題になった。OSSであるWebKitのリポジトリから、それが容易に伺える。このSwiftUIのWebViewのコードを読むと、よくできている。これがなぜよくできているのか、宣言的UIフレームワークとしてのSwiftUIという観点から、説明を試みたい。
SwiftUIのためにWebViewを作るには、WebViewの状態とそれを表示するビューを切り分ける必要があって、WebViewとWebPageの分離こそが肝要だったのだろうな。実際に、WebPageの方にWKWebViewがあって、一見トリッキーだけど、これこそまさにアイデアだ。https://t.co/nzZyyM4CA9
— Hiroki Kato (@cockscomb) 2025年6月2日
説明にあたって、回り道ではあるが、まずは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) ... } } }
ではページのタイトルを参照したいときはどうか。実装上はWKWebViewのtitle 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の使われる頻度からしても、ようやくか、という見方もある。しかし時間を要しただけのことはあるのではないか。