iOS 17 App 程式設計實戰心法(SwiftUI)

第 28 章
觸覺觸控

作為你的第一個 App,FoodPin App 已經相當令人印象深刻了,但是如果你想要進一步增強它,並納入 iOS 裝置提供的一些現代技術,則千萬不要錯過接下來的內容。

自從 iPhone 6s 與 6s Plus 推出以來,Apple 向我們介紹一種與手機互動的全新方式,稱為「3D 觸控」( 3D Touch ),此功能為使用者介面增加一個新維度,並提供獨特的使用者體驗。透過 3D 觸控,iPhone 不僅可以偵測到你的觸碰( Touch ),還可以感知到你對顯示器施加的壓力力道。

自 iPhone 11、iPhone 11 Pro 與 iPhone 11 Pro Max 開始,Apple 已將所有 iPhone 型號上的 3D 觸控替換為觸覺觸控( Haptic Touch )。儘管觸覺觸控和 3D 觸控有相似之處,但它們之間還是存在明顯的差異,3D 觸控依賴壓力觸控( Force Touch ),而觸覺觸控則是透過「長按」(Touch and hold )手勢來啟動。

你是否嘗試過用更大的壓力按主畫面上的 App 圖示呢?當你這樣做時,它會顯示一組快速動作,讓你直接訪問 App 的特定部分,這是觸覺觸控的實際應用範例,稱為「快速動作」( Quick Action )。

圖 28.1. 快速動作範例
圖 28.1. 快速動作範例

在本章中,我將介紹如何在 SwiftUI 專案中實作快速動作,並建立自訂的 URL 類型來處理這些快速動作,如圖 28.1 所示:

  • New Restaurant - 直接進入New Restaurant畫面。
  • Discover restaurants - 直接跳到Discover標籤。
  • Show Favorites -直接跳到 Favorites標籤。

主畫面的快速動作

Apple 提供兩種類型的快速動作:「靜態」( Static )與「動態」( Dynamic )。靜態快速動作寫死在 Info.plist 檔,當使用者安裝 App 後,即使在首次啟動 App 之前,也可使用快速動作。

顧名思義,動態快速動作本質上是動態的,App 在執行期中建立並更新快速動作。以 Instagram App 為例,其快速動作顯示了切換帳戶的選項,它們必須是動態的,因為帳戶名稱會隨時變更。

圖 28.2. 快速動作小工具
圖 28.2. 快速動作小工具

但是,它們有一個共同點是無論你使用靜態還是動態快速動作,你最多可以建立四個快速動作。

要建立靜態快速動作非常簡單,你只需要編輯 Info.plist 檔,並新增 UIApplicationShortcut Items 陣列。陣列的每個元素都是一個包含下列屬性的字典:

  • UIApplicationShortcutItemType (必填) - 用於識別快速動作的唯一識別碼,這個識別碼在所有的 App 中應是唯一的,因此較好的作法是將識別碼的前綴加上 App Bundle ID (例如:com.appcoda. )。
  • UIApplicationShortcutItemTitle (必填) - 使用者可見的快速動作名稱。
  • UIApplicationShortcutItemSubtitle (optional) - 快速動作的副標題,它是顯示在快速動作標題下方的可選型別字串。
  • UIApplicationShortcutItemIconType (選填) - 用於指定系統庫中圖示類型的可選型別 字串。你可以參考 這份文件 ,來了解可用的圖示類型。
  • UIApplicationShortcutItemIconFile (選填) - 如果你想使用自己的圖示,則從 App Bundle 中指定要使用的圖示圖片,或者在素材目錄中指定圖片名稱,圖示必須要是矩形且單色,尺寸為 35×35(1x)、70×70(2x) 與105×105(3x)。
  • UIApplicationShortcutItemUserInfo (選填) - 包含你想要傳送的一些額外資訊的可選型別字典,例如:這個字典的其中一種用途是傳送 App 版本。

如果你想加入一些靜態快速動作,下列是使用 UIApplicationShortcutItems 陣列來建立「New Restaurant」捷徑的範例,如圖 28.3 所示。

圖 28.3. 靜態快速動作的範例 Info.plist
圖 28.3. 靜態快速動作的範例 Info.plist

現在你應該對靜態快速動作有些概念了,接下來我們來說明動態快速動作。我們將修改 FoodPin 專案來示範,並加入三個快速動作至 App 中:

  • New Restaurant - 直接進入 New Restaurant畫面。
  • Discover restaurants - 直接跳到 Discover標籤。
  • Show Favorites - 直接跳到 Favorites 標籤。

首先,為何我們要使用動態快速動作呢?簡單的答案是「我想要介紹如何使用動態快速動作」,不過實際的原因是「我只想在使用者看完導覽畫面後才啟用這些快速動作」。

要編寫程式碼來建立快速動作時,你只需要使用所需屬性來實例化一個 UIApplication ShortcutItem 物件,然後將它指定給 UIApplication 的 shortcutItems 屬性。以下是一個例子:

let shortcutItem = UIApplicationShortcutItem(type: "com.appcoda.NewRestaurant", localizedTitle: "New Restaurant", localizedSubtitle: nil, icon: UIApplicationShortcutIcon(type: .add), userInfo: nil)
UIApplication.shared.shortcutItems = [shortcutItem]

第一行程式碼定義一個具有快速動作類型 com.appcoda.NewRestaurant 和系統圖示 .add 的捷徑項目,快速動作的標題設定為「New Restaurant」;第二行程式碼使用 shortcutItem 初始化一個陣列,並將其設定為 shortcutItems 屬性。

要建立快速動作,我們開啟 FoodPinApp.swift,並在 FoodPinApp 結構中建立一個輔助方法:

func createQuickActions() {
    if let bundleIdentifier = Bundle.main.bundleIdentifier {
        let shortcutItem1 = UIApplicationShortcutItem(type: "\(bundleIdentifier).OpenFavorites", localizedTitle: "Show Favorites", localizedSubtitle: nil, icon: UIApplicationShortcutIcon(systemImageName: "tag"), userInfo: nil)
        let shortcutItem2 = UIApplicationShortcutItem(type: "\(bundleIdentifier).OpenDiscover", localizedTitle: "Discover Restaurants", localizedSubtitle: nil, icon: UIApplicationShortcutIcon(systemImageName: "eyes"), userInfo: nil)
        let shortcutItem3 = UIApplicationShortcutItem(type: "\(bundleIdentifier).NewRestaurant", localizedTitle: "New Restaurant", localizedSubtitle: nil, icon: UIApplicationShortcutIcon(type: .add), userInfo: nil)
        UIApplication.shared.shortcutItems = [shortcutItem1, shortcutItem2, shortcutItem3]
    }
}

程式碼和前面的範例幾乎一樣。我們建立了三個快速動作項目,每個項目都有自己的識別碼、標題與圖示。

那麼,我們應該在何處呼叫這個 createQuickActions() 方法呢?

根據 Apple 的說法,當 App 轉換至背景狀態時,是更新任何動態快速動作的最好時機, 因為系統會在使用者返回主畫面之前執行程式碼。

下一個問題是我們如何偵測這種狀態變化呢? 在 SwiftUI 中, 你可以透過觀察 Environment 中的 scenePhase 值來讀取目前的場景階段。在 FoodPinApp 中插入下列程式碼來取得 scenePhase 值:

@Environment(\.scenePhase) var scenePhase

你可以將 .onChange 修飾器加到 WindowGroup 來監看場景階段的變化,如下所示:

WindowGroup {
    MainView()
}
.modelContainer(for: Restaurant.self)
.onChange(of: scenePhase) { oldValue, newValue in
    switch newValue {
    case .active:
        print("Active")
    case .inactive:
        print("Inactive")
    case .background:
        createQuickActions()
    @unknown default:
        print("Default scene phase")
    }
}

上列的程式碼監聽 scenePhase 狀態的變化。當狀態變更為 background 階段時,我們執行createQuickActions 方法來更新快速動作。

現在你可以在模擬器中建立與執行 App。App 啟動後返回主畫面,在 FoodPin 圖示上長按1 秒鐘,然後你應該會看到快速動作選單,如圖 28.4 所示。

圖 28.4. FoodPin App 中的快速動作
圖 28.4. FoodPin App 中的快速動作

使用自訂 URL 協定處理快速動作

快速動作還沒有準備好運作,因為我們還沒有實作啟動快速動作所需的方法。在 UIKit 中,有一個定義在UIWindowSceneDelegate 協定的方法,稱為「windowScene(_:performActionFor:completionHandler:)」,當使用者選擇快速動作時,此方法會被呼叫,我們將在 FoodPinApp.swift 中實作該方法。

Note: 目前版本的 SwiftUI不提供處理快速動作的原生方法,這便是為何我們仍然需要回復改用 UIKit。 .

那麼,當選擇快速動作時,我們如何導引使用者到 App 的特定部分呢?我們將使用自訂 URL 協定,這個功能提供一種參照 App 中特定內容或資源的方式,例如:我們定義了以下的自訂 URL:

foodpinapp://actions/OpenDiscover

當使用者點擊 URL 時,系統導引使用者至 FoodPin App 的「Discover」標籤。而我們要做的是建立一個名為「foodpinapp://」的自訂 URL 協定,每個快速動作都有自己的自訂 URL 來導引使用者至 App 的特定畫面:

  • foodpinapp://actions/OpenFavorites - 開啟「Favorites」標籤。
  • foodpinapp://actions/OpenDiscover - 開啟「Discover」標籤。
  • foodpinapp://actions/NewRestaurant - 顯示「New Restaurant」視圖。

在 FoodPinApp.swift 中插入下列的程式碼片段:

final class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {

    @Environment(\.openURL) private var openURL: OpenURLAction

    func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
        completionHandler(handleQuickAction(shortcutItem: shortcutItem))
    }

    private func handleQuickAction(shortcutItem: UIApplicationShortcutItem) -> Bool {
        let shortcutType = shortcutItem.type

        guard let shortcutIdentifier = shortcutType.components(separatedBy: ".").last else {
            return false
        }

        guard let url = URL(string: "foodpinapp://actions/" + shortcutIdentifier) else {
            print("Failed to initiate the url")
            return false
        }

        openURL(url)

        return true
    }
}

上列的程式碼建立了一個MainSceneDelegate 類別,並實作 windowScene(_:performActi onFor:completionHandler:) 方法來啟動快速動作。handleQuickActions 方法將捷徑項目作為參數。

它首先解析其 type 值(例如:com.appcoda.foodpin.OpenFavorites)來分析這個捷徑項目是什麼,根據提取的值來建構自訂URL(例如:foodpinapp://actions/OpenFavorites )。

要在 SwiftUI 中開啟 URL,你可以從環境中取得 .openURL 鍵並開啟 URL,如下所示:

openURL(url)

接下來,我們需要建立一個 AppDelegate 類別來設定 MainSceneDelegate 為委派類別。在同一個檔案中插入下列的程式碼:

final class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        let configuration = UISceneConfiguration(name: "Main Scene", sessionRole: connectingSceneSession.role)

        configuration.delegateClass = MainSceneDelegate.self

        return configuration
    }
}

儘管我們已經建立了 AppDelegate 類別並採用了所有必需的協定,但是 SwiftUI 中的 FoodPinApp 結構對於我們所實作的所有類別仍然一無所知,我們需要讓它知道使用 UIKit 中的 AppDelegate 類別。你可以在 FoodPinApp 結構中插入下列這行程式碼,來註冊 AppDelegate 類別:

@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate

現在你應該了解我們如何處理快速動作,不過這個快速動作還沒有完成,如果你嘗試開啟任何一個快速動作,你應該會在主控台中看到以下的錯誤訊息:

2023-10-26 12:39:31.547378+0800 FoodPin[36298:10370760] [default] Failed to open URL foodpinapp://actions/OpenDiscover: Error Domain=NSOSStatusErrorDomain Code=-10814 "(null)" UserInfo={_LSLine=229, _LSFunction=-[_LSDOpenClient openURL:options:completionHandler:]}

原因是我們在專案中註冊了自訂協定,如果沒有註冊,系統將不會重新導引 URL 到你的 App。

切換到專案設定,並開啟「Info」標籤。在「URL Types」區塊中,點選「+」按鈕來加入新的URL 類型,接著輸入你的識別碼,並設定「URL Schemes」選項為「foodpinapp」, 如圖 28.5 所示。

圖 28.5. 加入新的 URL 類型
圖 28.5. 加入新的 URL 類型

我們完成了嗎?我們建立了 URL 協定,App 會在選擇快速動作時開啟 URL,但還有一件事未完成,即 App 仍然不知道如何回應這個 URL。

現在切換至 MainView.swift,將 onOpenURL 修飾器加到 TabView:

.onOpenURL(perform: { url in

    switch url.path {
    case "/OpenFavorites": selectedTabIndex = 0
    case "/OpenDiscover": selectedTabIndex = 1
    case "/NewRestaurant": selectedTabIndex = 0
    default: return
    }
})

當視圖收到 URL 時,會呼叫 .onOpenURL 函數,然後我們檢查 URL 路徑,並相應更新 selectedTabIndex 的值。

對於 NewRestaurant 動作, 我們需要加入一個特別處理。開啟 RestaurantListView. swift,並將另一個 onOpenURL 修飾器加到 NavigationStack:

.onOpenURL(perform: { url in            
    switch url.path {
    case "/NewRestaurant": showNewRestaurant = true
    default: return
    }
})

當偵側到 NewRestaurant 動作時,我們設定 showNewRestaurant 變數為「true」。

這個 App 現在已經準備好進行測試了。在模擬器中執行它,並試著在主畫面中開啟快速動作,App 應該會依據你選擇的快速動作來導引你到相應的畫面。

如果 App 沒有執行怎麼辦?

我猜你的所有測試都是在 App 執行時完成的,你是否嘗試過關閉 App 並再次啟動快速動作呢?如果 App 沒有執行,則快速動作就無法運作。windowScene(_:performActionFor: completionHandler:) 方法只有在 App 處於執行狀態時,才會被呼叫。

如果 App 沒有執行,系統會呼叫下列的委派方法:

scene(_:willConnectTo:options:)

要修正此問題,則開啟 FoodPinApp.swift,並在 MainSceneDelegate 類別實作該方法:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let shortcutItem = connectionOptions.shortcutItem else {
        return
    }

    handleQuickAction(shortcutItem: shortcutItem)
}

你可以再次測試 App,即使 App 沒有執行,快速動作還是能夠運作。

本章小結

在本章中,我介紹了一些在 SwiftUI 專案中實作快速動作的基本 API。快速動作可作為 App 的捷徑,讓使用者方便訪問特定功能。作為一個 App 開發者,優先考量提供良好的使用者體驗是很重要的,當你要開發自己的 App 時,可考慮結合快速動作來提升整體的使用者體驗,並讓使用者能夠快速且容易地訪問主要功能。

在本章所準備的範例檔中,有最後完整的 Xcode 專案可供你參考 http://www.appcoda.com/resources/swift59/swiftui-foodpin-haptictouch.zip