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.
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.
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/
In my case, I save web archives for articles to read it later. I guess your solution fits well for image caching.