其它相关内容请见虚拟现实(VR)/增强现实(AR)&visionOS开发学习笔记
App可以让用户访问网页,但实现的方式有不止一种。我们可以让用户通过链接在浏览器中打开文档、在应用界面中内嵌一个预定义的浏览器或是在后台下载并处理数据。
链接
链接是一个关联表示文档位置的文本或图片。在用户点击链接时打开文档。链接设计之初用于网页,但我们可以将其插入应用,让系统决定在何处(浏览器或是其它应用)打开文档。SwiftUI自带有Link视图进行创建。
Link(String, destination: URL);初始化创建一个打开链接的按钮。第一个参数指定按钮的标题,destination参数是一个带有希望打开的文档位置的URL结构体。如果希望使用视图来展示标签,可以实现初始化方法Link(destination:,label”)。
下例在点击按钮时打开alanhou.org。代码定义了一个@State
属性存储URL,使用希望打开的链接进行初始化。我们使用该属性的值创建URL结构体并将其赋给Link视图。在点击按钮时,系统会读取URL,识别到它是一个网页链接,然后打开浏览器加载相应网站。
示例17-1:打开网站
1 2 3 4 5 6 7 8 9 10 11 12 13 |
struct ContentView: View { @State private var searchURL = "https://alanhou.org" var body: some View { NavigationStack { VStack { Link("Open Web", destination: URL(string: searchURL)!) .buttonStyle(.borderedProminent) Spacer() }.padding() } } } |
图17-1:链接
✍️跟我一起做:创建一个多平台项目。使用示例17-1的代码更新ContentView
视图。在iPhone模拟器上运行应用,点击按钮。系统会打开外部浏览器并加载网站。
本例中,我们在代码内定义了URL,但有时URL由用户提供或是通过另一个文档获取。这时,URL中可能包含不允许出现的字符,导致无法识别位置。要保障URL有效,我们需要将不安全的字符转化为百分号编码字符。这些字符由%接十六进制数字进行表示。为此String结构体中包含了如下方法。
- addingPercentEncoding(withAllowedCharacters: CharacterSet):该方法返回一个字符串,参数指定的集合中所有字符都会使用百分号编码的字符进行替换。withAllowedCharacters参数是一个带类型属性的结构体,创建表示通用集合的实例。用于URL的有
urlFragmentAllowed
、urlHostAllowed
、urlPasswordAllowed
、urlPathAllowed
、urlQueryAllowed
和urlUserAllowed
。
这一方法由NSString类实现,但可在String结构体的任意实例中使用。这意味着可以对希望检查的URL直接应用该方法,并将其赋值给Link
视图。唯一的问题是这个视图要求URL已可处理,因此要先使用一个计算属性或方法检查其值。为简化这一处理,环境中包含一个名为openURL
的属性,返回可用于打开URL的方法。下例实现了一个Button
视图使用百分号编码字符替换掉无效字符,然后执行openURL()
方法打开链接。
示例17-2:编码URL
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
struct ContentView: View { @Environment(\.openURL) var openURL @State private var searchURL = "https://alanhou.org" var body: some View { NavigationStack { VStack { Button("Open Web") { if let url = searchURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { openURL(URL(string: url)!) } } .buttonStyle(.borderedProminent) Spacer() }.padding() } } } |
上例中,我们处理了一个知道没有问题的URL,但有时并不是这样。通常URL来自外部数据源或由用户提供。这时我们不仅要使用addingPercentEncoding()
对值进行编码,还要确定存在所有的URL组件。 例如,用户只提供了域名(alanhou.org),没带协议(https),我们需要在尝试打开前创建完整的URL。为阅读、创建及修改URL组件,Foundation框架定义了URLComponents
结构体。该结构体包含如下初始化方法。
- URLComponents(string: String):这个初始化方法通过
string
参数指定的URL组成部分创建一个URLComponents结构体。
URLComponents结构体包含一些读取和修改组成部分的属性。下面是一些常用的。
- scheme:这一属性设置或返回URL的协议(如
http
)。 - host:该属性设置或返回URL的域名(如
www.google.com
)。 - path:该属性设置或返回URL域名后的部分(
/index.php
)。 - query:该属性设置或返回URL的参数(如
id=22
)。 - queryItems:该属性设置或返回一个
URLQueryItem
结构体数组,包含URL中的所有参数。
URLComponents结构体还包含如下属性,返回一个由各组成部分创建URL的字符串。
- string:该组成返回由各组成部分值构建URL的字符串。
在下例中,我们允许用户插入一个URL,但确保了一定会包含https
协议。
示例17-3:编码自定义URL
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
struct ContentView: View { @Environment(\.openURL) var openURL @State private var searchURL = "" var body: some View { NavigationStack { VStack { TextField("Insert URL", text: $searchURL) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .autocorrectionDisabled(true) Button("Open Web") { if !searchURL.isEmpty { var components = URLComponents(string: searchURL) components?.scheme = "https" if let newURL = components?.string { if let url = newURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { openURL(URL(string: url)!) } } } } .buttonStyle(.borderedProminent) Spacer() }.padding() } } } |
URLComponents结构体接收一个URL字符串,提取组成部分将它们赋值给结构体属性,以便读取或修改。本例中,我们将字符串https
给scheme
属性保障URL有效,可由系统处理。组成部分就绪后,我们可通过string
属性获取完成的URL,使用百分号编码的字符替换无效字符并打开。
图17-2:自定义URL
Safari视图控制器
链接为我们提供了在应用内对网页的访问,但是在外部应用中打开的文档。考虑到抓住用户的注意力非常重要,苹果内置了一个名为SafaraServices的框架。通过该框架,我们可以在应用中内置Safari流星器,为用户提供更好的体验。框架包含一个SFSafariViewController
类,创建包含显示网页的视图及导航工具的视图控制器。
- SFSafariViewController(url: URL, configuration: Configuration):这个初始化方法创建一个新的自动加载
url
参数指定网站的Safari视图控制器。configuration
参数是SFSafariViewController
类中Configuration
类的对象的一个属性。可以使用的属性有entersReaderIfAvailable
和barCollapsingEnabled
。
SFSafariViewController
类创建一个UIKit视图控制器。因此,我们必须通过UIViewControllerRepresentable
协议定义一个representable视图控制器,添加到我们的SwiftUI界面中,如下例所示。(更多有关representable视图控制器的内容,请参见第16章。)
示例17-4:创建Safari浏览器
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import SwiftUI import SafariServices struct SafariBrowser: UIViewControllerRepresentable { @Binding var searchURL: URL func makeUIViewController(context: Context) -> SFSafariViewController { let safari = SFSafariViewController(url: searchURL) return safari } func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) { } } |
该结构体创建一个包含可使用的Safari游览器的视图控制器。下例中,我们在sheet弹窗中打开这个视图。
示例17-5:打开Safari游览器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
struct ContentView: View { @State private var searchURL: URL = URL(string: "https://www.formasterminds.com")! @State private var openSheet: Bool = false var body: some View { VStack { Button("Open Browser") { openSheet = true }.buttonStyle(.borderedProminent) Spacer() }.padding() .sheet(isPresented: $openSheet) { SafariBrowser(searchURL: $searchURL) } } } |
这一视图定义了一个类型为URL的@State
属性,使用https://www.formasterminds.com
进行初始化。点击按钮时,SafariBrowser
视图使用该值进行初始化,在弹窗中打开浏览器并加载网站。
图17-3:Safari浏览器
✍️跟我一起做:创建一个多平台项目。使用示例17-4中的代码创建一个名为SafariBrowser.swift
的Swift文件。使用示例17-5中的代码更新ContentView
视图。在iPhone模拟器上运行程序,点击按钮。这时会在弹窗中打开Safari游览器访问网址https://www.formasterminds.com
。
SFSafariViewController
类还提供了如下的配置属性:
- dismissButtonStyle:该属性设置或返回一个值,用于决定视图控制器释放视图所显示的按钮类型。它是一个类型为
DismissButtonStyle
的枚举,值有done
(默认值)、close
和cancel
。 - preferredBarTintColor:该属性设置或返回一个决定导航栏颜色的
UIColor
值。 - preferredControlTintColor:该属性设置或返回一个决定控件颜色的
UIColor
值。
下例使用这三个属性将浏览器的颜色适配www.formasterminds.com
网站。
示例17-6:配置视图控制器
1 2 3 4 5 6 7 8 9 10 11 12 13 |
struct SafariBrowser: UIViewControllerRepresentable { @Binding var searchURL: URL func makeUIViewController(context: Context) -> SFSafariViewController { let safari = SFSafariViewController(url: searchURL) safari.dismissButtonStyle = .close safari.preferredBarTintColor = UIColor(red: 81/255, green: 91/255, blue: 119/255, alpha: 1.0) safari.preferredControlTintColor = UIColor.white return safari } func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) { } } |
示例17-6中的代码还修改了dismissButtonStyle
属性,来改变浏览器所显示的按钮类型。Done按钮变成了Close。
图17-4:自定义Safari视图控制器
注意:
UIColor
类是由UIKit框架所定义的类。该类包含很多的初始化方法。最常的是UIColor(red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat)
。这个类还包含一些创建预定义颜色的类型属性。当前可以使用的有systemBlue
、systemBrown
、systemCyan
、、systemGreen
、systemIndigo
、systemMint
、systemOrange
、systemPink
、systemPurple
、systemRed
、systemTeal
、systemYellow
、systemGray
、systemGray2
、systemGray3
、systemGray4
、systemGray5
、systemGray6
、clear
、black
、blue
、brown
、cyan
、darkGray
、gray
、green
、lightGray
、magenta
、orange
、purple
、red
、white
和yellow
。
在用户滚动页面时,控制器会收成导航栏为内容让出空间。这会对用户退出或访问工具造成困难。如果我们觉得应用保留导航栏为原始尺寸更为合理,可以使用Configuration
对象初始化控制器。这个类位于SFSafariViewController
类之中,包含如下控制导航栏的属性。
barCollapsingEnabled
:这一属性设置或返回决定导航栏收起或展开的布尔值。
创建好Configuration
对象后,我们可以配置这个属性,通过控制器的初始化方法将其赋值给Safari视图控制器。
示例17-7:导航栏保留为原始大小
1 2 3 4 5 6 7 8 9 10 11 12 |
struct SafariBrowser: UIViewControllerRepresentable { @Binding var searchURL: URL func makeUIViewController(context: Context) -> SFSafariViewController { let config = SFSafariViewController.Configuration() config.barCollapsingEnabled = false let safari = SFSafariViewController(url: searchURL, configuration: config) return safari } func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) { } } |
✍️跟我一起做:使用示例17-7中的代码更新SafariBrowser
结构体。运行应用、滑动页面。导航栏会保持在原始大小,按钮也一直可见。
该框架还定义了一个SFSafariViewControllerDelegate
协议,这样可以对Safari视图控制器添加一个代理用于控制流程。以下是一部分协议中定义的方法。
- safariViewController(SFSafariViewController, didCompleteInitialLoad: Bool):这个方法在初始网站完成加载时由控制器调用。
- safariViewControllerDidFinish(SFSafariViewController):这一方法在视图释放后(用户点击Done按钮)由控制器调用。
Safari视图控制器有一个delegate
属性用于设置代理。下例中创建了一个coordinator
,赋值给了视图的代理,并实现了safariViewControllerDidFinish()
方法来在用户释放视图时禁用界面上的按钮。(用户仅能打开视图一次。)
示例17-8:为Safari视图控制器添加代理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
struct SafariBrowser: UIViewControllerRepresentable { @Binding var disable: Bool @Binding var searchURL: URL func makeUIViewController(context: Context) -> SFSafariViewController { let config = SFSafariViewController.Configuration() config.barCollapsingEnabled = false let safari = SFSafariViewController(url: searchURL, configuration: config) safari.delegate = context.coordinator return safari } func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) { } func makeCoordinator() -> SafariCoordinator { SafariCoordinator(disableCoordinator: $disable) } } class SafariCoordinator: NSObject, SFSafariViewControllerDelegate { @Binding var disableCoordinator: Bool init(disableCoordinator: Binding<Bool>) { self._disableCoordinator = disableCoordinator } func safariViewControllerDidFinish(_ controller: SFSafariViewController) { disableCoordinator = true } } |
在这个视图中,我们需要定义一个@State
属性存储一个布尔值并在Button
视图中实现disable()
修饰符来根据这个值启用或禁用按钮。
示例17-9:通过Safari视图控制器代理禁用按钮
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
struct ContentView: View { @State private var searchURL: URL = URL(string: "https://www.formasterminds.com")! @State private var openSheet: Bool = false @State private var disableButton: Bool = false var body: some View { VStack { Button("Open Browser") { openSheet = true }.buttonStyle(.borderedProminent) .disabled(disableButton) Spacer() }.padding() .sheet(isPresented: $openSheet) { SafariBrowser(disable: $disableButton, searchURL: $searchURL) } } } |
本例中,我们添加了一个Bool
类型的@State
属性disableButton
,将其传递给representable视图控制器,因此可以通过coordinator
修改其值。在释放Safari视图控制器时,执行safariViewControllerDidFinish()
方法,disableButton
属性的设为true
,因此用户无法再次点击按钮。
✍️跟我一起做:使用示例17-8中的代码更新SafariBrowser.swift
文件、示例17-9中的代码更新ContentView
视图。在iPhone模拟中运行应用、按下按钮。点击Done按钮关闭Safari视图控制器。此时按钮被禁用。
WebKit框架
对于某些应用,Safari视图控制器中包含的自定义选项还不够。为此Apple又提供WebKit框架这一选项。借助于这个框架,我们可以在视图内展示网页内容。该视图通过UIView
类的子类WKWebView
定义。这个类提供了如下管理内容的属性和方法。
- title:该属性返回文档标题字符串。
- url:该属性返回带文档URL的URL结构体。
- isLoading:该属性返回决定视图是否处于加载URL状态的布尔值。
- canGoBack:该属性返回决定视图是否可导航至前一页的布尔值。
- canGoForward:该属性返回决定视图是否可导航至下一页的布尔值。
- estimatedProgress:该属性返回0.0到1.0之间Double类型的值,决定内容加载的占比。
- load(URLRequest):该方法加载URL的内容。参数是希望打开URL的请求对象。
- goBack():该方法导航到导航历史记录中的上一页。
- goForward():该方法导航到导航历史记录中的下一页。
- go(to: WKBackForwardListItem):该方法导航至参数指定的网页。to参数为表示导航列表中网页的对象。
- reload():该方法重新加载当前页(刷新网页)。
- stopLoading():该方法要求视图停止加载内容。
要加载网站,我们必须创建一个请求。为此UIKit框架提供了URLRequest
结构体。该结构体包含如下初始化方法。
- URLRequest(url: URL, cachePolicy: CachePolicy, timeoutInterval: TimeInterval):这个初始化方法创建一个加载由
url
参数指定URL的请求。cachePolicy
参数是一个枚举,指定请求如何操作缓存。可以使用的值有:useProtocolCachePolicy
(默认值)、reloadIgnoringLocalCacheData
、reloadIgnoringLocalAndRemoteCacheData
、returnCacheDataElseLoad
、returnCacheDataDontLoad
和reloadRevalidatingCacheData
。timeoutInterval
参数是允许系统处理请求的最大时间(默认为60.0)。只有第一个参数必填,其余的参数都有默认值。
WebKit视图可以通过代理上报内容的状态。为此框架定义了WKNavigationDelegate
协议。以下是此协议中包含的部分方法。
- webView(WKWebView, decidePolicyFor: WKNavigationAction, decisionHandler: Closure):该方法对代理调用,指定视图是否应处理请求。
decidePolicyFor
参数是带有请求信息的对象,decisionHandler
参数是一个闭包,必须执行它来上报我们的决策。闭包接收WKNavigationActionPolicy
类型的值,这是一个属性为cancel
和allow
的枚举。 - webView(WKWebView, didStartProvisionalNavigation: WKNavigation!):该方法在视图开始加载新内容时对代理调用。
- webView(WKWebView, didFinish: WKNavigation!):该方法在视图完成内容加载时对代理调用。
- webView(WKWebView, didFailProvisionalNavigation: WKNavigation!, withError: Error):该方法在内容加载发生错误时对代理调用。
- webView(WKWebView, didReceiveServerRedirectForProvisionalNavigation: WKNavigation!):该方法在服务端将导航器重定向到其它目标时对代理调用。
WebKit视图是一个UIKit视图,因此我们必须使用UIViewRepresentable
进行创建。定义好representable视图后,在WebKit视图中加载网站的流程非常简单,创建请求、要求视图加载它。
示例17-10:通过WebKit视图加载网站
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import SwiftUI import WebKit struct WebView: UIViewRepresentable { let searchURL: URL func makeUIView(context: Context) -> WKWebView { let view = WKWebView() let request = URLRequest(url: searchURL) view.load(request) return view } func updateUIView(_ uiView: UIViewType, context: Context) { } } |
本例中,我们通过从SwiftUI界面接收到的URL准备请求,然后使用load()
方法加载网站。因为我们加载的是同一个网站,视图只需要定义好URL、传递给WebView
实例。
示例17-11:显示WebKit视图
1 2 3 4 5 |
struct ContentView: View { var body: some View { WebView(searchURL: URL(string: "https://www.google.com")!) } } |
✍️跟我一起做:创建一个多平台项目。使用示例17-10中的代码创建Swift文件WebView.swift
。用示例17-11中的代码更新ContentView
视图。在iPhone模拟器上运行应用。会看到Google的首页显示在屏幕上。
注意:示例17-11的示例中,我们打开的是安全的URL(以
https://
开头的URL),因为这是默认允许的URL。我们在第9章中学到,Apple实现了一个名为的应用传输安全(ATS)的系统来屏幕不安全的URL。如果希望允许用户在WKWebView
视图中加载不安全的URL,必须将ATS系统配置为Allow Arbitrary Loads选项(见图9-3)。
通过WKWebView
视图,我们可以加载包含用户指定在内的所有网站。我们只需要和前面例子一样为用户提供一种输入URL的方式,然后执行load()
方法加载它。为此,在下面视图中包含有一个TextField
视图和一个按钮。在点击按钮后,我们调用WebView
结构体中的方法,通过用户输入的URL更新视图。
示例17-12:允许用户插入URL
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
struct ContentView: View { @State private var webView: WebView! @State private var inputURL: String = "" var body: some View { VStack { HStack { TextField("Insert URL", text: $inputURL) .autocapitalization(.none) .autocorrectionDisabled(true) Button("Load") { let text = inputURL.trimmingCharacters(in: .whitespaces) if !text.isEmpty { webView.loadWeb(web: text) } } }.padding(5) webView }.onAppear { webView = WebView(inputURL: $inputURL) } } } |
本例中,我们添加了一个webView
属性,用于存储WebView
结构体。该属性在视图出现时进行初始化,然后它用于调用结构体的方法并在屏幕上显示视图。
用户插入的URL存储在inputURL
属性中,它被传递给WebView
结构体。这是为了在每次用户浏览新的页面时可以更新文本框中的值。
本例中的WebView
结构体需要创建WKWebView
视图、实现方法加载新URL并保持视图更新。
示例17-13:通过用户插入的URL更新WKWebView
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
import SwiftUI import WebKit struct WebView: UIViewRepresentable { @Binding var inputURL: String let view: WKWebView = WKWebView() func makeUIView(context: Context) -> WKWebView { view.navigationDelegate = context.coordinator let request = URLRequest(url: URL(string: "https://www.google.com")!) view.load(request) return view } func updateUIView(_ uiView: UIViewType, context: Context) { } func loadWeb(web: String) { var components = URLComponents(string: web) components?.scheme = "https" if let newURL = components?.string { if let url = newURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { if let loadURL = URL(string: url) { let request = URLRequest(url: loadURL) view.load(request) } } } } func makeCoordinator() -> CoordinatorWebView { return CoordinatorWebView(input: $inputURL) } } class CoordinatorWebView: NSObject, WKNavigationDelegate { @Binding var inputURL: String init(input: Binding<String>) { self._inputURL = input } func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { if let webURL = webView.url { inputURL = webURL.absoluteString } } } |
我们对WebView
结构体做了几处修改,使其可以加载多个URL。首先,我们在makeUIView()
方法外实例化了WKWebView
视图,因此可以在我肯定义方法中访问。在makeUIView()
方法内,我们通过将coordinator
的指针赋值给视图的navigationDelegate
属性来声明coordinator
为视图的代理,然后创建并加载请求。视图完成初始化并会调用coordinator
上报改变。但在实现coordinator
前,我们定义了loadWeb()
方法加载用户所输入的URL。这一方法在用户点击文本框旁的Load按钮时执行。该方法接收一个字符串、准备好URL并通过load()
方法加载它。用户输入的URL加载后内容会显示到屏幕上。下面做反向操作,我们需要在视图内容发生变化时更新文本框中的URL。这在用户点击页面上的链接导航至其它页面时发生。为此,我们让coordinator
实现WKNavigationDelegate
协议并实现webView(WKWebView, didCommit)
方法。这个方法在加载新内容时由WKWebView
调用。这里当前的URL通过视图的url
属性获取、赋值给inputURL
属性,接着修改TextField
视图中的值,这样文本框中的URL会与屏幕上显示的网站相一致。
✍️跟我一起做:使用示例17-12中的代码更新ContentView.swift
,用示例17-13中的代码更新WebView.swift
文件。在iPhone模拟器上运行应用。插入一个URL、点击Load按钮。视图中会加载这一URL并显示网站。点击页面上的链接导航至另一个页面。文本框中的URL会与屏幕上的页面地址保持一致。
我们到目前所构建的应用中,用户可以访问任意URL并通过点击链接导航至其它页面,但界面中并没有提供访问导航历史前一页和后一页的方式。WKWebView
类提供了一些控制内容的方法。比如,有一个goBack()
方法可以回到前一页,goForward()
方法可以回到上一页,而reload()
方法可以刷新页面。要执行这些方法,我们在导航栏下添加三个按钮。
示例17-14:提供导航按钮
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
struct ContentView: View { @State private var webView: WebView! @State private var inputURL: String = "" @State private var backDisabled: Bool = true @State private var forwardDisabled: Bool = true var body: some View { VStack { HStack { TextField("Insert URL", text: $inputURL) Button("Load") { let text = inputURL.trimmingCharacters(in: .whitespaces) if !text.isEmpty { webView.loadWeb(web: text) } } }.padding(5) HStack { Button(action: { webView.goBack() }, label: { Image(systemName: "arrow.left.circle") .font(.title) }).disabled(backDisabled) Button(action: { webView.goForward() }, label: { Image(systemName: "arrow.right.circle") .font(.title) }).disabled(forwardDisabled) Spacer() Button(action: { webView.refresh() }, label: { Image(systemName: "arrow.clockwise.circle") .font(.title) }) }.padding(5) webView }.onAppear { webView = WebView(inputURL: $inputURL, backDisabled: $backDisabled, forwardDisabled: $forwardDisabled) } } } |
以上视图增加了两个@State
属性,指定前一页和后一页按钮是否可点击。在初次显示视图时,按钮应处于禁用,因为视图中只加载了一个文档,但在加载了新文档后,我们需要启用按钮让用户可在导航历史中向前或向后访问。为此我们必须将这些属性传给WebView
结构体,每次加载文档时在coordinator
中修改这些值。
示例17-15:在访问历史中向前或向后导航
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
struct WebView: UIViewRepresentable { @Binding var inputURL: String @Binding var backDisabled: Bool @Binding var forwardDisabled: Bool let view: WKWebView = WKWebView() func makeUIView(context: Context) -> WKWebView { view.navigationDelegate = context.coordinator let request = URLRequest(url: URL(string: "https://www.google.com")!) self.view.load(request) return view } func updateUIView(_ uiView: UIViewType, context: Context) { } func loadWeb(web: String) { var components = URLComponents(string: web) components?.scheme = "https" if let newURL = components?.string { if let url = newURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { if let loadURL = URL(string: url) { let request = URLRequest(url: loadURL) view.load(request) } } } } func goBack() { view.goBack() } func goForward() { view.goForward() } func refresh() { view.reload() } func makeCoordinator() -> CoordinatorWebView { return CoordinatorWebView(input: $inputURL, back: $backDisabled, forward: $forwardDisabled) } } class CoordinatorWebView: NSObject, WKNavigationDelegate { @Binding var inputURL: String @Binding var backDisabled: Bool @Binding var forwardDisabled: Bool init(input: Binding<String>, back: Binding<Bool>, forward: Binding<Bool>) { self._inputURL = input self._backDisabled = back self._forwardDisabled = forward } func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { if let webURL = webView.url { inputURL = webURL.absoluteString backDisabled = !webView.canGoBack forwardDisabled = !webView.canGoForward } } } |
这段代码对WebView
结构体添加了三个方法,来执行用户选择的操作(向前、向后或刷新页面)。在webView(WKWebView, didFinish:)
方法中,我们和之前一样更新文本框中的URL,但同时使用了canGoBack
和canGoForward
的值变更按钮的状态,这样只在有页面可供打开时才启用按钮。
图17-5:导航按钮
✍️跟我一起做:使用示例17-14中的代码更新ContentView.swift
文件,用示例17-15中的代码更新WebView.swift
文件。在iPhone模拟器中运行应用。在Google中搜索一个词,点击链接再点击返回按钮,这时视图会回到上一个页面。
注意:WebKit框架还提供了处理cookie和JavaScript代码的工具,让我们可以和文档的内容进行交互。这里暂不讨论。更多内容请参见苹果官方文档。
Web内容
Safari视图控制器和WKWebView
用于向用户展示内容,但内容与应用之间的集成却十分有限。有时会只需要从文档中提取部分信息,或是处理数据而不是将内容原封不动展示出来。这时,我们可以在后台加载、解析文档,只提取出我们需要的内容。
Foundation
包含了一组获取URL指向内容的类。其中最重要的类是URLSession
。这个类创建一个管理HTTP连接的会话,用于提取数据、下载或上传文件。下面是该类为创建会话所提供的部分属性和初始化方法。
- shared:这一类型属性返回一些默认配置的标准会话,适于执行基本请求。
- URLSession(configuraiton: URLSessionConfiguration):这个初始化方法按照参数配置新建会话。
configuration
参数是一个指定会话行为的对象。 - URLSession(configuration: URLSessionConfiguration, delegate: URLSessionDelegate?, delegateQueue: OperationQueue?):这个初始化方法通过参数指定配置新建会话。
configuration
参数是一个指定会话行为的对象,delegate
参数是我们希望赋给会话的代理对象指针,delegateQueue
参数是代理方法中所要执行的队列。
会话配置连接,但并不执行任何任务。下载或上传数据,我们必须实现URLSession
类中定义的如下方法。
- data(from: URL, delegate: URLSessionTaskDelegate?):这一异步方法对会话添加任务,下载
from
参数指定URL的数据。delegate
参数是任务在处理过程上报更新所使用的代理对象。该方法返回一个带两个值的元组:一个是包含服务端返回数据的Data
结构体,一个是包含请求状态的URLResponse
对象。 - download(from: URL, delegate: URLSessionTaskDelegate?):这个异步方法向会话添加任务,下载
from
参数指定的URL对应的文件。delegate
参数是任务在处理过程上报更新所使用的代理对象。该方法返回一个带两个值的元组:一个是包含表示所下载文件位置的的URL
结构体,一个是包含请求状态的URLResponse
对象。
以下由该类定义的用于上传数据和文件的方法。
- upload(for: URLRequest, from: Data, delegate: URLSessionTaskDelegate?):这一异步方法向会话添加一个任务,上传
from
参数所指定的数据。delegate
参数是任务在处理过程上报更新所使用的代理对象。该方法返回一个包含两个值的元组:一个是包含服务端返回数据的Data
结构体,一个是包含请求状态的URLResponse
对象。 - upload(for: URLRequest, fromFile: URL, delegate: URLSessionTaskDelegate?):这个异步方法向会话添加任务,上传
fromFile
参数指定的URL对应的文件。delegate
参数是任务在处理过程上报更新所使用的代理对象。该方法返回一个带两个值的元组:一个是服务端返回数据的Data
结构体,一个是包含请求状态的URLResponse
对象。
这些方法都是异步的,也就是说它们在数据下载或上传完成后舞台结果。例如,我们使用data()
方法获取 网站数据,返回值包含数据值及一个类型为URLResponse
的带请求状态的对象。在通过HTTP协议访问URL时,响应由一个HTTPURLResponse
类型(URLResponse
的子类)的对象表示。这个类包含表示请求状态码的statusCode
属性。有很多的状态,比如200,表示请求成功,或时301,表示网站跳转到另一个地址。如果要确保正确下载数据,可以在做处理前检查statusCode
属性的值是否为200。下例展示了如何执行一个简单的请求。
示例17-16:加载远程文档
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
import SwiftUI import Observation @Observable class ApplicationData { var webContent: String = "" var buttonDisabled: Bool = false func loadWeb() async { buttonDisabled = true let session = URLSession.shared let webURL = URL(string: "https://www.yahoo.com") do { let (data, response) = try await session.data(from: webURL!) if let resp = response as? HTTPURLResponse { let status = resp.statusCode if status == 200 { if let content = String(data: data, encoding: String.Encoding.ascii) { await MainActor.run { webContent = content buttonDisabled = false } print(content) } } else { print("Error: \(status)") } } } catch { print("Error: \(error)") } } } |
这个模型加载www.yahoo.com
网站的内容,将其赋值给webContent
属性。这一操作由loadWeb()
方法执行。该方法使用https://www.yahoo.com
这一URL定义请求,然后调用会话的data()
方法下载页面。这个方法下载指定地址的内容,检测操作是否成功(200),从数据接收字符串,用这个值更新webContent
属性让其可在视图中使用。下面是处理这个数据的简单视图。
示例17-17:显示文档内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
struct ContentView: View { @Environment(ApplicationData.self) private var appData var body: some View { VStack { Button("Load Web") { Task(priority: .high) { await appData.loadWeb() } }.disabled(appData.buttonDisabled) Text("Total Characters: \(appData.webContent.count)") .padding() Spacer() }.padding() } } |
www.yahoo.com
返回的内容非常多。这里方便演示,在控制台中打印内容,按字符串进行字符计数,显示在屏幕上,但专业的应用通常会处理其值提取信息。
✍️跟我一起做:创建一个多平台项目。使用示例17-16的代码创建一个名为ApplicationData.swift
的Swift文件。使用示例17-17的代码更新ContentView
视图。记住要在应用和预览中将ApplicationData
对象注入环境(第7章示例7-4)。在iPhone模拟器上运行应用。点击Load Web按钮。等待数秒,会看到从www.yahoo.com
下载的文档在控制台中打印,并且在屏幕上显示了字符数。
我们在本例中使用的这种默认配置的标准会话适用于大多数场景,而自定义会话需要自行配置。为进行会话配置,Foundation
提供了一个名为URLSessionConfiguration
的类。下面的类型属性可用于获取带默认值的配置对象。
- default:该属性返回一个带默认设置的
URLSessionConfiguration
。
通过标准配置获得对象后,我们可以对其自定义满足自己应用的要求。下面来自URLSessionConfiguration
类的属性可用于配置会话。
- allowsCellularAccess:此属性设置或返回一个布尔值,指定在设备通过蜂窝网络连接时是否进行连接。
- timeoutIntervalForRequest:该属性返回一个
TimeInterval
值(Double
的别名),指定会话等待请求回复的秒数。默认值是60. - waitsForConnectivity:该属性设备或返回一个布尔值,指定会话是否等待设备连接到网络后再执行请求。默认值是
false
。
处理自定义会员只需要改变会话的初始化,其它代码保持不变。
示例17-18:初始化自定义会话
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
@Observable class ApplicationData { var webContent: String = "" var buttonDisabled: Bool = false func loadWeb() async { buttonDisabled = true let config = URLSessionConfiguration.default config.waitsForConnectivity = true let session = URLSession(configuration: config) let webURL = URL(string: "https://www.yahoo.com") do { let (data, response) = try await session.data(from: webURL!) if let resp = response as? HTTPURLResponse { let status = resp.statusCode if status == 200 { if let content = String(data: data, encoding: String.Encoding.ascii) { await MainActor.run { webContent = content buttonDisabled = false } print(content) } } else { print("Error: \(status)") } } } catch { print("Error: \(error)") } } } |
上例中,我们没有实现data()
方法的delegate
参数。这是一个可选参数,但可以在需要响应及处理更新时进行声明。框架定义了一个URLSessionTaskDelegate
协议来创建这个代理 。下面是协议中的一些方法。
- urlSession(URLSession, task: URLSessionTask, didReceive: URLAuthenticationChallenge, completionHandler: Closure):该方法在服务端请求需要进行校验时对代理调用。我们的实现必须使用定义设置和认证信息的两个参数调用方法接收的完结处理器。
- urlSession(URLSession, task: URLSessionTask, willPerformHTTPRedirection: HTTPURLResponse, newRequest: URLRequest, completionHandler: Block):该方法在服务端将请求重定向到另一个URL时对代理调用。我们的实现必须通过新请求(
newRequest
参数的值)定义的参数或在不希望重定向时用nil
调用方法接收的完结处理器。
一些网站,比如www.yahoo.com
,自动将用户重定向到另一个适配用户地理位置和偏好网站版本的地址。这意味着我们所提供的URL不是最终地址。服务端不返回任何数据,而是将用户重定向到另一个文档。这时,我们可以定义一个带代理的自定义会话,然后实现URLSessionTaskDelegate
协议方法指定服务端重定向应用时我们希望做的操作。
示例17-19:重定向用户
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
@Observable class ApplicationData: NSObject, URLSessionTaskDelegate { var webContent: String = "" var buttonDisabled: Bool = false func loadWeb() async { buttonDisabled = true let session = URLSession.shared let webURL = URL(string: "https://www.yahoo.com") do { let (data, response) = try await session.data(from: webURL!, delegate: self) if let resp = response as? HTTPURLResponse { let status = resp.statusCode if status == 200 { if let content = String(data: data, encoding: String.Encoding.ascii) { await MainActor.run { webContent = content buttonDisabled = false } print(content) } } else { print("Error: \(status)") } } } catch { print("Error: \(error)") } } func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest) async -> URLRequest? { print(request.url ?? "No URL") return request } } |
✍️跟我一起做:使用示例17-19的代码更新ApplicationData
类。在iPhone模拟器中运行应用。点击Load Web按钮。会在控制台中打印出用户重定向的URL。
www.yahoo.com
等所返回的网页文档,是用HTML写的。这是一种简单的编程语言,由网站用于组织信息。从这些文档中提取数据比较枯燥、易出错。因此,网站通常会提供以JSON格式分享数据的服务。JSON文档动态生成,仅包含应用所请求的信息。例如,www.openweathermap.org
提供了生成包含天气信息JSON文档的服务(https://openweathermap.org/api
)。
为演示如何访问和处理这些服务生成的文档,我会从一个生成假文档的www.openweathermap.org
(jsonplaceholder.typicode.com
)网站上读取文章。这一处理不需要新知识。我们必须通过URLSession
加载文档并使用JSONDecoder
对象进行解码。
示例17-20:加载JSON文档
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
struct Post: Codable, Identifiable { var id: Int var userId: Int var title: String var body: String } @Observable class ApplicationData { var listOfPosts: [Post] = [] init() { Task(priority: .high) { await loadJSON() } } func loadJSON() async { let session = URLSession.shared let webURL = URL(string: "https://jsonplaceholder.typicode.com/posts") do { let (data, response) = try await session.data(from: webURL!) if let resp = response as? HTTPURLResponse { let status = resp.statusCode if status == 200 { let decoder = JSONDecoder() if let posts = try? decoder.decode([Post].self, from: data) { await MainActor.run { listOfPosts = posts } } } else { print("Error: \(status)") } } } catch { print("Error: \(error)") } } } |
我们在第10章中学过,解码JSON文档,我们需要定义一个与JSON值相匹配的结构体。URL https://jsonplaceholder.typicode.com/posts
返回一个文章列表,每条包含四个值:表示用户ID的整数、表示文章ID的整数、表示文章标题的字符串和表示内容的字符串。为存储这些值,我们在示例17-20中定义了一个Post
结构体。为能够解码其中的值这个结构体实现了Codable
协议,为能使用List
视图列出实例实现了Identifiable
协议。
文档下载的处理与之前相同。我们获取会话,调用data()
方法,使用JSONDecoder
将数据解码至Post
结构体数组中,将值存储到listOfPosts
属性中更新视图。因文档在模型初始化时下载,我们只需要在视图中列出这些值即可。
示例17-21:列出文档中的值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
struct ContentView: View { @Environment(ApplicationData.self) private var appData var body: some View { VStack { List { ForEach(appData.listOfPosts) { post in VStack(alignment: .leading) { Text(post.title).bold() Text(post.body) }.padding(5) } }.listStyle(.plain) }.padding() } } |
✍️跟我一起做:使用示例17-20中的代码更新ApplicationData.swift
文件,用示例17-21的代码更新ContentView
视图。运行应用。应该会在屏幕上看到100条信息。要查看https://jsonplaceholder.typicode.com/posts
所返回JSON文件的结构,可在浏览器中直接打开该链接。
代码请见:GitHub仓库