WKWebViewとネイティブのViewを同じスクロール領域でいい感じに表示したい

## はじめに

当方、Swift/SwiftUIは初心者ですが、勉強がてら個人開発を進めて先日Qiitaのクライアントアプリ「[QiitaReader](https://apps.apple.com/jp/app/qiitareader/id6470926641)」をAppStoreでリリースしました🎉

よければ覗いてみてください（現在`iOS16.4`以降対応）

https://apps.apple.com/jp/app/qiitareader/id6470926641

このアプリを構築する中で少し手こずった、「WebViewと通常のViewを同じスクロール領域でいい感じに表示する」という点を備忘録的に解説しようと思います。

## 実現したこと

記事閲覧画面を [Qiita API v2](https://qiita.com/api/v2/docs#%E6%8A%95%E7%A8%BF) の`GET /api/v2/items/:item_id`のレスポンス`rendered_body`（マークダウンをHTMLとしてレンダーした結果）を使用し、自前のCSSと組み合わせてWebViewで公式サイト風にマークダウンを表示します。

WKWebViewとSwiftUIのViewコンポーネントを同じスクロール領域に表示（厳密には違うが）して、いい感じにスクロールできるようにしました。

|画像|gif|
|:-:|:-:|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679599/303aa284-b929-898a-0d7a-8c9138a9f49b.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679599/e6e3135d-d6c1-d418-84e3-3eea2df5084b.gif)|

ViewコンポーネントをHTMLとしてWebViewに含めるという手もありましたが、他のスクリーンに遷移させたかったためViewコンポーネントとして表示したかった訳です（他にいい方法あれば教えてください）

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679599/e7917c3c-4ba4-0095-6d97-eccf6dc2ebde.gif)


## WKWebViewの実装

まず前提として、HTMLをレンダーする部分のViewの実装です。

`rendered_body`には`<p>タイトル</p>`のようにbodyタグに入るような内容のみ入っているので、イニシャライザで全体のHTMLを構成します。（スタイリングは割愛します）

```HtmlView.swift
struct HtmlView: UIViewRepresentable {
    let htmlString: String
    
    init(
        htmlString: String
    ) {
        // css, javascriptを別途用意して埋め込みもできる
        self.htmlString = """
            <!DOCTYPE html>
            <html lang="ja">
                <head>
                    \(css)
                    \(javascript)
                </head>
                <body>
                    \(htmlString)
                </body>
            </html>
        """
    }
    
    func makeUIView(context: Context) -> WKWebView {
        let webView = WKWebView()
        return webView
    }
    
    func updateUIView(_ uiView: WKWebView, context: Context) {
        uiView.loadHTMLString(htmlString, baseURL: nil)
    }
}
```

WebViewと同じ領域に表示したいViewコンポーネントも作成しましょう
こちらも本題ではないので詳細は割愛し、`HeaderView`の中身はキャプチャのものとは内容が異なります。

```HeaderView.swift
HeaderView: View {
    var body: some View {
        NavigationLink(
            destination: OtherView() // 別スクリーン
        ) {
            Image(systemName: "person.circle.fill")
                .resizable()
        }
    }
}

```


## 課題

普通に「ScrollViewにHeaderViewとHtmlViewをぶち込めばいいしっしょ」と思ったのですが、

```ArticleView.swift
struct ArticleView: View {
    let renderedBody: String
    
    var body: some View {
        ScrollView {
            VStack{
                HeaderView()
                HtmlView(htmlString: renderedBody)
                    .frame(height: UIScreen.main.bounds.height)
            }
        }
    }
}
```

うまくい... かないんですよね
よく見るとスクロールバーが2重に出ていることがわかります。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679599/e29f708a-2168-d591-bbbe-937290b15ff3.gif)

~~どこで見たか忘れたんですが、~~ とりあえず[どうやらWKWebViewはscrollViewを持っているらしい](https://developer.apple.com/documentation/webkit/wkwebview/1614784-scrollview)んですよね。

だからScrollViewとWKWebView内のUIScrollViewが競合(?)する感じで、二重にスクロール領域が表示されてしまう訳です。

かといってScrollViewをなくすとHeaderViewがスクロールされなくなってしまいます。困った。

## 解決策

いろいろ調べていると、StackOverflowで下記のような情報を見つけました。

https://stackoverflow.com/questions/70777919/detect-scroll-in-webview-with-swift

読み進めると、
> WebView with UIScrollViewDelegate support.

とあり、Observable ScrollViewDetectorを定義してスクロール位置を検知できそうです。

HeaderViewにoffsetをつけて検知したスクロール位置の分だけ移動させてopacityを下げればScrollViewなしでHeaderViewを動かせることに気がつきました。

## スクロール検知の実装

StackOverflowにあったようにScrollViewDetectorを作成してみます

```ScrollViewDetector.swift
import UIKit.UIScrollView

final class ScrollViewDetector: NSObject, ObservableObject, UIScrollViewDelegate {
    @Published var scrollViewOffset: CGFloat = 0
    @Published var headerHeight: CGFloat = 0

    // スクロールされた時に実行される
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        scrollViewOffset = scrollView.contentOffset.y >= headerHeight ? headerHeight : scrollView.contentOffset.y
    }

    // HeaderView描画時にHeaderViewの高さを保存する
    func setHeaderHeight(_ height: CGFloat) {
        headerHeight = height
    }
}
```

`setHeaderHeight`はHeaderViewが描画された際に高さを保存するためのメソッドです。

スクロールされるたびに`scrollViewOffset`が更新されてwebviewがどれだけ動いたかが更新されます。
ここでHeaderViewの高さ以上をoffsetにする必要はないため、HeaderViewの高さを超える場合にはheaderHeightをscrollViewOffsetに設定しています。

そして、WKWebViewが持っているUIScrollViewのUIScrollViewDelegateプロトコルを先ほど作成したscrollViewDelegateで上書きすることで検知できるようになるので、HtmlViewをそのように変更します。

ここで、ScrollViewDetectorの初期化はArticleViewで行うためHtmlViewでは引数として受け取る形にします。
（setHeaderHeightをHeaderViewの`.onAppear`に仕込んでHeaderViewの高さを保存するため）

```HtmlView.swift
struct HtmlView: UIViewRepresentable {
    var scrollViewDelegate: UIScrollViewDelegate?
    let htmlString: String
    
    init(
        scrollViewDelegate: UIScrollViewDelegate?,
        htmlString: String
    ) {
        self.scrollViewDelegate = scrollViewDelegate
        // css, javascriptを別途用意して埋め込みもできる
        self.htmlString = """
            <!DOCTYPE html>
            <html lang="ja">
                <head>
                    \(css)
                    \(javascript)
                </head>
                <body>
                    \(htmlString)
                </body>
            </html>
        """
    }
    
    func makeUIView(context: Context) -> WKWebView {
        let webView = WKWebView()
        webView.scrollView.delegate = scrollViewDelegate
        return webView
    }
    
    func updateUIView(_ uiView: WKWebView, context: Context) {
        uiView.loadHTMLString(htmlString, baseURL: nil)
    }
}
```

あとはArticleViewでScrollViewDetactorを初期化して、検知した高さがいい感じに適用されるように変更します。

```ArticleView.swift
struct ArticleView: View {
    let renderedBody: String

    // ScrollViewDetactorの初期化
    @StateObject var scrollViewDetector = ScrollViewDetector()
    
    var body: some View {
        ZStack {
            GeometryReader { geo in
                HtmlView(
                    scrollViewDelegate: scrollViewDetactor,
                    htmlString: renderedBody
                )
                // 高さを(画面高さ - ヘッダ高さ + スクロールされた分)にする
                .frame(height: geo.size.height - scrollViewDetector.headerHeight + scrollViewDetector.scrollViewOffset)
                // 上に(ヘッダ高さ - スクロールされた分)移動する
                .offset(x: 0, y: scrollViewDetector.headerHeight-scrollViewDetector.scrollViewOffset)
    
                HeaderView()
                    .background(
                        GeometryReader { geo in
                            Color.clear
                                .onAppear {
                                    let headerHeight = geo.size.height
                                    scrollViewDetector.setHeaderHeight(headerHeight)
                                }
                        }
                    )
                    // 常に scrollViewOffset <= headerHeight なので
                    .opacity(CGFloat(1) - (scrollViewDetector.scrollViewOffset / scrollViewDetector.headerHeight))
                    .frame(height: scrollViewDetector.headerHeight)
                    // スクロールされた分だけ移動
                    .offset(x: 0, y: -scrollViewDetector.scrollViewOffset)
            }
        }
    }
}
```

可読性を犠牲に、やりたかったことができました（白目）

## おわりに

わからなかったのが、なぜかHeaderViewをHtmlViewの下に記述しないと期待通り動かなかったんですよね🤔

ChatGPTと力を合わせつつ実装しましたが、かなりパワープレイだと思うんで他にいいやり方があるかもです