在 SwiftUI 檢視中開啟 URL 的若干方法

語言: CN / TW / HK

highlight: a11y-dark

原文發表在我的部落格 wwww.fatbobman.com

歡迎訂閱我的公共號:【肘子的Swift記事本】

本文將介紹在 SwiftUI 檢視中開啟 URL 的若干種方式,其他的內容還包括如何自動識別文字中的內容併為其轉換為可點選連結,以及如何自定義開啟 URL 前後的行為等。

本文的範例程式碼是在 Swift Playgrounds 4.1 ( macOS 版本 )中完成的,可在 此處下載。瞭解更多有關 Swift Playgrounds 的內容,可以參閱 Swift Playgrounds 4 娛樂還是生產力 一文。

image-20220520182722773

SwiftUI 1.0( iOS 13、Catalina )

在檢視中,開發者通常需要處理兩種不同的開啟 URL 的情況:

  • 點選一個按鈕( 或類似的部件 )開啟指定的 URL
  • 將文字中的部分內容變成可點選區域,點選後開啟指定的 URL

遺憾的是,1.0 時代的 SwiftUI 還相當稚嫩,沒有提供任何原生的方法來應對上述兩種場景。

對於第一種場景,常見的做法為:

```swift // iOS Button("Wikipedia"){ UIApplication.shared.open(URL(string:"http://www.wikipedia.org")!) }

// macOS Button("Wikipedia"){ NSWorkspace.shared.open(URL(string:"http://www.wikipedia.org")!) } ```

而第二種場景實現起來就相當地麻煩,需要包裝 UITextView( 或 UILabel )並配合 NSAttributedString 一起來完成,此時 SwiftUI 僅被當作一個佈局工具而已。

SwiftUI 2.0( iOS 14、Big sur )

SwiftUI 2.0 為第一個場景提供了相當完美的原生方案,但仍無法通過原生的方式來處理第二種場景。

openURL

openURL 是 SwiftUI 2.0 中新增的一個環境值( EnvironmentValue ),它有兩個作用:

  • 通過呼叫它的 callFunction 方法,實現開啟 URL 的動作

此時在 Button 中,我們可以直接通過 openURL 來完成在 SwiftUI 1.0 版本中通過呼叫其他框架 API 才能完成的工作。

```swift struct Demo: View { @Environment(.openURL) private var openURL // 引入環境值

var body: some View {
    Button {
        if let url = URL(string: "http://www.example.com") {
            openURL(url) { accepted in  //  通過設定 completion 閉包,可以檢查是否已完成 URL 的開啟。狀態由 OpenURLAction 提供
                print(accepted ? "Success" : "Failure")
            }
        }
    } label: {
        Label("Get Help", systemImage: "person.fill.questionmark")
    }
}

} ```

  • 通過提供 OpenURLAction ,自定義通過 openURL 開啟連結的行為(後文中詳細說明)

Link

SwiftUI 2.0 提供了一個結合 Button 和 openURL 的 Link 控制元件,幫助開發者進一步簡化程式碼:

swift Link(destination: URL(string: "mailto://[email protected]")!, label: { Image(systemName: "envelope.fill") Text("發郵件") })

SwiftUI 3.0( iOS 15、Monterey )

3.0 時代,隨著 Text 功能的增強和 AttributedString 的出現,SwiftUI 終於補上了另一個短板 —— 將文字中的部分內容變成可點選區域,點選後開啟指定的 URL。

Text 用例 1 :自動識別 LocalizedStringKey 中的 URL

通過支援 LocalizedStringKey 的構造方法建立的 Text ,會自動識別文字中的網址( 開發者無須做任何設定),點選後會開啟對應的 URL 。

swift Text("www.wikipedia.org 13900000000 [email protected]") // 預設使用引數型別為 LocalizedStringKey 的構造器

image-20220520141225595

此種方法只能識別網路地址( 網頁地址、郵件地址等 ),因此程式碼中的電話號碼無法自動識別。

請注意,下面的程式碼使用的是引數型別為 String 的構造器,因此 Text 將無法自動識別內容中的 URL :

swift let text = "www.wikipedia.org 13900000000 [email protected]" // 型別為 String Text(text) // 引數型別為 String 的構造器不支援自動識別

Text 用例 2 :識別 Markdown 語法中的 URL 標記

SwiftUI 3.0 的 Text ,當內容型別為 LocalizedStringKey 時,Text 可以對部分 Markdown 語法標記進行解析 :

swift Text("[Wikipedia](http://www.wikipedia.org) ~~Hi~~ [13900000000](tel://13900000000)")

在這種方式下,我們可以使用任何種類的 URI (不限於網路),比如程式碼中的撥打電話。

image-20220522085352243

Text 用例 3 :包含 link 資訊的 AttributedString

在 WWDC 2021 上,蘋果推出了 NSAttributedString 的值型別版本 AttributedString, 並且可以直接使用在 Text 中。通過在 AttributedString 中為不同位置的文字設定不同的屬性,從而實現在 Text 中開啟 URL 的功能。

```swift let attributedString:AttributedString = { var fatbobman = AttributedString("肘子的 Swift 記事本") fatbobman.link = URL(string: "http://www.fatbobman.com")! fatbobman.font = .title fatbobman.foregroundColor = .green // link 不為 nil 的 Run,將自動遮蔽自定義的前景色和下劃線 var tel = AttributedString("電話號碼") tel.link = URL(string:"tel://13900000000") tel.backgroundColor = .yellow var and = AttributedString(" and ") and.foregroundColor = .red return fatbobman + and + tel }()

Text(attributedString) ```

image-20220520144103395

更多有關 AttributedString 的內容,請參閱 AttributedString——不僅僅讓文字更漂亮

Text 用例 4 :識別字符串中的 URL 資訊,並轉換成 AttributedString

上述 3 個用例中,除了用例 1可以自動識別文字中的網路地址外,其他兩個用例都需要開發者通過某種方式顯式新增 URL 資訊。

開發者可以通過使用 NSDataDetector + AttributedString 的組合,從而實現類似系統資訊、郵件、微信 app 那樣,對文字中的不同型別的內容進行自動識別,並設定對應的 URL。

NSDataDetector 是 NSRegularExpression 的子類,它可以檢測自然語言文字中的半結構化資訊,如日期、地址、連結、電話號碼、交通訊息等內容,它被廣泛應用於蘋果提供的各種系統應用中。

swift let text = "http://www.wikipedia.org 13900000000 [email protected]" // 設定需要識別的型別 let types = NSTextCheckingResult.CheckingType.link.rawValue | NSTextCheckingResult.CheckingType.phoneNumber.rawValue // 建立識別器 let detector = try! NSDataDetector(types: types) // 獲取識別結果 let matches = detector.matches(in: text, options: [], range: NSRange(location: 0, length: text.count)) // 逐個處理檢查結果 for match in matches { if match.resultType == .date { ... } }

你可以將 NSDataDetector 視為擁有極高複雜度的正則表示式封裝套件。

完整的程式碼如下:

```swift extension String { func toDetectedAttributedString() -> AttributedString {

    var attributedString = AttributedString(self)

    let types = NSTextCheckingResult.CheckingType.link.rawValue | NSTextCheckingResult.CheckingType.phoneNumber.rawValue

    guard let detector = try? NSDataDetector(types: types) else {
        return attributedString
    }

    let matches = detector.matches(in: self, options: [], range: NSRange(location: 0, length: count))

    for match in matches {
        let range = match.range
        let startIndex = attributedString.index(attributedString.startIndex, offsetByCharacters: range.lowerBound)
        let endIndex = attributedString.index(startIndex, offsetByCharacters: range.length)
        // 為 link 設定 url
        if match.resultType == .link, let url = match.url {
            attributedString[startIndex..<endIndex].link = url
            // 如果是郵件,設定背景色
            if url.scheme == "mailto" {
            attributedString[startIndex..<endIndex].backgroundColor = .red.opacity(0.3)
            }
        }
        // 為 電話號碼 設定 url
        if match.resultType == .phoneNumber, let phoneNumber = match.phoneNumber {
            let url = URL(string: "tel:\(phoneNumber)")
            attributedString[startIndex..<endIndex].link = url
        }
    }
    return attributedString
}

}

Text("http://www.wikipedia.org 13900000000 [email protected]".toDetectedAttributedString()) ```

image-20220520150754052

自定義 Text 中連結的顏色

遺憾的是,即使我們已經為 AttributedString 設定了前景色,但當某段文字的 link 屬性非 nil 時,Text 將自動忽略它的前景色和下劃線設定,使用系統預設的 link 渲染設定來顯示。

目前可以通過設定著色來改變 Text 中全部的 link 顏色:

```swift Text("www.wikipedia.org 13900000000 [email protected]") .tint(.green)

Link("Wikipedia", destination: URL(string: "http://www.wikipedia.org")!) .tint(.pink) ```

image-20220520151737202

相較 Text 中連結的固定樣式,可以用 Button 或 Link 建立可以自由定製外觀的連結按鈕:

swift Button(action: { openURL(URL(string: "http://www.wikipedia.org")!) }, label: { Circle().fill(.angularGradient(.init(colors: [.red,.orange,.pink]), center: .center, startAngle: .degrees(0), endAngle: .degrees(360))) })

image-20220520164125700

自定義 openURL 的行為

在 Button 中,我們可以通過在閉包中新增邏輯程式碼,自定義開啟 URL 之前與之後的行為。

swift Button("開啟網頁") { if let url = URL(string: "http://www.example.com") { // 開啟 URL 前的行為 print(url) openURL(url) { accepted in // 通過設定 completion 閉包,定義點選 URL 後的行為 print(accepted ? "Open success" : "Open failure") } } }

但在 Link 和 Text 中,我們則需要通過為環境值 openURL 提供 OpenURLAction 處理程式碼的方式來實現自定義開啟連結的行為。

swift Text("Visit [Example Company](http://www.example.com) for details.") .environment(\.openURL, OpenURLAction { url in handleURL(url) return .handled })

OpenURLAction 的結構如下:

```swift public struct OpenURLAction { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public init(handler: @escaping (URL) -> OpenURLAction.Result)

public struct Result {
    public static let handled: OpenURLAction.Result  // 當前的程式碼已處理該 URL ,呼叫行為不會再向下傳遞
    public static let discarded: OpenURLAction.Result  // 當前的處理程式碼將丟棄該 URL ,呼叫行為不會再向下傳遞
    public static let systemAction: OpenURLAction.Result  // 當前程式碼不處理,呼叫行為向下傳遞( 如果外層沒有使用者的自定義 OpenURLAction ,則使用系統預設的實現)
    public static func systemAction(_ url: URL) -> OpenURLAction.Result  // 當前程式碼不處理,將新的 URL 向下傳遞( 如果外層沒有使用者的自定義 OpenURLAction ,則使用系統預設的實現)
}

} ```

比如:

swift Text("www.fatbobman.com [email protected] 13900000000".toDetectedAttributedString()) // 建立三個連結 https mailto tel .environment(\.openURL, OpenURLAction { url in switch url.scheme { case "mailto": return .discarded // 郵件將直接丟棄,不處理 default: return .systemAction // 其他型別的 URI 傳遞到下一層(外層) } }) .environment(\.openURL, OpenURLAction { url in switch url.scheme { case "tel": print("call number \(url.absoluteString)") // 列印電話號碼 return .handled // 告知已經處理完畢,將不會繼續傳遞到下一層 default: return .systemAction // 其他型別的 URI 當前程式碼不處理,直接傳遞到下一層 } }) .environment(\.openURL, OpenURLAction { _ in .systemAction(URL(string: "http://www.apple.com")!) // 由於在本層之後我們沒有繼續設定 OpenURLAction , 因此最終會呼叫系統的實現開啟蘋果官網 })

這種通過環境值層層設定的處理方式,給了開發者非常大的自由度。在 SwiftUI 中,採用類似邏輯的還有 onSubmit ,有關 onSubmit 的資訊,請參閱 SwiftUI TextField 進階 —— 事件、焦點、鍵盤

handler 的返回結果 handleddiscarded 都將阻止 url 繼續向下傳遞,它們之間的不同只有在顯式呼叫 openURL 時才會表現出來。

```swift // callAsFunction 的定義 public struct OpenURLAction { public func callAsFunction( url: URL, completion: @escaping ( accepted: Bool) -> Void) }

// handled 時 accepted 為 true , discarded 時 accepted 為 false openURL(url) { accepted in print(accepted ? "Success" : "Failure") } ```

結合上面的介紹,下面的程式碼將實現:在點選連結後,使用者可以選擇是開啟連結還是將連結複製在貼上板上:

```swift struct ContentView: View { @Environment(.openURL) var openURL @State var url:URL? var show:Binding{ Binding(get: { url != nil }, set: {_ in url = nil}) }

let attributedString:AttributedString = {
    var fatbobman = AttributedString("肘子的 Swift 記事本")
    fatbobman.link = URL(string: "http://www.fatbobman.com")!
    fatbobman.font = .title
    var tel = AttributedString("電話號碼")
    tel.link = URL(string:"tel://13900000000")
    tel.backgroundColor = .yellow
    var and = AttributedString(" and ")
    and.foregroundColor = .red
    return fatbobman + and + tel
}()

var body: some View {
    Form {
        Section("NSDataDetector + AttributedString"){
            // 使用 NSDataDetector 進行轉換
            Text("http://www.fatbobman.com 13900000000 [email protected]".toDetectedAttributedString())
        }
    }
    .environment(\.openURL, .init(handler: { url in
        switch url.scheme {
        case "tel","http","https","mailto":
            self.url = url
            return .handled
        default:
            return .systemAction
        }
    }))
    .confirmationDialog("", isPresented: show){
        if let url = url {
            Button("複製到剪貼簿"){
                let str:String
                switch url.scheme {
                case "tel":
                    str = url.absoluteString.replacingOccurrences(of: "tel://", with: "")
                default:
                    str = url.absoluteString
                }
                UIPasteboard.general.string = str
            }
            Button("開啟 URL"){openURL(url)}
        }
    }
    .tint(.cyan)
}

} ```

openURL_Demo_Recording_iPhone_13_mini_2022-05-20_18.00.15.2022-05-20 18_03_18

總結

雖說本文的主要目的是介紹在 SwiftUI 檢視中開啟 URL 的幾種方法,不過讀者應該也能從中感受到 SwiftUI 三年來的不斷進步,相信不久後的 WWDC 2022 會為開發者帶來更多的驚喜。

希望本文能夠對你有所幫助。

原文發表在我的部落格 wwww.fatbobman.com

歡迎訂閱我的公共號:【肘子的Swift記事本】