Create a custom URL scheme handler for SwiftUI WebViews.
Custom URL schemes
Apple introduced both WebView and WebPage SwiftUI views in iOS 26. These provide similar WebKit functionality to the UIKit WKWebView APIs. This includes being able to register custom URL schemes to load local resources.
Suppose I have an App that loads quotations from the local App bundle. My URL scheme uses the format:
quotes://001.html
There are a few steps to support a custom URL scheme:
- Create a custom URL scheme handler that knows how to load the local resources.
- Register the scheme handler in a WebPage configuration.
- Have the WebView load and display the web page.
Let’s look at each of those steps.
URLSchemeHandler (iOS 26)
The URLSchemeHandler is a protocol with a reply method to return an async sequence of results. The AsyncSequence expects us to either yield a URLResponse and some data or throw an error.
struct QuoteSchemeHandler: URLSchemeHandler {
static let scheme = "quotes"
func reply(for request: URLRequest) ->
some AsyncSequence<URLSchemeTaskResult, any Error> {
continuation in
...
}
}
The reply method provides us with the URLRequest. I start by extracting the URL and checking it matches our quotes scheme:
guard let url = request.url else {
continuation.finish(throwing: QuoteError.missingURL)
return
}
guard url.scheme == QuoteSchemeHandler.scheme else {
continuation.finish(throwing: QuoteError.invalidScheme)
return
}
To construct the URL response I need the mime type and length of the data. I first extract the filename from the URL, determine the mime type from the file extension, and then load the data:
do {
let file = try filename(for: url)
let mimeType = try mimeType(for: file)
let data = try Data(contentsOf: file)
I can then build the URLResponse:
let response = URLResponse(
url: url,
mimeType: mimeType,
expectedContentLength: data.count,
textEncodingName: "utf-8"
)
If nothing has gone wrong I then add the response and the data to the AsyncSequence and finish the stream:
continuation.yield(.response(response))
continuation.yield(.data(data))
continuation.finish()
If any of those steps went wrong I finish the stream with the error:
} catch {
continuation.finish(throwing: error)
}
I extract the filename from the hostname section of the URL and then locate the URL of the html file in the main App bundle:
private func filename(for url: URL) throws -> URL {
guard let resource = url.host(percentEncoded: false) else {
throw QuoteError.missingHost
}
guard let file = Bundle.main.url(
forResource: resource,
withExtension: nil
) else {
throw QuoteError.fileNotFound
}
return file
}
I lookup the mime type using the file extension:
private func mimeType(for file: URL) throws -> String {
guard let mimeType = UTType(filenameExtension: file.pathExtension)?
.preferredMIMEType
else {
throw QuoteError.unknownFileType
}
return mimeType
}
This returns “text\html” for .html files.
Registering the Scheme Handler
To register the scheme handler we create a page configuration and add our scheme handler. I’m doing that in an Observable object responsible for loading the quotes:
@Observable final class QuoteStore {
var page: WebPage
init() {
guard let scheme = URLScheme(QuoteSchemeHandler.scheme) else {
fatalError("Invalid URLScheme")
}
let handler = QuoteSchemeHandler()
var configuration = WebPage.Configuration()
configuration.urlSchemeHandlers[scheme] = handler
self.page = WebPage(configuration: configuration)
}
}
Then to load a URL:
func loadQuote(_ url: URL) async {
do {
let events = page.load(url)
for try await event in events { ...
}
} catch {
self.error = error
}
}
The WebPage load method returns an async sequence you can use to track progress if needed.
Loading the Page
Finally, to load and display the page:
extension QuoteStore {
static let quote1 = URL(string: "quotes://001.html")!
...
}
struct ContentView: View {
@State private var store = QuoteStore()
var body: some View {
WebView(store.page)
.task {
await store.loadQuote(QuoteStore.quote1)
}
}
}