SkipWeb provides two ways to display web content in Skip Lite apps:
-
WebView — A fully customizable embedded browser for app-integrated web content. Supports JavaScript execution, script messages, navigation control, scroll delegates, snapshots, popups, persistent tab engines, cookies/storage, profiles, and content blockers. Backed by
WKWebViewon iOS andandroid.webkit.WebViewon Android. -
WebBrowser — A lightweight View modifier that opens a URL in the platform's native in-app browser (
SFSafariViewControlleron iOS, Chrome Custom Tabs on Android). Ideal for external links where you want a polished browsing experience with minimal code.
openWebBrowser |
WebView |
|
|---|---|---|
| Best for | Links to external content: docs, terms of service, blog posts, OAuth flows | App-integrated web content where you need programmatic control |
| Browser chrome | Provided by the OS (address bar, back/forward, share) | You build your own toolbar and controls |
| JavaScript access | None — the page runs in a sandboxed browser | Full evaluateJavaScript support |
| Navigation control | None — the user navigates freely within the browser | Programmatic back/forward, reload, URL changes |
| Cookie/session sharing | Shares the user's browser cookies and autofill | Uses the app's WebView cookie store (shared across WebViews by default; not the same store as Safari/Chrome) |
| Customization | Custom share-sheet actions | Full layout control, scroll delegates, snapshot API |
Use openWebBrowser when you want to send the user to a web page with minimal code and maximum platform-native UX. Use WebView when you need to embed web content as part of your app's UI with programmatic control.
The package currently targets Apple platforms starting at iOS 17, macOS 14, tvOS 17, watchOS 10, and Mac Catalyst 17.
Current package dependencies are:
skipfrom1.8.9skip-uifrom1.54.0- when
SKIP_BRIDGEis enabled,skip-bridgein0.0.0..<2.0.0andskip-fuse-uifrom1.15.2
SkipWeb provides an embedded WebView for Skip Lite transpiled Swift. On iOS it uses a WKWebView and on Android it uses an android.webkit.WebView.
A simple example of using an embedded WebView with a static URL can be seen:
import Foundation
import SwiftUI
import SkipWeb
struct EmbeddedWebView : View {
let url: URL
var body: some View {
WebView(url: url)
}
}The WebView is backed by a
WebEngine.
It can be configured with a WebEngineConfiguration instance. For example:
import Foundation
import SwiftUI
import SkipWeb
struct ConfigurableWebView : View {
@State private var configuration = WebEngineConfiguration()
@State private var navigator = WebViewNavigator(initialURL: URL(string: "https://skip.dev")!)
@State private var state = WebViewState()
var body: some View {
WebView(configuration: configuration, navigator: navigator, state: $state)
}
}The main configuration knobs are:
let config = WebEngineConfiguration(
javaScriptEnabled: true,
javaScriptCanOpenWindowsAutomatically: false,
allowsBackForwardNavigationGestures: true,
allowsPullToRefresh: true,
allowsInlineMediaPlayback: true,
dataDetectorsEnabled: true,
isScrollEnabled: true,
pageZoom: 1.0,
isOpaque: true,
customUserAgent: nil,
profile: .default,
userScripts: [],
scriptMessageHandlerNames: [],
scriptMessageDelegate: nil,
schemeHandlers: [:],
uiDelegate: nil,
navigationDelegate: nil,
contentBlockers: nil,
capturesConsoleOutput: true,
contentBlockerRuntime: nil
)messageHandlers is still accepted for source compatibility, but it is deprecated. Prefer scriptMessageHandlerNames plus scriptMessageDelegate for new code.
WebViewState.url is the preferred typed URL state. pageURL is still present for source compatibility, but is deprecated:
if let currentURL = state.url {
print(currentURL.absoluteString)
}Profile selection is configured on WebEngineConfiguration:
let config = WebEngineConfiguration(
profile: .named("account-a")
)Use .ephemeral when the web view should avoid the normal persistent website data store:
let config = WebEngineConfiguration(
profile: .ephemeral
)On iOS this uses WKWebsiteDataStore.nonPersistent(). On Android this requires AndroidX WebKit
WebViewFeature.MULTI_PROFILE; SkipWeb creates a generated named profile for the engine so
cookies and storage do not share the default WebView store.
On Android, .named(...) identifiers beginning with skipweb-internal- are reserved for
SkipWeb-owned profiles and fail with WebProfileError.invalidProfileName.
Use .ephemeralSession(id) when multiple web views should share one explicitly owned,
nonpersistent login or browsing session:
let sessionID = UUID().uuidString
let config = WebEngineConfiguration(
profile: .ephemeralSession(sessionID)
)Engines with the same session ID share cookies and website data. Release all engines using the session, then clear its platform storage when the session ends. The ID must not be empty.
_ = try await WebEngine.clearEphemeralSessionProfile(identifier: sessionID)On iOS, clearing releases SkipWeb's reference to the shared nonpersistent data store; any engine
that is still alive continues using its old store. On Android, clearing deletes browsing data in
the isolated profile. Android requires WebViewFeature.MULTI_PROFILE.
WebViewNavigator can keep a warm WebEngine and reuse it across view recreation.
When the same navigator is rebound to an engine that already has content/history, initialURL/initialHTML are not reloaded.
This lets apps preserve page state when navigating away and back with the same navigator instance.
If you are building a tabbed browser, persistentWebViewID is the mechanism you need to manage multiple live tabs.
Think of it as a stable parking spot for one browser engine. Each tab gets its own ID, and SkipWeb
reuses the same cached WebEngine whenever a WebView is mounted again with that ID.
WebView(
configuration: configuration,
navigator: navigator,
url: tab.url,
state: $state,
persistentWebViewID: tab.id.uuidString
)Use a stable per-tab identifier when engine identity should be tied to a tab ID instead of only
to a WebViewNavigator instance. A stable navigator can still preserve one warm engine across
view recreation; persistentWebViewID is for managing multiple live engines by explicit tab ID.
IDs share one process-wide namespace, so make them unique per logical tab. Reusing an ID returns
the first cached engine with its original configuration until that ID is removed.
Important: passing a non-nil persistentWebViewID opts into explicit lifetime management.
SkipWeb stores the engine in a process-wide cache that holds a strong reference. Removing the
WebView from the UI does not release that engine. SkipWeb does not automatically limit the cache,
evict old entries, or purge entries during memory pressure. Every distinct retained ID can keep a
native web view, page state, history, and related browser resources in memory.
The host browser must decide how many tab engines stay warm, when engines are created lazily, and when to purge them. Call removal after the corresponding view has been unmounted; on Android, removal destroys the cached native web view.
When a tab closes or a background tab should no longer keep its engine alive, remove it explicitly:
WebView.removePersistentWebView(id: tab.id.uuidString)For bulk purges, such as memory pressure or session changes, use:
WebView.removePersistentWebViews(ids: tabIDs.map(\.uuidString))Removal drops SkipWeb's cache reference and ensures the next mount for that ID creates a new
engine. It cannot force immediate deallocation if a WebViewNavigator, mounted view, or other
application object still holds the engine. Release those references as part of eviction; on
Android, they must not be used after removal because the cached native web view is destroyed.
Content blockers are also configured on WebEngineConfiguration:
let config = WebEngineConfiguration(
contentBlockers: WebContentBlockerConfiguration(
iOSRuleListPaths: [
Bundle.main.path(forResource: "content-blockers", ofType: "json")!
],
whitelistedDomains: [
"example.com",
"*.example.org"
],
popupWhitelistedSourceDomains: [
"example.com"
],
androidMode: .custom(MyAndroidContentBlockingProvider())
)
)
_ = await config.iOSPrepareContentBlockers()For multiple independently created web views, create one WebContentBlockerRuntime and pass it to
each configuration. The runtime prepares one rule snapshot, is inherited by popup children, and
keeps only weak references to attached engines:
let runtime = WebContentBlockerRuntime(
configuration: WebContentBlockerConfiguration(
iOSRuleListPaths: ruleListPaths,
androidMode: .custom(MyAndroidContentBlockingProvider())
)
)
_ = await runtime.prepare()
let config = WebEngineConfiguration(
contentBlockerRuntime: runtime
)When the rules change, replace the complete runtime configuration once. Every live engine using
that runtime is updated; pass reloadLiveWebViews: true when current pages must reload:
let errors = await runtime.reapply(
configuration: updatedBlockers,
reloadLiveWebViews: true
)The older contentBlockers property remains the simpler single-configuration API. After changing
it on an existing engine—including setting it to nil—call engine.reapplyContentBlockers().
On Android, AndroidCosmeticRule can now carry current-frame guards directly:
AndroidCosmeticRule(
hiddenSelectors: ["#subframe-ad"],
urlFilterPattern: ".*\\/subframe\\.html",
ifDomainList: ["127.0.0.1"],
frameScope: .allFrames,
preferredTiming: .documentStart
)Think of it as "return selectors, render CSS once at the edge". On supported Android runtimes,
SkipWeb installs one fixed document-start hook, checks each frame's URL and host in the shared
runtime, and returns only matching display: none !important CSS. Older runtimes use lifecycle
injection as a fallback.
For Android navigation callbacks, prefer WebEngineConfiguration.navigationDelegate.
WebEngine.engineDelegate remains available as a deprecated compatibility escape hatch, but SkipWeb now keeps blocker enforcement on an internal engine-owned WebViewClient.
SkipWebNavigationDelegate runs on the main actor. Its
webEngineDidStartProvisionalNavigation(_:) callback exposes the first concrete URL of a
main-frame navigation. On Apple platforms, the configuration delegate is active while an engine
is detached, which lets it classify a popup engine that has been created but not mounted yet.
After an Apple engine is mounted in WebView, use the view's state and initializer callbacks.
For example, you can use shouldOverrideURLLoading to hand mailto: links or app deep links to native code before the WebView navigates:
final class AppNavigationDelegate: SkipWebNavigationDelegate {
func webEngine(_ engine: WebEngine, shouldOverrideURLLoading url: URL) -> Bool {
if url.scheme == "mailto" {
openSystemMailComposer(url)
return true
}
if url.scheme == "myapp" {
routeIntoNativeScreen(url)
return true
}
return false
}
}
let config = WebEngineConfiguration()
config.navigationDelegate = AppNavigationDelegate()For view-local interception, the WebView initializer closure now also reports whether the
request targets the main frame:
WebView(
shouldOverrideUrlLoading: { url, isMainFrame in
guard isMainFrame, url.scheme == "myapp" else { return false }
routeIntoNativeScreen(url)
return true
}
)On Android, content-blocking providers also receive HTTP(S) main-frame navigation requests. If a
provider blocks one, WebView.onContentRuleBlockedNavigation reports the rejected destination so
the host can explain why the visible page did not change. See
SkipWebContentBlockers.md for the request fields and an
integration example.
Navigation APIs:
load(url:)is fire-and-forget and logs load failures.loadOrThrow(url:)is async/throwing and should be used when callers need explicit error handling (includingWebProfileErrorpreflight failures).
WebView can detect navigations that the platform runtime wants to treat as downloads.
This is a detect-only API: SkipWeb does not save files, show a destination picker, hand off
to the system browser, or provide download progress UI.
Attach onDownloadRequested when creating the WebView:
import Foundation
import SwiftUI
import SkipWeb
struct DownloadAwareWebView: View {
@State private var navigator = WebViewNavigator(initialURL: URL(string: "https://example.com")!)
@State private var state = WebViewState()
@State private var downloadMessage: String?
var body: some View {
ZStack(alignment: .top) {
WebView(
navigator: navigator,
state: $state,
onDownloadRequested: { request in
let name = request.suggestedFilename ?? request.url?.lastPathComponent ?? "file"
downloadMessage = "Downloads are not supported yet: \(name)"
}
)
if let downloadMessage {
Text(downloadMessage)
.padding(12)
.background(Color.black.opacity(0.85))
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.top, 24)
}
}
}
}The callback receives a WebDownloadRequest:
public struct WebDownloadRequest {
public let url: URL?
public let suggestedFilename: String?
public let mimeType: String?
public let contentDisposition: String?
public let contentLength: Int64? // nil means unknown
}On iOS, SkipWeb detects downloads from WKNavigationAction.shouldPerformDownload,
Content-Disposition: attachment, MIME types WebKit cannot display, and
application/octet-stream responses that do not look like playable media.
On Android, SkipWeb emits the callback from android.webkit.DownloadListener.
When a download is detected, SkipWeb cancels the page navigation and clears the loading/provisional state. The host app should decide what to show to the user and whether to restore any address-bar text or other browser chrome state.
JavaScript can be executed against the browser with:
let json: String? = try await navigator.evaluateJavaScript(javaScriptInput)The JSON string that is returned may be an object, or may be fragmentary (that is, a top-level string or number, null, or array), so care should be taken when attempting to deserialize it.
Note: since the browser's JavaScript engines are quite different (V8 and Blink on Android versus JavaScriptCore and WebKit on iOS), results from script execution are expected to vary somewhat depending on the different quirks of the implementations.
Use WebViewUserScript for scripts that should run as pages load:
let config = WebEngineConfiguration(
userScripts: [
WebViewUserScript(
source: "window.appReady = true",
injectionTime: .atDocumentStart,
forMainFrameOnly: true
)
]
)On iOS this maps to WKUserScript. On Android, SkipWeb uses AndroidX document-start scripts when WebViewFeature.DOCUMENT_START_SCRIPT is available. If that feature is unavailable, document-start scripts are injected from onPageStarted and document-end scripts are injected from onPageFinished, so timing is best-effort.
For JavaScript-to-native messages, register handler names and provide a WebViewScriptMessageDelegate:
@MainActor
final class ScriptSink: WebViewScriptMessageDelegate {
func webEngine(_ webEngine: WebEngine, didReceiveScriptMessage message: WebViewScriptMessage) {
print("handler=\(message.name), body=\(message.bodyJSON)")
}
}
let scriptSink = ScriptSink()
let config = WebEngineConfiguration(
scriptMessageHandlerNames: ["native"],
scriptMessageDelegate: scriptSink
)JavaScript can then post through the familiar WebKit shape on both platforms:
window.webkit.messageHandlers.native.postMessage({
kind: "ready",
href: window.location.href
})Think of WebViewScriptMessage as the portable envelope around the message:
nameis the registered handler name.bodyJSONis the canonical payload. SkipWeb JSON-encodes posted values before crossing the Swift/Kotlin bridge.sourceURLis the sending frame URL when available.isMainFrametells you whether the message came from the top frame when available.
A full example of a browser that can evaluate JavaScript and display the results in a sheet can be implemented with the following View:
import SwiftUI
import SkipWeb
/// This component uses the `SkipWeb` module from https://source.skip.dev/skip-web
struct WebViewPlayground: View {
@State var config = WebEngineConfiguration()
@State var navigator = WebViewNavigator()
@State var state = WebViewState()
@State var showScriptSheet = false
@State var javaScriptInput = "document.body.innerText"
@State var javaScriptOutput = ""
var body: some View {
VStack {
WebView(configuration: config, navigator: navigator, url: URL(string: "https://skip.dev")!, state: $state)
}
.toolbar {
Button {
navigator.goBack()
} label: {
Image(systemName: "arrow.left")
}
.disabled(!state.canGoBack)
.accessibilityLabel(Text("Back"))
Button {
navigator.reload()
} label: {
Image(systemName: "arrow.clockwise.circle")
}
.accessibilityLabel(Text("Reload"))
Button {
navigator.goForward()
} label: {
Image(systemName: "arrow.forward")
}
.disabled(!state.canGoForward)
.accessibilityLabel(Text("Forward"))
Button {
self.showScriptSheet = true
} label: {
Image(systemName: "ellipsis")
}
.accessibilityLabel(Text("Evaluate JavaScript"))
}
.navigationTitle(state.pageTitle ?? "WebView")
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showScriptSheet) {
NavigationStack {
VStack {
TextField("JavaScript", text: $javaScriptInput)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.keyboardType(.asciiCapable) // also disables smart quotes
.textInputAutocapitalization(.never)
.onSubmit(of: .text) { evaluateJavaScript() }
.padding()
Text("Output")
.font(.headline)
TextEditor(text: $javaScriptOutput)
.font(Font.body.monospaced())
.border(Color.secondary)
.padding()
}
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Evaluate Script") {
evaluateJavaScript()
}
.disabled(javaScriptInput.isEmpty)
}
ToolbarItem(placement: .cancellationAction) {
Button("Close", role: .cancel) {
showScriptSheet = false
}
}
}
}
}
}
/// Evaluate the script specified in the sheet
func evaluateJavaScript() {
let navigator = self.navigator
Task {
var scriptResult: String = ""
do {
if let resultJSON = try await navigator.evaluateJavaScript(javaScriptInput) {
// top-level fragments are nicer to display as strings, so we try to deserialize them
if let topLevelString = try? JSONSerialization.jsonObject(with: resultJSON.data(using: .utf8)!, options: .fragmentsAllowed) as? String {
scriptResult = topLevelString
} else {
scriptResult = resultJSON
}
}
} catch {
scriptResult = error.localizedDescription
}
Task { @MainActor in
self.javaScriptOutput = scriptResult
}
}
}
}
SkipWeb exposes popup/window creation through SkipWebUIDelegate on WebEngineConfiguration.
This delegate API lets host apps decide whether a popup should open and which child WebEngine should back it.
For full API details and usage examples, see SkipWebUIDelegate.md.
The createWebViewWith callback returns a WebEngine?:
- Return
nilto deny child-window creation. - Return a child
WebEngineto allow child-window creation. - Without
WebEngineConfiguration.uiDelegate,_blank/window.open(...)falls back to platform defaults: on iOS the popup request is denied, while on Android the currentWebViewmay navigate instead of opening a child window.
JavaScript popup behavior can be configured with:
WebEngineConfiguration.javaScriptCanOpenWindowsAutomatically(maps to both iOS and Android platform settings).
Callbacks are platform-agnostic, but invocation source differs:
| Callback | iOS (WebKit) | Android |
|---|---|---|
webView(_:createWebViewWith:platformContext:) |
Called from WKUIDelegate.createWebViewWith |
Called from WebChromeClient.onCreateWindow |
webViewDidClose(_:child:) |
Called from WKUIDelegate.webViewDidClose |
Called from WebChromeClient.onCloseWindow |
WebWindowRequest.targetURL may be nil on Android during onCreateWindow.
PlatformCreateWindowContext aliases WebKitCreateWindowParams on iOS and AndroidCreateWindowParams on Android.
When handling iOS popups through WKUIDelegate.createWebViewWith, WebKit requires that the returned child WKWebView be initialized with the exact WKWebViewConfiguration provided by WebKit for that callback.
If this contract is violated, WebKit can raise NSInternalInconsistencyException with:
Returned WKWebView was not created with the given configuration.
SkipWeb validates this contract at popup creation time:
- A warning is logged when verification cannot be performed.
- An error is logged when a contract violation is detected.
On both platforms, prefer returning a child created with platformContext.makeChildWebEngine(...).
By default this mirrors the parent WebEngineConfiguration, including its profile and resolved
content-blocker runtime. On iOS it also preserves WebKit's required configuration identity and
mirrors inspectability. Pass an explicit configuration only when you intentionally want the child to diverge.
This default mirroring is configuration-level. Platform delegate assignments on the returned child (WKUIDelegate, WKNavigationDelegate) are not automatically copied from the parent, so assign them explicitly if your app depends on that behavior.
Popup children sharing the parent's runtime reuse prepared rules and receive later runtime reapplications.
On Android, once a child is returned from the delegate, SkipWeb also mirrors key parent web settings and verifies parent WebProfile inheritance; if profile inheritance fails, popup creation is denied.
SkipWeb exposes a WebView-attached scroll delegate API that follows UIScrollViewDelegate naming where practical while remaining portable across iOS and Android.
Attach a delegate through WebView(scrollDelegate:):
import SwiftUI
import SkipWeb
final class ScrollProbe: SkipWebScrollDelegate {
func scrollViewDidEndDragging(_ scrollView: WebScrollViewProxy, willDecelerate decelerate: Bool) {
print("ended drag at y=\(scrollView.contentOffset.y), decelerate=\(decelerate)")
}
func scrollViewDidEndDecelerating(_ scrollView: WebScrollViewProxy) {
print("scroll settled at y=\(scrollView.contentOffset.y)")
}
}
struct ScrollHostView: View {
private let scrollDelegate = ScrollProbe()
@State private var navigator = WebViewNavigator()
var body: some View {
WebView(
navigator: navigator,
url: URL(string: "https://example.com")!,
scrollDelegate: scrollDelegate
)
}
}Supported callbacks:
scrollViewDidScroll(_:)scrollViewWillBeginDragging(_:)scrollViewDidEndDragging(_:willDecelerate:)scrollViewWillBeginDecelerating(_:)scrollViewDidEndDecelerating(_:)
WebScrollViewProxy exposes portable geometry through WebScrollPoint and WebScrollSize:
contentOffsetcontentSizevisibleSizedragEndContentVelocity(content-offset units per second, with positive values toward increasing offsets)isTrackingisDraggingisDeceleratingisScrollEnabled
| Callback | iOS | Android |
|---|---|---|
scrollViewDidScroll(_:) |
Native UIScrollViewDelegate.scrollViewDidScroll |
WebView.setOnScrollChangeListener |
scrollViewWillBeginDragging(_:) |
Native UIScrollViewDelegate.scrollViewWillBeginDragging |
Inferred from touch-slop crossing, or synthesized from first scroll delta while touch is active if ACTION_MOVE is missed |
scrollViewDidEndDragging(_:willDecelerate:) |
Native UIScrollViewDelegate.scrollViewDidEndDragging |
Inferred from touch end; willDecelerate is computed from fling velocity vs Android minimum fling velocity |
scrollViewWillBeginDecelerating(_:) |
Native UIScrollViewDelegate.scrollViewWillBeginDecelerating |
Emitted when fling velocity crosses the deceleration threshold |
scrollViewDidEndDecelerating(_:) |
Native UIScrollViewDelegate.scrollViewDidEndDecelerating |
Emitted after a short idle period (~120 ms) with no new scroll deltas during momentum |
Android deceleration is heuristic-based because android.webkit.WebView does not expose a direct didEndDecelerating callback.
SkipWebScrollDelegateintentionally exposes a focused subset: noscrollViewDidScrollToTop(_:), zoom callbacks, or scrolling-animation callbacks in the public API.- On iOS, callback timing comes directly from
UIScrollViewDelegate; on Android, drag/deceleration lifecycle callbacks are synthesized from touch and scroll signals. - On Android,
ACTION_CANCELis finalized with a short grace period so nested gesture interception does not prematurely end a drag. - On Android, a new touch during momentum immediately ends the synthetic deceleration phase before starting a new drag sequence.
scrollViewDidScroll(_:)is offset-change-driven (including programmatic scroll changes), while drag/deceleration lifecycle callbacks are user-gesture-driven.
SkipWeb provides WebEngine.takeSnapshot(configuration:) and WebViewNavigator.takeSnapshot(configuration:)
using SkipWebSnapshotConfiguration, which mirrors the core WKSnapshotConfiguration fields:
rect(.nullcaptures the full visible web view bounds)snapshotWidth(output width while preserving aspect ratio)afterScreenUpdatesandroidCaptureMode(.webViewContentby default, or.visibleWindowPixels)imageFormat(.jpeg(quality: 0.85)by default, or.png)
let snapshot = try await navigator.takeSnapshot(
configuration: SkipWebSnapshotConfiguration(
rect: .null,
snapshotWidth: 240,
afterScreenUpdates: true,
androidCaptureMode: .webViewContent,
imageFormat: .jpeg(quality: 0.85)
)
)
let imageData = snapshot.imageData
let mimeType = snapshot.imageFormat.mimeTypeOn Android, capture timing and capture source are independent:
| Mode | Captures |
|---|---|
.webViewContent |
The WebView's rendered content, using WebView.draw(Canvas). The capture compensates for the current scroll offset and excludes other window content, including overlays and blur effects. Some video, surface, or other compositor-backed content may not appear. |
.visibleWindowPixels |
The final pixels currently composited inside the WebView's window rectangle, using PixelCopy. This includes WebView content and anything drawn over it, including overlays and blur effects. |
afterScreenUpdates waits for the next UI tick before either capture mode. If the wait cannot be scheduled, takeSnapshot throws WebSnapshotError.afterScreenUpdatesUnavailable.
SkipWeb does not switch between these modes automatically. .visibleWindowPixels requires the WebView to be attached to a valid window. If PixelCopy is unavailable or fails, takeSnapshot throws WebSnapshotError.visibleWindowPixelsCaptureFailed.
When the user long-presses a link or image-link in a WebView, SkipWeb can show a fully customizable native menu populated from an array of WebContextMenuAction items. The same closure drives both platforms — on iOS the items become UIActions inside WKWebView's native preview menu, and on Android they become android.widget.PopupMenu items shown at the long-pressed location.
Configure the menu by setting WebEngineConfiguration.linkContextMenuActions to a closure that receives the long-pressed URL and returns the actions to display:
let config = WebEngineConfiguration()
config.linkContextMenuActions = { url in
return [
WebContextMenuAction(title: NSLocalizedString("Open", comment: "")) { url in
navigator.load(url: url)
},
WebContextMenuAction(title: NSLocalizedString("Open in New Tab", comment: "")) { url in
openInNewTab(url)
},
WebContextMenuAction(title: NSLocalizedString("Copy Link", comment: "")) { url in
UIPasteboard.general.url = url
},
WebContextMenuAction(title: NSLocalizedString("Share…", comment: "")) { url in
// Present a UIActivityViewController on iOS / send an ACTION_SEND intent on Android
},
]
}WebContextMenuAction carries only a title (String) and a handler ((URL) -> Void). Per-item icons aren't included because Android's PopupMenu API doesn't render them, so the title alone must convey the action.
Returning an empty array (or leaving linkContextMenuActions as nil) falls back to each platform's default link long-press behaviour — WKWebView's preview menu on iOS, the text-selection action mode on Android.
The closure is called every time the user long-presses, so dynamic actions (e.g. "Add Bookmark" / "Remove Bookmark" depending on store state) are straightforward — just branch on the URL when assembling the array.
For cases where you want to display a web page without the full power and complexity of an embedded WebView, SkipWeb provides the View.openWebBrowser() modifier. This opens a URL in the platform's native in-app browser:
- iOS: SFSafariViewController — a full-featured Safari experience presented within your app, complete with the address bar, share sheet, and reader mode.
- Android: Chrome Custom Tabs — a Chrome-powered browsing experience that shares cookies, autofill, and saved passwords with the user's browser.
Open a URL in the platform's native in-app browser:
import SwiftUI
import SkipWeb
struct MyView: View {
@State var showPage = false
var body: some View {
Button("Open Documentation") {
showPage = true
}
.openWebBrowser(
isPresented: $showPage,
url: "https://skip.dev/docs",
mode: .embeddedBrowser(params: nil)
)
}
}To open the URL in the user's default browser app instead of an in-app browser:
Button("Open in Safari / Chrome") {
showPage = true
}
.openWebBrowser(
isPresented: $showPage,
url: "https://skip.dev",
mode: .launchBrowser
)By default the embedded browser slides up vertically as a modal sheet. Set presentationMode to .navigation for a horizontal slide transition that feels like a navigation push:
Button("Open with Navigation Style") {
showPage = true
}
.openWebBrowser(
isPresented: $showPage,
url: "https://skip.dev",
mode: .embeddedBrowser(params: EmbeddedParams(
presentationMode: .navigation
))
)| Mode | iOS | Android |
|---|---|---|
.sheet (default) |
Full-screen cover (slides up vertically) | Partial Custom Tabs bottom sheet (resizable, initially half-screen height). Falls back to full-screen if the browser does not support partial tabs. |
.navigation |
Navigation push (slides in horizontally) | Standard full-screen Chrome Custom Tabs launch |
Limitations:
- iOS: The
.navigationpresentation mode requires the calling view to be inside aNavigationStack(orNavigationView). If the view is not hosted in a navigation container, the modifier will have no effect. - Android: In
.sheetmode, if the user's browser does not support the Partial Custom Tabs API, the tab launches full-screen as a fallback.
Add custom actions that appear in the share sheet (iOS) or as menu items (Android):
Button("Open with Actions") {
showPage = true
}
.openWebBrowser(
isPresented: $showPage,
url: "https://skip.dev",
mode: .embeddedBrowser(params: EmbeddedParams(
customActions: [
WebBrowserAction(label: "Copy Link") { url in
// handle the action with the current page URL
},
WebBrowserAction(label: "Bookmark") { url in
// save the URL
}
]
))
)On iOS, custom actions appear as UIActivity items in the Safari share sheet. On Android, they appear as menu items in Chrome Custom Tabs (maximum 5 items).
/// Controls how the embedded browser is presented.
public enum WebBrowserPresentationMode {
/// Present as a vertically-sliding modal sheet (default).
case sheet
/// Present as a horizontally-sliding navigation push.
case navigation
}
/// The mode for opening a web page.
public enum WebBrowserMode {
/// Open the URL in the system's default browser application.
case launchBrowser
/// Open the URL in an embedded browser within the app.
case embeddedBrowser(params: EmbeddedParams?)
}
/// Configuration for the embedded browser.
public struct EmbeddedParams {
public var presentationMode: WebBrowserPresentationMode
public var customActions: [WebBrowserAction]
}
/// A custom action available on a web page.
public struct WebBrowserAction {
public let label: String
public let handler: (URL) -> Void
}
/// View modifier to open a web page.
extension View {
public func openWebBrowser(
isPresented: Binding<Bool>,
url: String,
mode: WebBrowserMode
) -> some View
}SkipWeb exposes portable browser-data APIs through WebEngine and WebViewNavigator:
cookies(for:)cookieHeader(for:)setCookie(_:requestURL:)applySetCookieHeaders(_:for:)clearCookies()removeData(ofTypes:modifiedSince:)
Supporting types:
WebCookie(name,value, optionaldomain/path/expires, plusisSecure/isHTTPOnly)WebProfile(.default,.named(String),.ephemeral)WebSiteDataType(cookies,diskCache,memoryCache,offlineWebApplicationCache,localStorage,sessionStorage,webSQLDatabases,indexedDBDatabases)
Example:
let url = URL(string: "https://example.com/path")!
try await navigator.setCookie(
WebCookie(name: "session", value: "abc123"),
requestURL: url
)
let header = await navigator.cookieHeader(for: url)
try await navigator.applySetCookieHeaders(
["pref=1; Path=/; HttpOnly"],
for: url
)
await navigator.clearCookies()
try await navigator.removeData(
ofTypes: Set([.diskCache, .memoryCache, .localStorage]),
modifiedSince: .distantPast
)Platform behavior:
- Profile mapping:
WebProfile |
iOS data store | Android data store |
|---|---|---|
.default |
WKWebsiteDataStore.default() |
Default process-wide store |
.named("id") |
WKWebsiteDataStore(forIdentifier: "id") |
AndroidX WebKit named profile (requires WebViewFeature.MULTI_PROFILE) |
.ephemeral |
WKWebsiteDataStore.nonPersistent() |
Generated AndroidX WebKit named profile (requires WebViewFeature.MULTI_PROFILE) |
.ephemeralSession("id") |
Shared WKWebsiteDataStore.nonPersistent() for that session ID |
Shared AndroidX WebKit named profile for that session ID (requires WebViewFeature.MULTI_PROFILE) |
- iOS cookie scope follows the
WKWebsiteDataStoreattached to theWKWebView. - Android
.defaultusesandroid.webkit.CookieManagersingleton. - Android
.named("id")requiresWebViewFeature.MULTI_PROFILE; otherwise profile setup fails withWebProfileError.unsupportedOnAndroid(no fallback to default). - Android
.named("id")rejects the reservedskipweb-internal-prefix withWebProfileError.invalidProfileName. - Android
.ephemeralalso requiresWebViewFeature.MULTI_PROFILE; otherwise profile setup fails withWebProfileError.unsupportedOnAndroidinstead of falling back to the default store. - Each Android
.ephemeralengine receives a generated named profile. Popup child engines inherit the parent's resolved generated profile so opener and child share the same isolated store. - Android
.ephemeralSession("id")shares one generated named profile across engines using the same ID. Release those engines and callWebEngine.clearEphemeralSessionProfile(identifier:)to delete its browsing data. - On Android, always check profile support at runtime before using
.named("id"),.ephemeral, or.ephemeralSession("id"). You can useWebEngine.isAndroidMultiProfileSupported()(or the underlyingWebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)check directly). cookies(for:)returns URL-matching cookies; on Android this is best-effort becauseCookieManagerreads as a cookie-header string (limited metadata).setCookie(_:requestURL:)requires eithercookie.domainor arequestURLhost; otherwise it throwsWebCookieError.missingCookieDomain.removeData(ofTypes:modifiedSince:)maps to iOSWKWebsiteDataStore.removeData.- On Android,
removeDatarequiresmodifiedSince == .distantPastwhenofTypesis non-empty; otherwise it throwsWebDataRemovalError.unsupportedModifiedSinceOnAndroid. - Android data removal is bucket-level (cookies/cache/storage), not timestamp-granular, and may clear a broader bucket than an individual requested data type.
SkipWeb exposes portable content-blocking hooks through WebEngineConfiguration.contentBlockers.
For a fuller guide with a quick integration example, whitelist behavior, and up-to-date blocker API shapes, see SkipWebContentBlockers.md.
Quick summary:
iOSRuleListPathspoints to WebKit content-blocker JSON files that are compiled intoWKContentRuleListvalues and attached by SkipWeb.whitelistedDomainsaccepts WebKit-style entries such asexample.comand*.example.com, normalizes them, and disables blocking for matching page domains across both platforms.popupWhitelistedSourceDomainsis the popup-only override. A bare entry likeexample.commeans "this site", so it covers bothexample.comand common subdomains such aswww.example.com;*.example.comremains subdomains-only.androidMode: .custom(...)is the Android content-blocking entry point.WebEngineConfiguration.iOSClearContentBlockerCache()explicitly removes the persisted iOS compiled rule-list cache so the next install recompiles from source.iOSPrepareContentBlockers()lets apps prewarm iOS blocker setup without importingWebKit.- Popup children and caller-supplied
WKWebViewinstances inherit the configured blocker setup.
Many delegates that are provided by WKWebView are not yet implemented in this project,
and so deeper customization may require custom implementation work.
To implement these, you may need to fork the repository and add it to your workspace,
as described in the Contributing guide.
Please consider creating a Pull Request
with features and fixes that you create, as this benefits the entire Skip community.
This project is a Swift Package Manager module that uses the Skip plugin to build the package for both iOS and Android. The package manifest is the source of truth for dependency versions; see Requirements for the current values.
The module can be tested using the standard swift test command
or by running the test target for the macOS destination in Xcode,
which will run the Swift tests as well as the transpiled
Kotlin JUnit tests in the Robolectric Android simulation environment.
Parity testing can be performed with skip test,
which will output a table of the test results for both platforms.
WebEngine.isAttachedToWindow distinguishes a mounted platform view from an
allocated engine. After document readiness, await engine.waitForPendingVisualUpdates()
waits for Android's visual-state callback before revealing an enclosing view.
Keep the WebView attached and natively visible during this wait; an enclosing
view may have zero opacity. The method is a no-op on Apple platforms, preserving
their navigation-completion behavior. Callers own timeouts and must revalidate
their navigation or runtime identity after the wait.
We welcome contributions to this package in the form of enhancements and bug fixes.
The general flow for contributing to this and any other Skip package is:
- Fork this repository and enable actions from the "Actions" tab
- Check out your fork locally
- When developing alongside a Skip app, add the package to a shared workspace to see your changes incorporated in the app
- Push your changes to your fork and ensure the CI checks all pass in the Actions tab
- Add your name to the Skip Contributor Agreement
- Open a Pull Request from your fork with a description of your changes
This software is licensed under the Mozilla Public License 2.0.