WebView i WebPage w SwiftUI — natywne osadzanie treści web w iOS 26

Poznaj natywny WebView i WebPage w SwiftUI dla iOS 26 — od prostego osadzania stron, przez JavaScript i kontrolę nawigacji, aż po budowę mini-przeglądarki. Praktyczny przewodnik z gotowym kodem.

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śćTypOpis
titleString?Tytuł aktualnie załadowanej strony
urlURL?Aktualny adres URL
estimatedProgressDoublePostęp ładowania (0.0–1.0)
isLoadingBoolCzy strona jest w trakcie ładowania
canGoBackBoolCzy 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 WebKit
  • dataDetectorTypes — automatyczne wykrywanie numerów telefonów, adresów, linków
  • allowsAirPlayForMediaPlayback — czy strona może odtwarzać media przez AirPlay
  • defaultNavigationPreferences — 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 arguments w callJavaScript — 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 inicjalizacjiWebPage.Configuration traktuj jako niemutowalną po utworzeniu instancji
  • Nie polegaj wyłącznie na callJavaScript("history.back()") — zawsze sprawdzaj canGoBack przed 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 WKWebViewConfigurationWKWebView 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.

O Autorze Editorial Team

Our team of expert writers and editors.