LoginSignup
6
4

More than 1 year has passed since last update.

「The Ultimate Guide to WKWebView」をSwiftUIで実装する #09 - Reading pages the user has visited -

Last updated at Posted at 2022-03-06

「The Ultimate Guide to WKWebView」をSwiftUIで実装してみるの、
9つ目になります。

今回は、
「The Ultimate Guide to WKWebView」をSwiftUIで実装する #07 - Monitoring page loads -」と
「The Ultimate Guide to WKWebView」をSwiftUIで実装する #08 - Reading a web page’s title as it changes -」で作成したものをベースに、
少しiOS15向けに修正して、履歴閲覧機能をつけました。

ちなみに今回はWKWebViewが、というかSwiftUIで引っかかって
記事作成までだいぶ時間がかかりました・・・ :sweat_drops:

目次

シリーズ化していこうと思うので、全体の目次を置いておきます。
リンクが貼られていないアジェンダは、記事作成中または未作成のものになります。

# タイトル
01 Making a web view fill the screen
(WebViewを画面に表示する)
02 Loading remote content
(リモートのコンテンツを読み込む)
03 Loading local content
(ローカルのコンテンツを読み込む)
04 Loading HTML fragments
(HTMLフラグメントの読み込み)
05 Controlling which sites can be visited
(訪問可能なサイトの制御)
06 Opening a link in the external browser
(外部ブラウザでリンクを開く)
07 Monitoring page loads
(ページの読み込みを監視する)
08 Reading a web page’s title as it changes
(Webページのタイトルの変化を読み取る)
09 Reading pages the user has visited
(ユーザーが閲覧したページを読み取る)
10 Injecting JavaScript into a page
(JavaScriptをページに注入する)
11 Reading and deleting cookies
(cookieの読み取りと削除)
12 Providing a custom user agent
(カスタムUser Agentを提供する)
13 Showing custom UI
(カスタムUIを表示する)
14 Snapshot part of the page
(ページの一部のスナップショットを撮る)
15 Detecting data
(データの探索)

環境

【Xcode】13.1
【Swift】5.5
【iOS】15.0
【macOS】Big Sur バージョン 11.4

実現したいこと

今回やることは
「>」と「<」のボタンを長押しすることで、モーダルで閲覧履歴を表示します。

また表示された履歴のリンクをタップすると、そのページに遷移します。

app.gif

「#08」で作ったものに対しての機能追加なので、#08で作っていた機能はそのまま使えます。

また参考にもリンクを記載していますが、
今回のアプリの挙動は、過去にObjective-Cで書かれた「[iOS 8] WKBackForwardList で 閲覧履歴を管理する」を参考にしております。感謝。

ワンタップとロングタップを同じボタンに対して適用する方法が、SwiftUIだとなかなか難しかった・・・(後述します)

実現方法

まずWebViewです。

今回のメインである閲覧履歴とは関係ないところは省略しています。
全体が見たい方は、一番下に貼ったGitHubのリンクをご確認ください。

WebView.swift
import SwiftUI
import WebKit

struct WebView: UIViewRepresentable {
    // 省略...

    var url: URL?
    @Binding var backList: [WKBackForwardListItem]
    @Binding var forwardList: [WKBackForwardListItem]
    @Binding var shouldUpdateWebView: Bool

    private let webView = WKWebView()

    func makeUIView(context: Context) -> WKWebView {
        webView.navigationDelegate = context.coordinator
        guard let url = url else {
            return WKWebView()
        }
        let request = URLRequest(url: url)
        webView.load(request)
        return webView
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        // 省略...

        // ここで止めておかないと無限ループが発生する
        // - seealso: https://swiftui-lab.com/state-changes/
        if !shouldUpdateWebView { return }
        guard let url = url else {
            return
        }
        let request = URLRequest(url: url)
        uiView.load(request)

        DispatchQueue.main.async {
            shouldUpdateWebView = false
        }
    }

    func makeCoordinator() -> Coodinator {
        return Coodinator(self)
    }

    func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
        coordinator.observations.removeAll()
    }
}

extension WebView {
    class Coodinator: NSObject, WKNavigationDelegate {
        var parent: WebView
        var observations: [NSKeyValueObservation?] = []

        init(_ parent: WebView) {
            // 省略...(KVOでestimatedProgress,isLoading,titleを監視する)
        }

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            // 省略...

            parent.backList = webView.backForwardList.backList
            parent.forwardList = webView.backForwardList.forwardList
        }
    }
}

では詳細を見ていきます。

閲覧履歴は、backForwardListから取得することができます。
がその前に、このプロパティが存在しているWKBackForwardListの定義から見ていきます。

Appel公式ドキュメントのOverViewには、以下のように書いてあります。

「以前読み込まれたページを取得するために、WKBackForwardListオブジェクトを使用する。 通常、このオブジェクトを直接作成することはない。
各WebViewは、自動的に1つのWKBackForwardListオブジェクトを作成
し、読み込まれた全てのページの履歴を保存するためにそれを使用する。
このオブジェクトをWebViewの backForwardListプロパティから取得し、その内容を使用して、プログラムによる操作を容易にします。」

Use a WKBackForwardList object to retrieve a web view’s previously loaded pages. Typically, you don’t create WKBackForwardList objects directly. Each web view creates one automatically and uses it to store the history of all loaded pages. Fetch this object from your web view’s backForwardList property and use its contents to facilitate programmatic navigation.

太字にした文は、考えれば想定つくことなのですが、あまり意識してはなかったので個人的な学びでした。

ではそんなわけで、backForwardListから履歴を取得します。

名前からも想像できる通り、現在表示しているページの前と後のページのリスト両方をこのプロパティで取得することができます。

参考までにドキュメントコメントはこうなってます。↓

/** @abstract The portion of the list preceding the current item.
@discussion The items are in the order in which they were originally
visited.
*/
open var backList: [WKBackForwardListItem] { get }

    
/** @abstract The portion of the list following the current item.
@discussion The items are in the order in which they were originally
visited.
*/
open var forwardList: [WKBackForwardListItem] { get }

前と後ろのリストはちゃんと別々のプロパティで管理されています。

「 < 」を長押しした場合は`backList`を、「>」を長押しした場合はforwardListを表示するようにしています。

ちなみにupdateUIView()では何をしているのかというと、
履歴一覧の画面で履歴を押下した時、一覧画面を自動で閉じて再度WebViewを表示するのですが
その時に実行される処理を書いています。

再度WebViewを表示した時、makeUIView()は呼ばれません。
もしmakeUIView()の方にしか、webView.load(request)の実装、つまりWebページを読み込むための実装がされていないと、
履歴画面で押下したページを読み込んでくれず、履歴一覧画面を表示する前の画面のままになってしまいます。

次にshouldUpdateWebViewのフラグについて。
これがtrueの時だけloadしないと無限ループが発生してしまったので使用しています・・・(ここ詰まった)

【SwiftUI】カレンダーを再描画する方法」に書かれていることと同じっぽかったので、フラグを使って実装しました。
でもいまいち発生する理由がわかったようなわかってないような・・・何か根本的なところを理解できていない気がする。

わかったらまた追記します。


では次に、取得した履歴を表示するViewです。
(実際はこのViewを表示する前に別のViewを実装していたのですが、この記事の主眼とは少しずれているので、順番を変えて説明しています。)

VisitHistory.View
import SwiftUI
import WebKit

struct VisitHistoryView: View {
    @Binding var isShownVisitHistory: Bool
    var visitedList: [WKBackForwardListItem]
    @Binding var selectedLink: URL?
    @Binding var shouldUpdateWebView: Bool

    var body: some View {
        NavigationView {
            List(visitedList, id: \.self) { list in
                listButton(title: list.title, url: list.url)
            }
                .listStyle(.plain)
                .navigationBarTitle("履歴", displayMode: .inline)
                .navigationBarItems(trailing: doneButton)
        }
    }
}

private extension VisitHistoryView {
    func listButton(title: String?, url: URL) -> some View {
        Button(action: {
            selectedLink = url
            isShownVisitHistory = false
            shouldUpdateWebView = true
        }) {
            VStack(alignment: .leading) {
                Text(title ?? "")
                    .lineLimit(1)
                Text(url.absoluteString)
                    .lineLimit(1)
                    .foregroundColor(.gray)
            }
        }
    }

    var doneButton: some View {
        Button(action: {
            isShownVisitHistory = false
        }) {
            Text("完了")
        }
    }
}

先ほど取得した履歴のデータが入っているプロパティbackListforwardListは、WKBackForwardListItem型です。

ここには以下の通り、3つの情報が入っています。

  • url:この項目によって表されるWebページのURL
  • title:この項目によって表されるWebページのタイトル
  • initialURL:この項目を作成するためにリクエストした最初のURL

結構シンプルですね。今回は、urltitleを使用しています。
initialURLは今回は特に使い道がなかったので、使ってません。

/** A WKBackForwardListItem object represents a webpage in the back-forward list of a web view.
 */
@available(iOS 8.0, *)
open class WKBackForwardListItem : NSObject {

    /** @abstract The URL of the webpage represented by this item.
     */
    open var url: URL { get }

    
    /** @abstract The title of the webpage represented by this item.
     */
    open var title: String? { get }

    
    /** @abstract The URL of the initial request that created this item.
     */
    open var initialURL: URL { get }
}

ここまでで、履歴閲覧の機能の実装自体は完了です。 :clap_tone2:


では次に、ここまで説明してきたWebView.swiftVisitHistory.swiftを表示しているViewです。

一番最初に書いた、今回苦労したことの一つである、ワンタップとロングタップの検知処理などはここです。

WebBaseView.swift
import SwiftUI
import WebKit

struct WebBaseView: View {
    // 省略...
    @State private var backList = [WKBackForwardListItem]()
    @State private var forwardList = [WKBackForwardListItem]()
    @State private var selectedLink = URL(string: "https://www.apple.com/")
    @State private var shouldUpdateWebView = false

    var body: some View {
        NavigationView {
            ZStack(alignment: .top) {
                WebView(
                    url: selectedLink,
                    loadingProcess: $loadingProgress,
                    isLoading: $isLoading,
                    action: $action,
                    canGoBack: $canGoBack,
                    canGoForward: $canGoForward,
                    title: $title,
                    backList: $backList,
                    forwardList: $forwardList,
                    shouldUpdateWebView: $shouldUpdateWebView
                )
                if isLoading {
                    ProgressView(value: loadingProgress, total: 1.0)
                        .progressViewStyle(.linear)
                        .accentColor(.green)
                }
            }
            .toolbar {
                ToolbarItemGroup(placement: .bottomBar) {
                    goBackButton()
                    goForwardButton()
                    reloadButton
                    Spacer()
                    closeButton
                }
            }
            .navigationBarTitle(title, displayMode: .inline)
        }
    }
}

private extension WebBaseView {
    // TODO: 押下時にボタンの色は薄くしたい場合は、Buttonを使用する方法を考えるか、押下中であることを検知して色を変更する処理が必要
    func goBackButton() -> some View {
        let oneTapGesture = TapGesture().onEnded({ _ in
            action = .goBack
        })
        let longTapGesture = LongPressGesture(minimumDuration: 1.0).onEnded({ _ in
            isShownBackList = true
        })

        return Image(systemName: "chevron.backward")
        .foregroundColor(canGoBack ? .black : .gray)
        .frame(width: 30, height: 30)
        .gesture(SimultaneousGesture(oneTapGesture, longTapGesture))
        .disabled(!canGoBack)
        .fullScreenCover(isPresented: $isShownBackList) {
            VisitHistoryView(
                isShownVisitHistory: $isShownBackList,
                visitedList: backList,
                selectedLink: $selectedLink,
                shouldUpdateWebView: $shouldUpdateWebView
            )
        }
    }

    func goForwardButton() -> some View {
        let oneTapGesture = TapGesture().onEnded({ _ in
            action = .goForward
        })
        let longTapGesture = LongPressGesture(minimumDuration: 1.0).onEnded({ _ in
            isShownForwardList = true
        })

        return Image(systemName: "chevron.forward")
        .foregroundColor(canGoForward ? .black : .gray)
        .frame(width: 30, height: 30)
        .gesture(SimultaneousGesture(oneTapGesture, longTapGesture))
        .disabled(!canGoForward)
        .fullScreenCover(isPresented: $isShownForwardList) {
            VisitHistoryView(
                isShownVisitHistory: $isShownForwardList,
                visitedList: forwardList,
                selectedLink: $selectedLink,
                shouldUpdateWebView: $shouldUpdateWebView
            )
        }
    }

    var reloadButton: some View {
        Button(action: {
            action = .reload
        }) {
            Image(systemName: "arrow.clockwise")
        }
        .foregroundColor(.black)
        .frame(width: 30, height: 30)
    }

    var closeButton: some View {
        Button(action: {
            isShownWebView.toggle()
        }) {
            Image(systemName: "xmark")
        }
        .foregroundColor(.black)
        .frame(width: 30, height: 30)
    }
}

今回ここの実装で苦労したことは、2つあります。

  1. ToolBarの背景色を変えること -> できなかったので今回は諦めた
  2. 1つのボタンに対して、ワンタップとロングタップができるようにすること -> SimultaneousGestureを使用して実現できた!と思う・・・

まずToolBarについてです。

前回と違い、ツールバーは、iOS15から登場したtoolBarモディファイアを使用しています。
ただ、このtoolBar、私の力ではどう頑張っても背景色を変えることができなかった・・・

NavigationBarもそうなのですが、SwiftUIでは、ToolBarの背景色を変えるための機能が標準でサポートされておらず、一筋縄ではいきません。

現時点の私の観測範囲では、NavigationBarの背景色を変える方法は、
ZStackを使ってColorを載せるという荒技しか見つからなかったです。
(試行錯誤の結果はこちら↓)

ToolBarも同じ方法でやろうとしたのですが、なんかわからんですができませんでした・・・
(どうやったか気になる人は、GitHubに載せたToolBarModifier.swiftの実装を見てください。そして良い方法があれば教えてください・・・ :pray:

次にワンタップとロングタップの検知です。
「 < 」と「 > 」のボタンに対して、
ワンタップした時は、前または後のページに遷移し、
ロングタップ(今回は1.0秒)した時は、前または後の履歴の履歴の一覧画面をモーダルで表示
します。

最初はこんな感じで、ワンタップとロングタップを検知しようと思っていました。

Button(action: {
  // 前または後のページに戻る処理
}) {
  Image(systemName: "chevron.backward")
}
.onLongPressGesture{
  // 履歴一覧の表示処理
}

ですけど、これだとロングタップの時の処理が呼ばれませんでした・・・
[SwiftUI] Longpress もできる Button の作り方」にもあるとおり、ButtononLongPressGestureモディファイアの共存がうまくいかないようです。

次に記事にもあるように、こうしてみました。

Button(action: {
  // 前または後のページに戻る処理
}) {
  Image(systemName: "chevron.backward")
}
.simultaneousGesture(LongPressGesture().onEnded{ _ in
  // 履歴一覧の表示処理
})

結論これもうまくいかず、今度はワンタップとロングタップいずれの終了時にもactionが実行されるようになってしまいました。

これでは例えば「 < 」をロングタップした時に、閲覧履歴を見たいだけなのに、勝手に前のページへの遷移までされてしまいます。

そんな感じで試行錯誤を繰り返し、最終的に、私のコードはこうなりました。
(記事に書いてあった結論とは違う。やり方違ったのかバージョン問題かうまくいかなかったから・・・)

let oneTapGesture = TapGesture().onEnded({ _ in
   // 前または後のページに戻る処理
})
let longTapGesture = LongPressGesture(minimumDuration: 1.0).onEnded({ _ in
  // 履歴一覧の表示処理
})

return Image(systemName: "chevron.backward")
  .gesture(SimultaneousGesture(oneTapGesture, longTapGesture))

ご覧の通り、問題の一因になっていたButtonとgestureモディファイアの併用をやめました・・・
そして、SimultaneousGestureを使って、ワンタップとロングタップの処理を一緒に評価してもらことにしました。

ちなみにSimultaneousGestureのOverViewによると、同時に評価できるのは、2つだけのようです。3つも4つもはだめみたい。

A simultaneous gesture is a container-event handler that evaluates its two child gestures at the same time. Its value is a struct with two optional values, each representing the phases of one of the two gestures.

Buttongestureモディファイアの明確な違いは何なのかというのが疑問だと思うのですが、
、"押下中のボタンの色が変わるかどうか"が、1つの違いだと思います。

Buttonを使っていれば、押下中は勝手に「 < 」の色が薄くなります。
でも、gestureを使っている場合は、押下中だろうが色は微塵も変わりません。

Buttongestureの共存の仕方がわかれば、多分この課題はなくなると思いますが、
今回は本題ではないという理由で一旦諦めた・・・

本題は履歴表示です!(言い訳)


最後に、アプリを起動して一番最初に表示されるViewの実装です。

ContentView.swift
import SwiftUI

struct ContentView: View {
    @State private var isShownWebView = false

    var body: some View {
        Button(action: {
            isShownWebView.toggle()
        }) {
            Text("WebView開く")
                .padding()
        }
        .fullScreenCover(isPresented: $isShownWebView) {
            WebBaseView(isShownWebView: $isShownWebView)
        }
    }
}

ボタンが1つ配置されているだけのシンプルなものです。

以上になります!

コード全体は、以下にあげています。

今回は私の知識不足とSwiftUIの不安定さが災いして
これでいいのか問題がけっこう残りました・・・

まあ課題を把握しているだけマシかもしれません。

いつも通りコメント歓迎です。 :blush:

参考

6
4
0

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
6
4