精通 SwiftUI - iOS 17 版

第 12 章
實作強制回應視圖、浮動按鈕與警告提示視窗

在前一章中,我們建立了一個導覽介面,讓使用者從內容視圖導覽至細節視圖。視圖轉場動畫很精巧,並且完全由iOS 負責。當使用者觸發轉場時,細節視圖會流暢地從右至左滑動。導覽 UI 只是常用的 UI 模式之一,在本章中,我將向你介紹另一個強制顯示內容的設計技巧。

對於 iPhone 的使用者,你應該非常熟悉強制回應視圖了。強製回應視圖的一種常見用途是顯示輸入表單,例如:行事曆 App 為使用者顯示一個強制回應視圖來建立一個新事件。系統內建的提醒事項與聯絡人 App 也使用強制回應視圖來要求使用者輸入。

圖 12.1. 行事曆、提醒事項與聯絡人 App 的強制回應視圖範例
圖 12.1. 行事曆、提醒事項與聯絡人 App 的強制回應視圖範例

從使用者體驗的角度來看,這個強制回應視圖通常是透過點擊按鈕來觸發。同樣的, 強制回應視圖的轉場動畫是由 iOS 所處理。當顯示全螢幕的強制回應視圖時,它會流暢地從畫面底部向上滑動。

如果你是 iOS 的長期使用者,你可能會發現如圖 12.1 所示的強制回應視圖的外觀及感覺和平常不太一樣。在 iOS 13 之前,顯示強制回應視圖時會覆蓋整個畫面,自 iOS 13 起,強制回應視圖預設是以卡片式的形式顯示,其不會覆蓋全畫面,而是部分覆蓋了底層內容視圖,你仍然可看到內容 / 父視圖的頂部邊緣。除了視覺變動之外,現在可從畫面的任意位置向下滑動來解除強制回應視圖。你不需要撰寫任何一行程式碼,即可啟動這個手勢。它完全是內建且由 iOS 產生。當然,若是你想透過按鈕來解除強制回應視圖,則依然可以這樣做。

那麼,我們將在本章中要實作什麼呢?

我教你如何使用強制回應視圖顯示和在前一章中我們實作過的相同細節視圖,雖然強制回應視圖通常用於顯示表單,這並不表示你不能使用它們來顯示其他資訊。除了強制回應視圖之外,你還將學習如何在細節視圖中建立浮動按鈕。雖然可透過滑動手勢來解除強制回應視圖,但我想提供一個「Close」按鈕來供使用者解除細節視圖。另外,我們也將研究警告提示視窗(Alert ),這是另一種強制回應視圖。

圖 12.2. 使用強制回應視圖來顯示細節畫面
圖 12.2. 使用強制回應視圖來顯示細節畫面

我們在本章中有許多要討論的內容。讓我們開始吧 !

了解 SwiftUI 的工作表

工作表(sheet )的表現風格看起來為一張卡片,其部分覆蓋了底層內容,並使所有未覆蓋到的地方變暗,以防止與其互動。在目前卡片的後面可看見父視圖或上一張卡片的頂部邊緣,以幫助人們記住他們開啟卡片時暫停的任務

- Apple 的官方文件(https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/modality/)

在深入鑽研如何實作之前,讓我先簡要介紹一下強制回應視圖的卡片式外觀。在 SwiftUI 中,卡片外觀是使用工作表的表現風格來實現,這是強制回應視圖的預設表示風格。

基本上,要顯示強制回應視圖,你可應用 sheet 修飾器,如下所示:

.sheet(isPresented: $showModal) {
    DetailView()
}

它採用布林值來指示是否顯示強制回應視圖,如果 isPresented 設定為 true,則強制回應視圖將自動以卡片形式顯示。

顯示強制回應視圖的另一種方式,可以寫成如下程式碼:

.sheet(item: $itemToDisplay) {
    DetailView()
}

sheet 修飾器也讓你可透過傳送一個 Optional 綁定來觸發強制回應視圖的顯示。如果 Optional有一個值,iOS 會帶出強制回應視圖,如果你還記得我們在前一章中對於 actionSheet 的討論,你應該會發現 sheet 的用法與 actionSheet 非常相似。

準備起始專案

以上是背景資訊,我們來繼續實際執行範例專案。首先,,請至 https://www.appcoda.com/resources/swiftui5/SwiftUIModalStarter.zip。 下載起始專案,下載後開啟專案,並看一下預覽,如圖 12.3 所示。你應該非常熟悉範例 App 了,該 App 仍有一個導覽列,但導覽連結已被刪除。

圖 12.3. 起始專案
圖 12.3. 起始專案

使用 isPresented 實作強制回應視圖

如前所述,sheet 修飾器提供我們兩種顯示模式的方式。我將展示這兩種方法如何工作,我們從 isPresented 方法開始,對於這個方法,我們需要一個Bool 型別的狀態變數來追蹤強制回應視圖的狀態。在 ContentView中宣告這個變數:

@State var showDetailView = false

預設情況下,它設定為 false。當點擊其中一列時,該變數的值將會設定為 true。稍後, 我們會在程式碼中做這個變更。

當顯示細節視圖時,該視圖需要我們傳送所選的文章,因此我們也需要宣告一個狀態來儲存使用者的選擇。在ContentView 中,為此宣告另一個狀態變數如下:

@State var selectedArticle: Article?

為了實作強制回應視圖,我們將 sheet 修飾器加到 List 上,如下所示:

NavigationStack {
    List(articles) { article in
        ArticleRow(article: article)

        .listRowSeparator(.hidden)
    }
    .listStyle(.plain)
    .sheet(isPresented: $showDetailView) {

        if let selectedArticle = self.selectedArticle {
            ArticleDetailView(article: selectedArticle)
        }
    }

    .navigationTitle("Your Reading")
}

強制回應視圖的顯示取決於 showDetailView 屬性的值,這就是為何我們在 isPresented 參數中指定它的原因。該 sheet 修飾器的閉包宣告要顯示的視圖佈局。這裡我們將顯示 ArticleDetailView

最後一個問題是我們如何偵測觸控呢?當建立導覽UI 時,我們利用 NavigationLink 來處理觸控, 然而此特殊按鈕是為導覽介面所設計。在 SwiftUI 中, 有一個名為 onTapGesture 的處理器,可以用來識別觸控手勢,因此你可以將此處理器加到每個 ArticleRow 來偵測使用者的觸控。現在修改 body 變數中的 NavigationStack,如下所示:

NavigationStack {
    List(articles) { article in
        ArticleRow(article: article)
            .onTapGesture {
                self.showDetailView = true
                self.selectedArticle = article
            }

        .listRowSeparator(.hidden)
    }
    .listStyle(.plain)
    .sheet(isPresented: $showDetailView) {

        if let selectedArticle = self.selectedArticle {
            ArticleDetailView(article: selectedArticle)
        }
    }

    .navigationTitle("Your Reading")
}

onTapGesture 的閉包中,我們將 showDetailView 設定為 true,這是用於觸發強制回應視圖的顯示。我們也將所選的文章儲存在 selectedArticle 變數中。

現在於預覽畫布上執行這個 App。你應該能夠以強制回應模式帶出細節視圖,如圖 12.4 所示。

注意:當你第一次打開模態視圖時,它顯示的是一個空白視圖。 擦拭對話框以關閉它,然後選擇另一篇文章(不是同一篇文章),App應該會顯示正確的文章。 這是一個已知問題,我們將在後面的部分討論如何改善。

圖 12.4. 以強制回應模式來顯示細節視圖
圖 12.4. 以強制回應模式來顯示細節視圖

使用 Optional 綁定實作強制回應視圖

sheet 修飾器還提供另一種顯示強制回應視圖的方式。這裡不使用布林值來控制強制回應視圖的外觀,這個修飾器讓你使用一個 Optional 綁定來實現相同的目標。

你可以將 sheet 修飾器替換為下列程式碼:

.sheet(item: $selectedArticle) { article in
    ArticleDetailView(article: article)
}

在這種情況下,sheet 修飾器需要你傳送一個 Optional 綁定。這裡我們指定為 selectedArticle 綁定,這表示只有當所選的文章有值,iOS 才會帶出強制回應視圖。閉包中的程式碼指定強制回應視圖的外觀,但它和我們之前所撰寫的有些不同。

對於這個方法,sheet 修飾器將閉包中所選的文章傳送給我們。article 參數包含了所選的文章,且該文章確保有一個值,這就是為何我們可以使用它來初始化 ArticleDetailView

由於我們不再使用 showDetailView 變數,因此你可以刪除下列這行程式碼:

@State var showDetailView = false

另外,從 .onTapGesture 閉包中刪除 self.showDetailView = true:

.onTapGesture {
    self.showDetailView = true
    ...
}

更改程式碼,你可以再次測試這個App。運作一如往常,但底層比原來更簡潔。

建立浮動按鈕來解除強制回應視圖

強制回應視圖具有向下滑動手勢的內建支援。現在,你可以向下滑動視圖來關閉它,我想這對於 iPhone 長期使用者而言很自然,因為如 Facebook 之類的 App 已使用這個手勢來解除視圖,但是新的使用者可能對此一無所知,我們最好開發一個「關閉」按鈕作為解除強制回應視圖的替代方式。

圖 12.5. 浮動按鈕
圖 12.5. 浮動按鈕

現在切換到 ArticleDetailView.swift, 我們將「關閉」按鈕加入至視圖中,如圖 12.5 所示。

你知道如何將按鈕放在右上角嗎?試著不直接遵循我的程式碼,而是提出自己的實作。

好的,回到實作部分。

NavigationStack 類似,我們可以使用 dismiss 環境值來解除模式。因此,首先在ArticleDetailView 中宣告下列的變數:

@Environment(\.dismiss) var dismiss

對於「關閉」按鈕,我們可以將 overlay 修飾器加到滾動視圖上(在ignoresSafeArea之前添加),如下所示:

.overlay(

    HStack {
        Spacer()

        VStack {
            Button {
                dismiss()
            } label: {
                Image(systemName: "chevron.down.circle.fill")
                    .font(.largeTitle)
                    .foregroundStyle(.white)
            }

            .padding(.trailing, 20)
            .padding(.top, 40)

            Spacer()
        }
    }
)

如此,按鈕將會覆蓋在滾動視圖上方,以浮動按鈕的形式顯示。即使你向下滾動視圖,按鈕也會停留在相同的位置。要將按鈕放在右上角,這裡我們使用 HStackVStack, 然後加上 Spacer 作為輔助。要解除視圖, 你可以呼叫 dismiss() 函數。

圖 12.9. 實作「關閉」按鈕
圖 12.9. 實作「關閉」按鈕

現在於模擬器中實作 App,或切換至 ContentView,並在畫布中執行。你應該能點選「關閉」按鈕來解除強制回應視圖。

使用警告提示視窗

除了卡片式的強制回應視圖,「警告提示視窗」( Alert )是另一種強制回應視圖,當它顯示時,整個畫面會被鎖住,如果你不選擇其中一個選項,將會無法離開。圖 12.7 為一個警告提示視窗的範例,這是我們將在範例專案中實作的內容,而我們所要做的是,當使用者點擊「關閉」按鈕後,顯示一個警告提示視窗。

圖 12.7 顯示一個警告提示視窗
圖 12.7 顯示一個警告提示視窗

在 SwiftUI 中,你可以使用 Alert 結構來建立一個警告提示視窗,下列是 Alert 的範例用法:

.alert("Warning", isPresented: $showAlert, actions: {
    Button {
        dismiss()
    } label: {
        Text("Confirm")
    }

    Button(role: .cancel, action: {}) {
        Text("Cancel")
    }
}, message: {
    Text("Are you sure you want to leave?")
})

範例程式碼初始化一個標題為「警告」的警告提示視圖,警告提示視窗還向使用者顯示「你確定要離開嗎」。在警告提示視圖中有兩個按鈕:「確認」(Confirm )與「取消」(Cancel )。

要建立如圖 12.7 的警告提示視窗,程式碼如下所示:

.alert("Reminder", isPresented: $showAlert, actions: {
    Button {
        dismiss()
    } label: {
        Text("Yes")
    }

    Button(role: .cancel, action: {}) {
        Text("No")
    }

}, message: {
    Text("Are you sure you are finished reading the article?")
})

除了主按鈕具有 action 參數之外,它和先前的程式碼類似。這個警告提示視窗詢問使用者是否已閱讀完文章,若是使用者選擇「是」( Yes ),它會繼續關掉強制回應視圖,否則強制回應視圖將保持開啟。

現在,我們已有了建立警告提示視窗的程式碼,問題是如何觸發警告提示視窗的顯示呢? SwiftUI 提供一個可加到任何視圖的 alert 修飾器。同樣的,你使用一個布林變數來控制警告提示視窗的顯示,因此在 ArticleDetailView中宣告一個狀態變數:

@State private var showAlert = false

接下來,將以上的 alert 修飾器加到 ScrollView 上。

還剩下一件事,我們應該何時觸發警告提示視窗呢?換句話說,我們何時要將 showAlert 設定為 true

顯然的,當某人點擊「關閉」按鈕時,App 應該顯示警告提示視窗。因此,替換按鈕動作的程式碼如下:

Button {
    self.showAlert = true
} label: {
    Image(systemName: "chevron.down.circle.fill")
        .font(.largeTitle)
        .foregroundColor(.white)
}

我們沒有直接解除強制回應視圖,而是透過將 showAlert 設定為 true,來指示 iOS 顯示警告提示視窗。現在你可以測試 App 了,當你點擊「關閉」按鈕時,你將看到警告提示視窗,如圖12.10 所示。若是你選擇「是」(Yes ),強制回應視圖將解除。

圖 12.8. 點擊「關閉」按鈕將顯示警告提示視窗
圖 12.8. 點擊「關閉」按鈕將顯示警告提示視窗

全螢幕強制回應視圖的呈現

自從 iOS 13 開始,強制回應視圖預設是不使用全螢幕的覆蓋形式。如果想要以全螢幕來呈現強制回應視圖的話,你可以使用 iOS 14 所導入的 .fullScreenCover 修飾器,替代 .sheet 修飾器來呈現強制回應視圖, .fullScreenCover 修飾器的用法如下:

.fullScreenCover(item: $selectedArticle) { article in
    ArticleDetailView(article: article)
}

本章小結

你已經學習了如何顯示強制回應視圖、實作浮動按鈕以及顯示警告提示視窗。iOS 持續鼓勵使用者利用手勢來與裝置互動,並為常見手勢提供內建支援。不需要撰寫一行程式碼,即可讓使用者在畫面上向下滑動,以解除強制回應視圖。

強制回應視圖與警告提示視窗的 API 設計非常相似,它監控狀態變數,以確認是否觸發強制回應視圖(或警告提示視窗)。一旦你了解這個技術後,實作對你而言應該不困難了。

在本章所準備的範例檔中,有完整的專案可供下載: