🍲

webViewでswiftUI viewを表示する

に公開

iOSアプリ開発において、「WebViewの中にネイティブなSwiftUIviewを重ねて表示したい」という要件が出てくることもあります。

この記事では、WKWebViewの中にSwiftUIビューを追加する方法を紹介し、特に「Webコンテンツの末尾にSwiftUI製のフッターを追加する」ユースケースに焦点を当てます。

目標

・WKWebViewで表示されるWebページの末尾に、SwiftUIで作成したフッターを追加表示します。

・フッターの高さは動的に指定可能

・Webの内容が短くても、無駄な空白が生まれないよう調整

・Webページ側にマーカー(app-footer-placeholder)を設置することで、フッターの位置を決定

追加する方法

方法1

WKWebView の scrollView に SwiftUI View を直接追加する

メリット デメリット
Web 側の任意の場所に View を追加できる(たとえば中間や末尾など)という柔軟性 Webとの連携が必要、app-footer-placeholder を Web 側に埋め込む必要がある
WebView 高さに依存しない、WebView 自体はフルサイズで固定なので、高さ調整が不要 JavaScript依存、getBoundingClientRect() などで位置を取得しないとズレる可能性あり
SwiftUI View を後から差し替え可能、scrollView に乗っているので表示更新も容易 デバッグがやや難しい、レイアウトのミスマッチが起きたとき、原因がWebかSwiftUIか判断しにくい

方法2

親の ScrollView に WebView と SwiftUI View を順に配置する

ScrollView {
    WebView()
    FooterView()
}
メリット デメリット
SwiftUIのレイアウト構造にフィットしやすく、設計が明快 WebViewの高さ測定が難しい、正確な高さを取得するにはJavaScript + evaluateJavaScript が必要
更新しやすい、SwiftUI View の差し替えや表示条件が簡単に書ける レイアウトが崩れやすい、高さがズレると、WebViewの一部がスクロールできなくなることがある
デバッグしやすい、レイアウトの流れがSwiftUIだけで完結するため、調整が楽 WebViewが読み込み完了してからでないとViewを確定できないため、表示タイミング制御が難しい

方法1を採用しました。方法1を選んだ理由は、

・WebView 本来のスクロールがそのまま使える
・Webコンテンツの任意の場所に SwiftUI View を挿入できる
・Webページがどんなに長くても対応できる
・非同期にUIを追加・制御しやすい
・Webとアプリのハイブリッド設計に向いている

実装の概要

フッター表示の主な流れは以下の通りです:

・WKWebViewが読み込みを完了したタイミングで、JavaScriptを使ってスペーサーを注入(主にiOS側での描画調整用)。

・Webページ内に存在するマーカー(例: id="app-footer-placeholder")の位置を取得。

・SwiftUIで作成されたviewを、その位置に合わせて scrollView 上に追加。

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    let footerHeight: CGFloat = parent.footerHeight
    
    let injectScript = """
        (function() {
            const existing = document.getElementById('ios-footer-placeholder');
            if (!existing) {
                const spacer = document.createElement('div');
                spacer.id = 'ios-footer-placeholder';
                spacer.style.height = '\(footerHeight)px';
                spacer.style.background = 'transparent';
                spacer.style.pointerEvents = 'none';
                document.body.appendChild(spacer);
            }
        })();
    """
    webView.callAsyncJavaScript(injectScript, in: nil, in: WKContentWorld.defaultClient, completionHandler: nil)
    
    let getFooterYScript = """
    (function() {
        const el = document.getElementById("app-footer-placeholder");
        if (!el) return null;
        const rect = el.getBoundingClientRect();
        return rect.top + window.scrollY;
    })()
    """
    webView.evaluateJavaScript(getFooterYScript) { [weak self] result, error in
        if let y = result as? CGFloat,
           let self {
            if let footerHostingView {
                let scrollView = webView.scrollView
                scrollView.addSubview(footerHostingView)
                scrollView.contentSize.height += footerHeight
            } else {
                let hosting = hostingController
                
                let scrollView = webView.scrollView
                
                hosting.view.frame = CGRect(
                    x: 0,
                    y: y,
                    width: scrollView.frame.width,
                    height: footerHeight
                )
                
                scrollView.addSubview(hosting.view)
                scrollView.contentSize.height += footerHeight
                
                footerHostingView = hosting.view
            }
        }
    }
}

注意点

1 Webページ側にapp-footer-placeholderを用意する必要がある

この実装では、Webページにあらかじめ以下のようなHTML要素が配置されている必要があります:
<div id="app-footer-placeholder"></div>
この要素の位置を基準にフッターの表示位置を決めているため、Web側との事前連携が重要です。
 
2 Webの内容が短い場合の対策

内容が少なく、スクロール可能な範囲が狭いWebページでは、フッターが画面内に食い込んでしまうことがあります。これを防ぐため、iOS側でスペーサー(透明のDIV)を挿入し、最低限の高さを確保しています。

SKIYAKI Tech Blog

Discussion