Codementor Events

Working with web content offline in SwiftUI apps

Published Apr 29, 2021Last updated Oct 25, 2021
Working with web content offline in SwiftUI apps

I continue developing an app for saving and reading articles. In my previous post, I covered interesting cases of using sheets in SwiftUI. Now I want to describe my journey with offline mode.

There are situations when users are unable to download web content: bad connection, airplane mode, etc. WKWebView has useful APIs for saving its content in different formats. Let's check it out!

Note: Examples are written in Swift 5.4 and tested on iOS 14.5 with Xcode 12.5 (12E262).

Preparation

In the app user can save URL content without explicit previewing. To deal with it, we use a simple WKWebView wrapper called WebDataManager:

import WebKit

final class WebDataManager: NSObject {
    
    private lazy var webView: WKWebView = {
        let webView = WKWebView()
        webView.navigationDelegate = self
        return webView
    }()
    
    private var completionHandler: ((Result<Data, Error>) -> Void)?
    
    func createData(url: URL, completionHandler: @escaping (Result<Data, Error>) -> Void) {
        self.completionHandler = completionHandler
        webView.load(.init(url: url))
    }
}

We create a web view without frame, because we don't want to show it. Also, WebDataManager has a convenience function with completion to handle web content. For all formats it will return Data that we can store locally.

To work with web navigation, we must conform WKNavigationDelegate and implement didFinish and didFail functions:

extension WebDataManager: WKNavigationDelegate {
    
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        // save loaded content
    }
    
    func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
        completionHandler?(.failure(error))
    }
}

These functions will be called on navigation changes. Now we are ready to save the content, and our first station will be takeSnapshot.

Snapshots

Since iOS 11.0 WKWebView has takeSnapshot function. It has an optional WKSnapshotConfiguration to specify a capture behavior. In the final, it returns a generated image.

// declared in WebDataManager
enum DataError: Error {
    case noImageData
}

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    let config = WKSnapshotConfiguration()
    webView.takeSnapshot(with: config) { [weak self] image, error in
        if let error = error {
            self?.completionHandler?(.failure(error))
            return
        }
        guard let pngData = image?.pngData() else {
            self?.completionHandler?(.failure(DataError.noImageData))
            return
        }
        self?.completionHandler?(.success(pngData))
    }
}

Remember zero frame of the web view? Because of it, we have an unknown error here:

Error Domain=WKErrorDomain Code=1 "An unknown error occurred" UserInfo={NSLocalizedDescription=An unknown error occurred}

To fix it, we can get a contentSize and set it to config's rect:

config.rect = .init(origin: .zero, size: webView.scrollView.contentSize)

Now we have the image data, can save it locally and show in the app:

import SwiftUI

struct SnapshotContentView: View {
    
    let url: URL
    
    var body: some View {
        if let image = UIImage(contentsOfFile: url.path) {
            ScrollView {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
            }
        }
        else {
            Text("Fail to load image")
        }
    }
}

Let's highlight a few cons of this approach:

  • Images have fixed sizes and may look bad on different screens;
  • We can't copy text from contents and open URLs.

Luckily, the next approach has no these issues.

PDF

With iOS 14.0 WKWebView got a new createPDF function. It already returns a Result object that we can just pass to our completionHandler:

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    webView.createPDF { [weak self] result in
        self?.completionHandler?(result)
    }
}

For a record, the function also takes WKPDFConfiguration object with the only option β€” a rect to capture a portion of the web view.

SwiftUI has no views for PDF content. But with helping of UIViewRepresentable we can use PDFView from PDFKit framework:

import SwiftUI
import PDFKit

struct PDFContentView: UIViewRepresentable {
    
    let url: URL
    
    func makeUIView(context: Context) -> PDFView {
        let view = PDFView()
        view.autoScales = true
        view.document = PDFDocument(url: url)
        return view
    }
    
    func updateUIView(_ pdfView: PDFView, context: Context) {
    }
}

Now we can copy text and even open URLs in Safari, but web content is still static. For instance, animations are gone, drop-down lists will be collapsed forever.

Web archive

The last approach in this article is web archives. A web archive is a file that archives inside it all the content of one web page. And since iOS 14.0 we can work with it easily:

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    webView.createWebArchiveData { [weak self] result in
        self?.completionHandler?(result)
    }
}

Of course, we have another wrapper, but for WKWebView:

import SwiftUI
import WebKit

struct WebArchiveContentView: UIViewRepresentable {
    
    let url: URL
    
    func makeUIView(context: Context) -> WKWebView {
        WKWebView()
    }
    
    func updateUIView(_ webView: WKWebView, context: Context) {
        webView.loadFileURL(url, allowingReadAccessTo: url)
    }
}

With the magic of web archives web page logic is working as well. With an internet connection, the web view will navigate to tapped content links.

Conclusion

Finally, for the app I choose web archives, but all approaches are helpful based on app purposes. The last thing I want to add is a size of rendered contents. For my blog's main page the sizes are:

  • .png β€” 1,8 Mb;
  • .pdf β€” 2,3 Mb;
  • .webarchive β€” 5,3 Mb.

I don't mind it (yet πŸ˜…), but I'm planning to add features for clearing saved data and download archives for selected articles.

If you want to play with examples and test your URLs, an example project with all three saving options is available here.

6d79e1cb-54ae-4b8f-967d-915f80496846.png

Twitter Β· Telegram Β· Github

Discover and read more posts from Artem Novichkov
get started
post comments2Replies
fincher david
4 years ago

I’m not sure if you just want to cache the pages that have already been visited or if you have specific requests that you’d like to cache. I’m currently working on the latter. So I’ll speak to that. My urls are dynamically generated from an api request. From this response I set requestPaths with the non-image urls and then make a request for each of the urls and cache the response. For the image urls, I used the Kingfisher library to cache the images. I’ve already set up my shared cache urlCache = URLCache.shared in my AppDelegate. And allotted the memory I need: urlCache = URLCache(memoryCapacity: <setForYourNeeds>, diskCapacity: <setForYourNeeds>, diskPath: β€œurlCache”) Then just call startRequest(:_) for each of the urls in requestPaths. (Can be done in the background if it’s not needed right away)
https://cinemahdapk.info/

Artem Novichkov
4 years ago

In my case, I save web archives for articles to read it later. I guess your solution fits well for image caching.