WebView
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, navigation control, scroll delegates, snapshots, and popups. 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.
When to use openWebBrowser vs WebView
Section titled “When to use openWebBrowser vs WebView”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 | Isolated web engine per WebView instance |
| 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.
WebView: Customizable Embedded Web Browser
Section titled “WebView: Customizable Embedded Web Browser”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 Foundationimport SwiftUIimport SkipWeb
struct EmbeddedWebView : View { let url: URL
var body: some View { WebView(url: url) }}Customization
Section titled “Customization”The WebView ↗ is backed by a
WebEngine ↗.
It can be configured with a WebEngineConfiguration ↗ instance. For example:
import Foundationimport SwiftUIimport SkipWeb
struct ConfigurableWebView : View { let navigator: WebViewNavigator = WebViewNavigator(initialURL: URL("https://skip.dev")!) @ObservedObject var configuration: WebEngineConfiguration @Binding var state: WebViewState
var body: some View { WebView(configuration: configuration, navigator: navigator, state: $state) }}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.
JavaScript
Section titled “JavaScript”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 JavaScript Core and WebKit on iOS), resuts from script execution are expected to vary somewhat depending on the different quirks of the implementations.
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 SwiftUIimport SkipWeb
/// This component uses the `SkipWeb` module from https://source.skip.dev/skip-webstruct 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 } } }}Window Creation & Popups
Section titled “Window Creation & Popups”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.
JavaScript popup behavior can be configured with:
WebEngineConfiguration.javaScriptCanOpenWindowsAutomatically(maps to both iOS and Android platform settings).
Platform callback semantics
Section titled “Platform callback semantics”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.
iOS WebKit popup contract
Section titled “iOS WebKit popup contract”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.
For iOS parity, return a child created with platformContext.makeChildWebEngine(...).
By default this mirrors the parent WebEngineConfiguration and inspectability on the popup child. 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.
Scroll Delegate
Section titled “Scroll Delegate”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 SwiftUIimport 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:
contentOffsetcontentSizevisibleSizeisTrackingisDraggingisDeceleratingisScrollEnabled
Platform callback semantics
Section titled “Platform callback semantics”| 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.
Important differences from UIScrollViewDelegate
Section titled “Important differences from UIScrollViewDelegate”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.
Snapshots
Section titled “Snapshots”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)afterScreenUpdates
let snapshot = try await navigator.takeSnapshot( configuration: SkipWebSnapshotConfiguration( rect: .null, snapshotWidth: 240, afterScreenUpdates: true ))
let png = snapshot.pngDataOn Android, afterScreenUpdates is best-effort: SkipWeb captures on the next UI tick before drawing the WebView into a bitmap.
If that UI-tick wait cannot be scheduled (for example, when the view is detached), takeSnapshot throws WebSnapshotError.afterScreenUpdatesUnavailable.
WebBrowser: Lightweight In-App Browser
Section titled “WebBrowser: Lightweight In-App Browser”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.
Basic Usage
Section titled “Basic Usage”Open a URL in the platform’s native in-app browser:
import SwiftUIimport 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) ) }}Launch in System Browser
Section titled “Launch in System Browser”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)Presentation Mode
Section titled “Presentation Mode”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.
Custom Actions
Section titled “Custom Actions”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).
API Reference
Section titled “API Reference”/// 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}Contribution
Section titled “Contribution”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.
Building
Section titled “Building”This project is a free Swift Package Manager module that uses the Skip plugin to transpile Swift into Kotlin.
Building the module requires that Skip be installed using
Homebrew ↗ with brew install skiptools/skip/skip.
This will also install the necessary build prerequisites:
Kotlin, Gradle, and the Android build tools.
Testing
Section titled “Testing”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.
Contributing
Section titled “Contributing”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