Wprowadzenie — dlaczego to taka rewolucja
Osadzanie treści webowych w aplikacjach SwiftUI było przez lata jednym z najbardziej frustrujących doświadczeń dla iOS developerów. Serio. Za każdym razem, gdy chciałeś wyświetlić stronę internetową, formularz HTML czy dokumentację online, musiałeś sięgać po UIKit i opakowywać WKWebView w UIViewRepresentable. Kilkadziesiąt linii boilerplate'u, żonglowanie delegatami i koordynatorami, a na koniec — walka z imperatywnym API w deklaratywnym świecie SwiftUI.
No i w końcu Apple powiedział: dosyć.
W iOS 26 SwiftUI dostał natywny komponent WebView oraz towarzyszący mu model WebPage — oba zaprojektowane od zera pod deklaratywny paradygmat i framework Observation. Koniec z koordynatorami, koniec z UIViewRepresentable. Dosłownie jedna linia kodu wystarczy, żeby osadzić pełnoprawną przeglądarkę opartą na WebKit bezpośrednio w widoku SwiftUI. Brzmi zbyt dobrze? Zobaczmy.
W tym artykule przejdziemy przez wszystko — od najprostszego użycia, przez śledzenie postępu ładowania, wykonywanie JavaScript, kontrolę nawigacji, aż po budowę mini-przeglądarki. Będzie konkretnie, z kodem i praktycznymi przykładami, które możesz od razu wrzucić do swojego projektu.
WebView(url:) — najprostsze podejście
Zacznijmy od podstaw. Jeśli potrzebujesz po prostu wyświetlić stronę internetową i nic więcej — to naprawdę wystarczy jedna linia. Zaimportuj WebKit i gotowe:
import SwiftUI
import WebKit
struct ContentView: View {
var body: some View {
WebView(url: URL(string: "https://www.apple.com"))
}
}
I to wszystko. Żadnych wrapperów, żadnych koordynatorów, żadnych delegatów. WebView(url:) przyjmuje opcjonalny URL i renderuje pełną stronę z użyciem tego samego silnika WebKit, który napędza Safari. Szczerze mówiąc, na to czekaliśmy od lat.
W praktyce ten wariant sprawdza się idealnie do:
- Wyświetlenia strony z regulaminem lub polityką prywatności
- Osadzenia statycznej dokumentacji
- Szybkiego podglądu linku w aplikacji
Ale co, jeśli potrzebujesz czegoś więcej — na przykład śledzenia tytułu strony, postępu ładowania czy nawigacji? Tutaj wkracza WebPage.
WebPage — Observable model do zaawansowanego zarządzania
WebPage to klasa zgodna z protokołem Observable, która daje pełną kontrolę nad cyklem życia strony internetowej. Możesz ją traktować jak view model dla swojego WebView — przechowuje stan strony i automatycznie powiadamia SwiftUI o zmianach. Zero ręcznego wiązania, zero Combine.
import SwiftUI
import WebKit
struct ContentView: View {
@State private var page = WebPage()
var body: some View {
WebView(page)
.ignoresSafeArea()
.onAppear {
if let url = URL(string: "https://www.apple.com") {
page.load(URLRequest(url: url))
}
}
}
}
Zauważ jedną ważną różnicę: WebView(url:) przyjmuje opcjonalny URL, ale page.load() wymaga nieopakowanego URL osadzonego w URLRequest. To celowa decyzja projektowa — WebPage daje większą kontrolę, więc wymaga bardziej jawnego podejścia.
Kluczowe właściwości WebPage
Ponieważ WebPage jest Observable, wszystkie poniższe właściwości automatycznie aktualizują powiązane widoki SwiftUI:
| Właściwość | Typ | Opis |
|---|---|---|
title | String? | Tytuł aktualnie załadowanej strony |
url | URL? | Aktualny adres URL |
estimatedProgress | Double | Postęp ładowania (0.0–1.0) |
isLoading | Bool | Czy strona jest w trakcie ładowania |
canGoBack | Bool | Czy jest dostępna nawigacja wstecz |
Śledzenie postępu ładowania i właściwości strony
Jedna z najczęstszych potrzeb — wyświetlanie informacji o stronie: tytułu, aktualnego URL i postępu ładowania. Z WebPage to banalnie proste:
import SwiftUI
import WebKit
struct ProgressWebView: View {
@State private var page = WebPage()
var body: some View {
VStack(spacing: 0) {
// Pasek postępu ładowania
if page.isLoading {
ProgressView(value: page.estimatedProgress)
.tint(.blue)
}
WebView(page)
.ignoresSafeArea(.container, edges: .bottom)
}
.navigationTitle(page.title ?? "Ładowanie...")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
if let url = URL(string: "https://www.swift.org") {
page.load(URLRequest(url: url))
}
}
}
}
Pomyśl, ile kodu potrzebowałbyś wcześniej, żeby osiągnąć ten sam efekt z WKWebView i KVO. Tutaj framework Observation robi za Ciebie całą robotę. Gdy estimatedProgress się zmienia, ProgressView automatycznie się aktualizuje. Gdy strona się załaduje, isLoading przełącza się na false i pasek postępu znika.
Zero ręcznych subskrypcji, zero KVO. Po prostu działa.
Ładowanie własnego HTML
Nie zawsze chcesz ładować zewnętrzną stronę. Czasem potrzebujesz wyświetlić lokalny HTML — wygenerowany raport, podgląd e-maila, osadzony film z YouTube. Nic prostszego:
struct LocalHtmlView: View {
@State private var page = WebPage()
private let htmlContent = """
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, system-ui;
padding: 20px;
background: #f5f5f7;
color: #1d1d1f;
}
h1 { font-size: 28px; font-weight: 700; }
.card {
background: white;
border-radius: 12px;
padding: 20px;
margin-top: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<h1>Raport dzienny</h1>
<div class="card">
<p>Statystyki z dnia: <strong>1 marca 2026</strong></p>
<ul>
<li>Aktywni użytkownicy: 12 540</li>
<li>Nowe rejestracje: 384</li>
<li>Współczynnik retencji: 73%</li>
</ul>
</div>
</body>
</html>
"""
var body: some View {
WebView(page)
.onAppear {
page.load(html: htmlContent, baseURL: URL(string: "about:blank")!)
}
}
}
Metoda page.load(html:baseURL:) przyjmuje surowy string HTML i bazowy URL do rozwiązywania ścieżek względnych (obrazki, arkusze stylów itp.). Jeśli Twój HTML jest w pełni samodzielny, śmiało użyj about:blank.
Ładowanie danych z typem MIME
WebPage obsługuje też ładowanie danych binarnych z określonym typem MIME — przydatne np. do wyświetlania archiwów web:
page.load(
data: webArchiveData,
mimeType: "application/x-webarchive",
characterEncodingName: "utf-8",
baseURL: baseURL
)
Wykonywanie JavaScript
No to teraz robi się ciekawie. Natywny WebView w SwiftUI daje pełny dostęp do wykonywania JavaScript na załadowanej stronie. Metoda callJavaScript(_:) jest asynchroniczna i może rzucać błędy — idealnie wpisuje się w model async/await Swifta:
struct JavaScriptDemoView: View {
@State private var page = WebPage()
@State private var pageTitle = ""
var body: some View {
VStack {
WebView(page)
.ignoresSafeArea(.container, edges: .bottom)
Text("Tytuł z JS: \(pageTitle)")
.padding()
Button("Pobierz tytuł przez JavaScript") {
Task {
do {
let result = try await page.callJavaScript("document.title")
if let title = result as? String {
pageTitle = title
}
} catch {
print("Błąd JS: \(error)")
}
}
}
.padding()
}
.onAppear {
if let url = URL(string: "https://www.swift.org") {
page.load(URLRequest(url: url))
}
}
}
}
JavaScript z parametrami
Dla lepszej wydajności i bezpieczeństwa, zamiast interpolować dane bezpośrednio w stringu JavaScript, możesz (a wręcz powinieneś) przekazywać argumenty przez dedykowany parametr arguments:
// Zamiast niebezpiecznej interpolacji:
// try await page.callJavaScript("document.getElementById('\(userId)')")
// Użyj bezpiecznych argumentów:
let result = try await page.callJavaScript(
"document.getElementById(userId).textContent",
arguments: ["userId": "profile-name"]
) as? String
To podejście chroni przed atakami typu injection. Dane są przekazywane jako parametry, a nie wstrzykiwane do kodu JavaScript — drobna zmiana, a robi ogromną różnicę w bezpieczeństwie.
Konfiguracja WebPage
WebPage.Configuration pozwala dostosować zachowanie przeglądarki przed jej zainicjalizowaniem. Ważna uwaga: konfiguracja jest ustawiana raz, przy tworzeniu instancji WebPage, i nie powinna być zmieniana później.
@State private var page: WebPage = {
var config = WebPage.Configuration()
// Niestandardowy user agent
config.applicationNameForUserAgent = "MojaAplikacja/2.0"
// Wykrywanie numerów telefonów i adresów
config.dataDetectorTypes = [.phoneNumber, .address]
// Preferencje nawigacji
var navPreferences = WebPage.NavigationPreferences()
navPreferences.allowsContentJavaScript = true
navPreferences.preferredContentMode = .mobile
config.defaultNavigationPreferences = navPreferences
return WebPage(configuration: config)
}()
Główne opcje konfiguracji to:
applicationNameForUserAgent— tekst dopisywany do domyślnego user agenta WebKitdataDetectorTypes— automatyczne wykrywanie numerów telefonów, adresów, linkówallowsAirPlayForMediaPlayback— czy strona może odtwarzać media przez AirPlaydefaultNavigationPreferences— domyślne preferencje dla ładowania treści
Kontrola nawigacji — NavigationDeciding
W wielu aplikacjach chcesz kontrolować, dokąd użytkownik może przejść. Może chcesz ograniczyć nawigację do własnej domeny, a linki zewnętrzne otwierać w Safari? Dokładnie do tego służy protokół WebPage.NavigationDeciding:
import WebKit
final class DomainNavigationDecider: WebPage.NavigationDeciding {
var onExternalLink: ((URL) -> Void)?
func decidePolicy(
for action: WebPage.NavigationAction,
preferences: inout WebPage.NavigationPreferences
) async -> WKNavigationActionPolicy {
guard let url = action.request.url else { return .cancel }
// Pozwól na nawigację tylko w obrębie własnej domeny
if url.host() == "mojaaplikacja.pl" || url.host() == "api.mojaaplikacja.pl" {
return .allow
}
// Linki zewnętrzne — przekaż do obsługi
onExternalLink?(url)
return .cancel
}
}
A potem podłączasz decidera przy tworzeniu WebPage:
struct RestrictedWebView: View {
@Environment(\.openURL) private var openURL
@State private var page: WebPage?
var body: some View {
Group {
if let page {
WebView(page)
.ignoresSafeArea()
} else {
ProgressView()
}
}
.onAppear {
let decider = DomainNavigationDecider()
decider.onExternalLink = { url in
openURL(url) // Otwórz w domyślnej przeglądarce
}
let webPage = WebPage(navigationDecider: decider)
webPage.load(URLRequest(url: URL(string: "https://mojaaplikacja.pl")!))
page = webPage
}
}
}
Decider dostaje obiekt WebPage.NavigationAction zawierający żądanie (URLRequest) oraz informacje o tym, co wywołało nawigację — kliknięcie linku, przekierowanie itp. Zwracasz .allow lub .cancel. Proste i czytelne.
Obsługa własnych schematów URL
To jedna z bardziej zaawansowanych (i szczerze mówiąc, fajnych) funkcji WebPage. Możesz zarejestrować własne schematy URL, np. myapp://, i obsługiwać je za pomocą Swiftowego kodu. Idealne rozwiązanie dla aplikacji hybrydowych, gdzie HTML komunikuje się z natywną warstwą:
struct AppSchemeHandler: URLSchemeHandler {
func reply(to request: URLRequest) -> AsyncSequence<URLSchemeTaskResult> {
return AsyncStream { continuation in
let response = URLResponse(
url: request.url!,
mimeType: "text/html",
expectedContentLength: -1,
textEncodingName: "utf-8"
)
continuation.yield(.response(response))
let html = "<h1>Treść z natywnej aplikacji</h1>"
if let data = html.data(using: .utf8) {
continuation.yield(.data(data))
}
continuation.finish()
}
}
}
// Rejestracja schematu w konfiguracji
let config = WebPage.Configuration()
config.urlSchemeHandlers[URLScheme("mojaapp")!] = AppSchemeHandler()
let page = WebPage(configuration: config)
Teraz każdy link mojaapp://cokolwiek na stronie zostanie przechwycony i obsłużony przez Twój Swiftowy kod. Zwróć uwagę, że URLSchemeHandler wykorzystuje AsyncSequence — jest w pełni asynchroniczny, bez delegatów. Tak powinno to zawsze wyglądać.
Przewijanie i Find-in-Page
iOS 26 przynosi też modyfikatory do kontroli przewijania i wbudowanego wyszukiwania na stronie. Przejdźmy szybko przez oba.
Kontrola pozycji przewijania
@State private var scrollPosition = WebViewScrollPosition()
WebView(page)
.webViewScrollPosition(scrollPosition)
.onChange(of: selectedSection) { _, newSection in
scrollPosition.scrollTo(position: newSection.offset)
}
Wyszukiwanie na stronie (Find-in-Page)
@State private var showingFind = false
WebView(page)
.findNavigator(isPresented: $showingFind)
.toolbar {
Button("Szukaj") {
showingFind.toggle()
}
}
Modyfikator .findNavigator(isPresented:) wyświetla natywny interfejs wyszukiwania tekstu na stronie — dokładnie taki sam, jaki znasz z Safari. Jedna linijka i masz gotowy find-in-page.
Budujemy mini-przeglądarkę — kompletny przykład
Dobra, pora połączyć wszystko w jeden spójny przykład. Zbudujmy funkcjonalną mini-przeglądarkę z paskiem nawigacji, postępem ładowania i obsługą historii:
import SwiftUI
import WebKit
struct MiniBrowserView: View {
@State private var page = WebPage()
@State private var urlText = "https://www.swift.org"
@State private var showingFind = false
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Pasek adresu
HStack {
TextField("Wpisz adres URL", text: $urlText)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.onSubmit { loadURL() }
Button("Idź") { loadURL() }
.buttonStyle(.bordered)
}
.padding(.horizontal)
.padding(.vertical, 8)
// Pasek postępu
if page.isLoading {
ProgressView(value: page.estimatedProgress)
.tint(.blue)
}
// Widok web
WebView(page)
.findNavigator(isPresented: $showingFind)
.ignoresSafeArea(.container, edges: .bottom)
}
.navigationTitle(page.title ?? "Przeglądarka")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
Button {
Task { try? await page.callJavaScript("history.back()") }
} label: {
Image(systemName: "chevron.backward")
}
.disabled(!page.canGoBack)
Button {
Task { try? await page.callJavaScript("history.forward()") }
} label: {
Image(systemName: "chevron.forward")
}
Spacer()
Button {
showingFind.toggle()
} label: {
Image(systemName: "doc.text.magnifyingglass")
}
Button {
page.reload()
} label: {
Image(systemName: "arrow.clockwise")
}
if page.isLoading {
Button {
page.stopLoading()
} label: {
Image(systemName: "xmark")
}
}
}
}
.onAppear { loadURL() }
.onChange(of: page.url) { _, newURL in
if let newURL {
urlText = newURL.absoluteString
}
}
}
}
private func loadURL() {
guard let url = URL(string: urlText) else { return }
page.load(URLRequest(url: url))
}
}
Nie wiem jak Ty, ale ja jestem pod wrażeniem, ile rzeczy daje się osiągnąć tak małą ilością kodu. Dzięki Observable pasek adresu automatycznie synchronizuje się z aktualnym URL, tytuł nawigacji aktualizuje się sam, a pasek postępu pojawia i znika w zależności od stanu ładowania. Bez jednej linii dodatkowego "klejenia".
Kompatybilność wsteczna — obsługa starszych wersji iOS
Nowe API WebView wymaga iOS 26 jako minimalnej wersji docelowej. Jeśli Twoja aplikacja musi obsługiwać starsze systemy (a pewnie jeszcze przez jakiś czas musi), potrzebujesz warunkowego fallbacku:
struct CrossVersionWebView: View {
let url: URL
var body: some View {
if #available(iOS 26, *) {
WebView(url: url)
} else {
LegacyWebView(url: url)
}
}
}
// Fallback dla iOS < 26
struct LegacyWebView: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView {
WKWebView()
}
func updateUIView(_ uiView: WKWebView, context: Context) {
uiView.load(URLRequest(url: url))
}
}
Warto wydzielić tę logikę do osobnego komponentu. Reszta aplikacji nie powinna wiedzieć o różnicach między wersjami — to szczegół implementacyjny.
Zdarzenia nawigacji — śledzenie cyklu życia strony
WebPage udostępnia strumień zdarzeń nawigacyjnych poprzez currentNavigationEvent, który obserwujesz jako AsyncSequence:
.task {
for await event in page.currentNavigationEvent.values {
switch event {
case .startedProvisionalNavigation:
showLoadingIndicator = true
case .committed:
updateBreadcrumbs()
case .finished:
showLoadingIndicator = false
extractTableOfContents()
case .failed(let error):
handleNavigationError(error)
default:
break
}
}
}
To naprawdę potężne narzędzie. Możesz reagować na każdy etap ładowania strony: początek, zatwierdzenie, zakończenie, błąd. Idealnie nadaje się do budowania niestandardowych wskaźników ładowania, logowania analityki czy automatycznej ekstrakcji treści po załadowaniu strony.
Dodatkowe modyfikatory widoku
iOS 26 oferuje jeszcze kilka przydatnych modyfikatorów dla WebView, o których warto wiedzieć:
WebView(page)
// Wyłącz podglądy linków przy długim naciśnięciu
.webViewLinkPreviews(.disabled)
// Wyłącz gesty swipe do nawigacji wstecz/dalej
.webViewBackForwardNavigationGestures(.disabled)
// visionOS: włącz przewijanie wzrokiem
.webViewScrollInputBehavior(.enabled, for: .look)
Najlepsze praktyki i pułapki
Na podstawie dotychczasowych doświadczeń (moich i innych developerów) z nowym API, oto kilka wskazówek, które mogą Ci zaoszczędzić trochę czasu.
Rób
- Konfiguruj WebPage przy inicjalizacji — ustawienia takie jak handlery schematów URL i preferencje nawigacji powinny być ustawione przed pierwszym użyciem
- Używaj
argumentswcallJavaScript— zamiast interpolacji stringów, przekazuj dane jako parametry dla bezpieczeństwa - Ładuj tylko zaufane URL-e HTTPS — pamiętaj o polityce App Transport Security
- Testuj na prawdziwym urządzeniu — wydajność WebView na symulatorze potrafi mocno się różnić od rzeczywistej
Nie rób
- Nie zmieniaj konfiguracji po inicjalizacji —
WebPage.Configurationtraktuj jako niemutowalną po utworzeniu instancji - Nie polegaj wyłącznie na
callJavaScript("history.back()")— zawsze sprawdzajcanGoBackprzed próbą cofnięcia - Nie ignoruj
.ignoresSafeArea()— bez tego modyfikatora WebView może nie wypełniać całego dostępnego obszaru, szczególnie na urządzeniach z Dynamic Island
FAQ — najczęściej zadawane pytania
Czy natywny WebView w SwiftUI zastępuje WKWebView?
Nie do końca. Nowy WebView jest zbudowany na silniku WebKit (tym samym co WKWebView), ale dostarcza deklaratywne API zaprojektowane specjalnie dla SwiftUI. Jeśli potrzebujesz bardzo zaawansowanej kontroli — np. niestandardowych user scripts, głębokiej integracji z cookies, pełnej konfiguracji WKWebViewConfiguration — WKWebView przez UIViewRepresentable nadal może być lepszym wyborem. Ale dla większości typowych zastosowań natywny WebView jest zdecydowanie prostszą i preferowaną opcją.
Jak obsłużyć nawigację wstecz w WebView?
Natywny WebView nie udostępnia bezpośredniej metody goBack(). Najczęstsze obejście to wywołanie try await page.callJavaScript("history.back()"). Możesz też skorzystać z page.backForwardList, żeby uzyskać dostęp do historii nawigacji i załadować konkretne elementy historii za pomocą page.load(item).
Czy mogę używać WebView na macOS i visionOS?
Tak! Natywny WebView jest dostępny na iOS 26, macOS 26 i visionOS 26. Na visionOS możesz dodatkowo włączyć przewijanie wzrokiem za pomocą modyfikatora .webViewScrollInputBehavior(.enabled, for: .look).
Jak wyświetlić WebView dla użytkowników na starszej wersji iOS?
Użyj sprawdzenia dostępności if #available(iOS 26, *) i zapewnij fallback z UIViewRepresentable opakowującym WKWebView. Dzięki temu Twoja aplikacja będzie działać na starszych wersjach systemu, a użytkownicy z iOS 26 skorzystają z nowego, natywnego API.
Czy WebView obsługuje dark mode i Liquid Glass?
WebView automatycznie respektuje ustawienia trybu ciemnego systemu i przekazuje informację o preferowanym schemacie kolorów do załadowanej strony (przez CSS media query prefers-color-scheme). Jeśli chodzi o Liquid Glass — dotyczy on elementów nawigacyjnych i toolbarów otaczających WebView, a nie samej treści strony. Zgodnie z założeniem, że Liquid Glass jest materiałem dla warstwy nawigacji, nie dla treści.