Вступ
Якщо ви хоч раз намагалися показати веб-контент у SwiftUI-додатку до iOS 26, то знаєте цей біль. Створити обгортку UIViewRepresentable навколо WKWebView, налаштувати делегати, побороти ретейн-цикли, якось синхронізувати імперативний UIKit із декларативним SwiftUI... Працювало? Так. Було зручно? Навряд.
На WWDC 2025 Apple нарешті вирішила цю проблему — і, чесно кажучи, краще пізно, ніж ніколи. Тепер у SwiftUI є нативний WebView, і відображення веб-сторінки зводиться до одного рядка коду. А для повного контролю — прогрес завантаження, JavaScript, навігація — є модель WebPage, побудована на протоколі Observable.
У цьому посібнику ми пройдемо весь шлях від найпростішого завантаження URL до побудови повноцінного браузера з історією навігації, фільтрацією доменів і виконанням JavaScript. Усі приклади — робочий код, який можна використовувати у своїх проєктах прямо зараз.
Базове завантаження URL: один рядок коду
Отже, найпростіший варіант. Хочете відобразити веб-сторінку? Просто передайте URL у WebView:
import SwiftUI
import WebKit
struct SimpleWebView: View {
var body: some View {
WebView(url: URL(string: "https://www.apple.com"))
}
}
Зверніть увагу: WebView(url:) приймає опціональний URL?. Якщо URL некоректний або nil — нічого не завантажиться, і жодних крешів. Для роботи потрібно імпортувати фреймворк WebKit.
Такий підхід ідеально підходить для простих речей: показати сторінку політики конфіденційності, документацію або статичний контент. Компонент сам обробляє завантаження, підтримує жести навігації і добре інтегрується зі SwiftUI.
WebPage: повний контроль над веб-контентом
Якщо простого відображення недостатньо — скажімо, вам потрібно відстежувати прогрес завантаження, читати заголовок сторінки чи керувати навігацією програмно — тоді на сцену виходить WebPage. Це Observable-клас, створений спеціально для SwiftUI:
import SwiftUI
import WebKit
struct AdvancedWebView: View {
@State private var page = WebPage()
var body: some View {
VStack(spacing: 0) {
if page.isLoading {
ProgressView(value: page.estimatedProgress)
.progressViewStyle(.linear)
}
WebView(page)
.ignoresSafeArea(.container, edges: .bottom)
}
.navigationTitle(page.title ?? "Завантаження...")
.onAppear {
let request = URLRequest(url: URL(string: "https://www.swift.org")!)
page.load(request)
}
}
}
Ключовий момент: WebPage.load() приймає URLRequest (з неопціональним URL), а не просто URL-адресу. Це дає змогу додавати HTTP-заголовки, змінювати метод запиту й інші параметри — що набагато гнучкіше.
Властивості WebPage, які оновлюються автоматично
Оскільки WebPage відповідає протоколу Observable, будь-яка зміна його властивостей автоматично перемальовує залежні View. Ось основні:
page.title— заголовок поточної сторінки (String?)page.url— поточний URL (URL?)page.isLoading— чи завантажується сторінка (Bool)page.estimatedProgress— прогрес завантаження від 0.0 до 1.0 (Double)
Це, м'яко кажучи, набагато простіше, ніж підписуватись на KVO-спостерігачі в WKWebView. Хто працював із KVO у Swift — той зрозуміє.
Конфігурація WebPage: User Agent, медіа та інше
Перед створенням WebPage можна налаштувати конфігурацію через WebPage.Configuration:
@State private var page: WebPage = {
var config = WebPage.Configuration()
config.applicationNameForUserAgent = "MyApp/1.0"
return WebPage(configuration: config)
}()
Конфігурація дає змогу задати ідентифікацію додатку через User Agent, налаштувати поведінку медіа-відтворення, розпізнавання типів даних (посилання, номери телефонів тощо). Важливий нюанс: конфігурацію потрібно задати до створення WebPage. Змінити її після ініціалізації вже не вийде.
Завантаження локального HTML-контенту
WebPage підтримує не лише віддалені URL, а й завантаження HTML-рядків напряму. Це зручно для відображення стилізованого контенту, вбудованих відео або динамічно згенерованого HTML:
struct LocalHTMLView: View {
@State private var page = WebPage()
private let htmlContent = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, system-ui;
padding: 20px;
background-color: #f5f5f7;
color: #1d1d1f;
}
h1 { color: #0066cc; }
</style>
</head>
<body>
<h1>Привіт із SwiftUI!</h1>
<p>Цей HTML завантажено через WebPage.</p>
</body>
</html>
"""
var body: some View {
WebView(page)
.onAppear {
page.load(
html: htmlContent,
baseURL: URL(string: "about:blank")!
)
}
}
}
Метод page.load(html:baseURL:) приймає HTML-рядок і базовий URL для розв'язання відносних шляхів. Якщо ваш HTML не посилається на зовнішні ресурси — about:blank цілком підійде.
Виконання JavaScript
Ось де стає по-справжньому цікаво. WebPage дозволяє виконувати довільний JavaScript на завантаженій сторінці. Метод callJavaScript — асинхронний і може повертати результат:
struct JavaScriptExample: View {
@State private var page = WebPage()
@State private var pageHeaders: [String] = []
var body: some View {
VStack {
WebView(page)
.ignoresSafeArea(.container, edges: .bottom)
if !pageHeaders.isEmpty {
List(pageHeaders, id: \.self) { header in
Text(header)
}
.frame(height: 200)
}
}
.onAppear {
let request = URLRequest(url: URL(string: "https://www.swift.org")!)
page.load(request)
}
.task {
// Чекаємо, поки сторінка завантажиться
while page.isLoading {
try? await Task.sleep(for: .milliseconds(100))
}
// Збираємо всі заголовки h2 зі сторінки
do {
let script = """
Array.from(document.querySelectorAll('h2'))
.map(el => el.textContent)
.join('|||')
"""
if let result = try await page.callJavaScript(script) as? String {
pageHeaders = result.split(separator: "|||").map(String.init)
}
} catch {
print("Помилка JavaScript: \(error)")
}
}
}
}
callJavaScript(_:) працює через async/await, що ідеально вписується в модель конкурентності Swift. Скрипти виконуються в пісочниці WebKit, тож безпека забезпечена.
Важливо: завжди виконуйте JavaScript після завантаження сторінки. Якщо DOM ще не готовий — результат буде непередбачуваним. І ще: уникайте виконання ненадійного JavaScript-коду, це може створити вразливості у вашому додатку.
Навігація: кнопки «Назад» і «Вперед»
На відміну від Safari, WebView у SwiftUI не має вбудованих кнопок навігації. Їх потрібно зробити самостійно. Але не хвилюйтесь — WebPage надає всю необхідну інфраструктуру через властивість backForwardList:
struct BrowserView: View {
@State private var page = WebPage()
var body: some View {
VStack(spacing: 0) {
if page.isLoading {
ProgressView(value: page.estimatedProgress)
.progressViewStyle(.linear)
}
WebView(page)
.ignoresSafeArea(.container, edges: .bottom)
}
.onAppear {
let request = URLRequest(url: URL(string: "https://www.swift.org")!)
page.load(request)
}
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
Button {
if let item = page.backForwardList.backList.last {
page.load(item)
}
} label: {
Label("Назад", systemImage: "chevron.backward")
}
.disabled(page.backForwardList.backList.isEmpty)
Button {
if let item = page.backForwardList.forwardList.first {
page.load(item)
}
} label: {
Label("Вперед", systemImage: "chevron.forward")
}
.disabled(page.backForwardList.forwardList.isEmpty)
Spacer()
Button {
page.reload()
} label: {
Label("Оновити", systemImage: "arrow.clockwise")
}
if let url = page.url {
ShareLink(item: url)
}
}
}
}
}
Властивість backForwardList містить два масиви: backList (відвідані сторінки) та forwardList (сторінки для переходу вперед після натискання «Назад»). Кожен елемент — це WebPage.BackForwardList.Item з властивостями title і url.
Розширене меню історії
Для кращого UX можна додати меню з повною історією навігації — приблизно так, як Safari показує список сторінок при довгому натисканні на кнопку «Назад»:
struct BackForwardMenu: View {
let list: [WebPage.BackForwardList.Item]
let systemImage: String
let navigateToItem: (WebPage.BackForwardList.Item) -> Void
var body: some View {
Menu {
ForEach(list) { item in
Button(item.title ?? item.url.absoluteString) {
navigateToItem(item)
}
}
} label: {
Label("Навігація", systemImage: systemImage)
} primaryAction: {
if let first = list.first {
navigateToItem(first)
}
}
.disabled(list.isEmpty)
}
}
Одне натискання — перехід на попередню або наступну сторінку. Довге натискання — повний список історії. Зручно і звично для користувачів.
Контроль навігації: фільтрація URL та протокол NavigationDeciding
У реальних додатках часто потрібно контролювати, куди може переходити користувач. Наприклад, обмежити навігацію вашим доменом або перенаправити зовнішні посилання в системний браузер. Саме для цього WebPage надає протокол NavigationDeciding:
final class DomainNavigationDecider: WebPage.NavigationDeciding {
var onExternalURL: ((URL) -> Void)?
private let allowedDomain: String
init(allowedDomain: String) {
self.allowedDomain = allowedDomain
}
func decidePolicy(
for action: WebPage.NavigationAction,
preferences: inout WebPage.NavigationPreferences
) async -> WKNavigationActionPolicy {
guard let url = action.request.url else { return .cancel }
if url.host() == allowedDomain {
return .allow
}
onExternalURL?(url)
return .cancel
}
}
А ось як це використовувати у View:
struct FilteredWebView: View {
@Environment(\.openURL) private var openURL
@State private var page = WebPage()
var body: some View {
WebView(page)
.onAppear {
let decider = DomainNavigationDecider(
allowedDomain: "www.swift.org"
)
decider.onExternalURL = { url in
openURL(url)
}
page = WebPage(navigationDecider: decider)
page.load(URLRequest(url: URL(string: "https://www.swift.org")!))
}
}
}
Тепер якщо користувач натисне на зовнішнє посилання (наприклад, на GitHub), навігація всередині WebView скасується, а URL відкриється в Safari. Класичний патерн для додатків, що показують власний контент у вбудованому браузері.
Модифікатори WebView: жести та прев'ю посилань
SwiftUI надає кілька модифікаторів для тонкого налаштування WebView:
WebView(page)
// Вимкнути прев'ю посилань при довгому натисканні
.webViewLinkPreviews(.disabled)
// Вимкнути жести свайпу для навігації назад/вперед
.webViewBackForwardNavigationGestures(.disabled)
За замовчуванням обидві функції увімкнені. Прев'ю показує попередній перегляд сторінки при довгому натисканні, а жести свайпу дозволяють переходити назад і вперед горизонтальним рухом. Якщо ви вже реалізували навігацію через тулбар — жести свайпу краще вимкнути, щоб не було конфліктів.
Міграція з WKWebView: підтримка старіших версій iOS
Нативний WebView доступний тільки з iOS 26. Якщо ваш додаток має підтримувати старіші версії — використовуйте умовну компіляцію:
struct CrossVersionWebView: View {
let url: URL
var body: some View {
if #available(iOS 26, *) {
WebView(url: url)
} else {
LegacyWebView(url: url)
}
}
}
// Обгортка для старих версій iOS
struct LegacyWebView: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView {
WKWebView()
}
func updateUIView(_ webView: WKWebView, context: Context) {
webView.load(URLRequest(url: url))
}
}
Такий підхід забезпечує плавну міграцію: на iOS 26 і новіше працює нативний компонент, на старших версіях — перевірена обгортка UIViewRepresentable. Нічого складного.
Порівняння WKWebView та нативного WebView
Ось ключові відмінності між старим і новим підходами:
- Налаштування: WKWebView вимагає
UIViewRepresentableплюс делегати; WebView — один рядок коду. - Синтаксис: WKWebView — імперативний UIKit; WebView — декларативний SwiftUI.
- Стан: WKWebView потребує ручної синхронізації через KVO; WebPage автоматично оновлює UI завдяки
Observable. - JavaScript: обидва підтримують, але WebPage використовує
async/awaitзамість completion handlers. - Пам'ять: WebView усуває проблеми з ретейн-циклами, типові для UIViewRepresentable-обгорток.
Загалом, якщо ви починаєте новий проєкт під iOS 26 — немає жодної причини тягнути за собою WKWebView.
Повний приклад: міні-браузер у SwiftUI
Ну що, зберемо все воєдино? Ось повноцінний міні-браузер з адресним рядком, прогресом завантаження, навігацією та обробкою помилок:
import SwiftUI
import WebKit
struct MiniBrowser: View {
@State private var page = WebPage()
@State private var urlText = "https://www.swift.org"
@State private var showError = false
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Індикатор прогресу
if page.isLoading {
ProgressView(value: page.estimatedProgress)
.progressViewStyle(.linear)
.tint(.blue)
}
// Веб-контент
WebView(page)
.ignoresSafeArea(.container, edges: .bottom)
}
.navigationTitle(page.title ?? "Браузер")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
// Адресний рядок
ToolbarItem(placement: .principal) {
HStack {
TextField("URL", text: $urlText)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.onSubmit { loadURL() }
Button("Перейти") { loadURL() }
.buttonStyle(.bordered)
}
}
// Навігаційні кнопки
ToolbarItemGroup(placement: .bottomBar) {
Button {
if let item = page.backForwardList.backList.last {
page.load(item)
}
} label: {
Image(systemName: "chevron.backward")
}
.disabled(page.backForwardList.backList.isEmpty)
Button {
if let item = page.backForwardList.forwardList.first {
page.load(item)
}
} label: {
Image(systemName: "chevron.forward")
}
.disabled(page.backForwardList.forwardList.isEmpty)
Spacer()
if page.isLoading {
Button {
page.stopLoading()
} label: {
Image(systemName: "xmark")
}
} else {
Button {
page.reload()
} label: {
Image(systemName: "arrow.clockwise")
}
}
if let url = page.url {
ShareLink(item: url)
}
}
}
.onAppear { loadURL() }
.alert("Некоректний URL", isPresented: $showError) {
Button("OK") {}
}
}
}
private func loadURL() {
var input = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
if !input.hasPrefix("http://") && !input.hasPrefix("https://") {
input = "https://" + input
}
guard let url = URL(string: input) else {
showError = true
return
}
page.load(URLRequest(url: url))
}
}
Цей приклад покриває більшість можливостей нативного WebView: завантаження сторінок, прогрес, навігацію назад/вперед, перезавантаження, зупинку завантаження і шерінг URL. І все це — без жодного рядка UIKit-коду. Особисто мене це неймовірно тішить після років боротьби з UIViewRepresentable.
Поради та найкращі практики
- Завжди використовуйте HTTPS. Завантаження HTTP-контенту заблоковане за замовчуванням через App Transport Security. Якщо вам справді потрібен HTTP — додайте виняток в
Info.plist, але це вкрай не рекомендується. - Перевіряйте готовність DOM перед JavaScript. Виконуйте скрипти тільки після завершення завантаження, інакше
callJavaScriptможе повернути неочікуваний результат або помилку. - Обробляйте помилки JavaScript. Метод
callJavaScriptможе кинути помилку — завжди загортайте виклик уdo-catch. - Використовуйте
.ignoresSafeAreaдляWebView, щоб контент заповнював весь екран до країв. - Конфігуруйте WebPage один раз.
WebPage.Configurationзадається при ініціалізації і змінити її пізніше неможливо. - Уникайте
@StateObject.WebPage— цеObservable-клас, який працює з@State, а не з@StateObject. Не переплутайте (це поширена помилка).
Часті запитання (FAQ)
Чи можна використовувати SwiftUI WebView на версіях iOS до 26?
Ні, нативний WebView у SwiftUI доступний тільки з iOS 26. Для старших версій використовуйте обгортку UIViewRepresentable навколо WKWebView з умовною перевіркою #available(iOS 26, *).
У чому різниця між WebView і WebPage у SwiftUI?
WebView — це SwiftUI View для відображення веб-контенту. WebPage — це Observable-модель, яка дає повний контроль: заголовок, URL, прогрес завантаження, JavaScript, навігація. Для простих випадків вистачить WebView(url:), а для складних — створіть WebPage і передайте його у WebView(page).
Як виконати JavaScript у SwiftUI WebView?
Використовуйте метод callJavaScript(_:) на екземплярі WebPage. Він асинхронний (async throws) і повертає результат виконання скрипту. Головне — переконайтесь, що сторінка вже повністю завантажена.
Чи замінює новий WebView WKWebView?
Не повністю. Нативний WebView побудований на тому ж WebKit, але не дає доступу до всіх можливостей WKWebView — наприклад, WKUIDelegate для JavaScript-діалогів або детального налаштування WKWebViewConfiguration. Для більшості задач нового WebView більш ніж достатньо, але для специфічних сценаріїв WKWebView все ще може знадобитись.
Як обмежити навігацію певним доменом у WebView?
Реалізуйте протокол WebPage.NavigationDeciding. У методі decidePolicy(for:preferences:) перевірте домен запиту і поверніть .allow або .cancel. Зовнішні URL можна перенаправити у Safari через openURL.