0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

iOSAdvent Calendar 2023

Day 14

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

Posted at

はじめに

当方、Swift/SwiftUIは初心者ですが、勉強がてら個人開発を進めて先日Qiitaのクライアントアプリ「QiitaReader」をAppStoreでリリースしました🎉

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

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

実現したこと

記事閲覧画面を Qiita API v2GET /api/v2/items/:item_idのレスポンスrendered_body(マークダウンをHTMLとしてレンダーした結果)を使用し、自前のCSSと組み合わせてWebViewで公式サイト風にマークダウンを表示します。

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

画像 gif

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

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重に出ていることがわかります。

どこで見たか忘れたんですが、 とりあえずどうやらWKWebViewはscrollViewを持っているらしいんですよね。

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

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

解決策

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

読み進めると、

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

0
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?