はじめに
当方、Swift/SwiftUIは初心者ですが、勉強がてら個人開発を進めて先日Qiitaのクライアントアプリ「QiitaReader」をAppStoreでリリースしました🎉
よければ覗いてみてください(現在iOS16.4
以降対応)
このアプリを構築する中で少し手こずった、「WebViewと通常のViewを同じスクロール領域でいい感じに表示する」という点を備忘録的に解説しようと思います。
実現したこと
記事閲覧画面を Qiita API v2 のGET /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を構成します。(スタイリングは割愛します)
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: View {
var body: some View {
NavigationLink(
destination: OtherView() // 別スクリーン
) {
Image(systemName: "person.circle.fill")
.resizable()
}
}
}
課題
普通に「ScrollViewにHeaderViewとHtmlViewをぶち込めばいいしっしょ」と思ったのですが、
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を作成してみます
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の高さを保存するため)
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を初期化して、検知した高さがいい感じに適用されるように変更します。
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と力を合わせつつ実装しましたが、かなりパワープレイだと思うんで他にいいやり方があるかもです