精通 SwiftUI - iOS 17 版

第 18 章
利用 Presentation Detents 建立展開式底部表

最近「底部表」(bottom sheet )越來越受歡迎,你可以在 Facebook 與 Uber 等知名 App 中找到這個功能,它更像是動作表(action sheet )的加強版,底部表會從螢幕底部向上滑動,並覆蓋在原始視圖的上面,來提供上下文訊息(contextual information )或者可供使用者選擇的其他選項。舉例而言,當你將照片儲存在 Instagram 的照片集中,該 App 會顯示一個底部表,以選擇照片集。而在 Facebook App 中,當點選貼文的「⋯」(ellipsis )按鈕,它會顯示帶有其他的動作項目表。Uber App 也使用底部表來顯示所選擇的行程價格。

底部表的大小取決於想要顯示的上下文訊息。在某些情況下,底部表往往較大(也稱為「背幕」(backdrop )),占據了螢幕的 80~90%。通常,使用者可以使用拖曳手勢與表互動。你可以向上滑動來展開它,或者向下滑動來最小化或解除表。

圖 18.1. Uber、Facebook 與Instagram 皆於App 中使用底部表
圖 18.1. Uber、Facebook 與Instagram 皆於App 中使用底部表

在本章中,我們將使用 SwiftUI 手勢建立類似的展開式底部表(expandable bottom sheet )。範例 App 在主視圖中顯示餐廳清單,當使用者點擊其中一筆餐廳紀錄時,該 App 會帶出一個底部表來顯示餐廳的詳細資訊。你可以向上滑動來展開表,而要解除表則是向下滑動,如圖 18.2 所示

圖 18.2. 建立一個展開式底部表
圖 18.2. 建立一個展開式底部表

Presentation Detents 介紹

在 iOS 15 中,Apple 引入了 UISheetPresentationController 類,用於在 iOS 應用程式中顯示可擴展的底部工作表。 然而,這個功能最初僅限於 UIKit 框架。 在 SwiftUI 中,您必須建立自己的元件或依賴第三方程式庫來實作底部表單。

然而,從 iOS 16 開始,SwiftUI 包含一個名為 presentationDetents 的內建修飾器,允許呈現可調整大小的底部表單。

若要使用 SwiftUI 呈現底部工作表,您可以使用 sheet 視圖並套用 presentationDetents 修飾器。 以下是如何使用它的範例:

struct BasicBottomSheet: View {
    @State private var showSheet = false

    var body: some View {
        VStack {
            Button("Show Bottom Sheet") {
                showSheet.toggle()
            }
            .buttonStyle(.borderedProminent)
            .sheet(isPresented: $showSheet) {
                Text("This is the expandable bottom sheet.")
                    .presentationDetents([.medium, .large])
            }

            Spacer()
        }
    }
}

您可以在 presentationDetents 修飾器中指定一組「detents」。 在提供的範例中,底部片材支援中號(.medium) 和大號(.large)尺寸。 最初呈現時,底頁以中等尺寸顯示。 但是,您可以透過向上拖曳工作表將其擴展到大尺寸。

圖 18.3. 底部表樣版
圖 18.3. 底部表樣版

了解起始專案

為了節省你從頭建立範例App 的時間,我已經為你準備好起始專案。你可以至 https://www.appcoda.com/resources/swiftui5/SwiftUIBottomSheetStarter.zip 下載起始專案,並解壓縮檔案,再開啟 SwiftUIBottomSheet.xcodeproj 來開始。

起始專案已經提供一組餐廳圖片及餐廳資料。如果你在專案導覽器中查看「Model」資料夾,則應該會找到一個Restaurant.swift 檔,此檔案包含了 Restaurant 結構與一組範例餐廳資料。

struct Restaurant: Identifiable {
    var id: UUID = UUID()
    var name: String
    var type: String
    var location: String
    var phone: String
    var description: String
    var image: String
    var isVisited: Bool

    init(name: String, type: String, location: String, phone: String, description: String, image: String, isVisited: Bool) {
        self.name = name
        self.type = type
        self.location = location
        self.phone = phone
        self.description = description
        self.image = image
        self.isVisited = isVisited
    }

    init() {
        self.init(name: "", type: "", location: "", phone: "", description: "", image: "", isVisited: false)
    }
}

此外,我已經為你建立了主視圖,它顯示了一個餐廳清單。你可以開啟 ContentView.swift 檔來看一下程式碼,我將不會詳細解釋程式碼,因為我們已經在第 10 章實作過清單。

圖 18.4. 清單視圖
圖 18.4. 清單視圖

建立餐廳細節視圖

底部表實際上包含餐廳細節與一個小橫列(handlebar ),因此我們要做的第一件事是建立如圖 18.5 所示的餐廳細節視圖。

圖 18.5. 具有小橫列的餐廳細節視圖
圖 18.5. 具有小橫列的餐廳細節視圖

在隨著我實作視圖之前,我建議你把它當作練習,並自行建立細節視圖。如您所見, 細節視圖是由 UI 元件所組成,包括「圖片」(Image )、「文字」(Text )與「滾動視圖」(ScrollView )。我們已經介紹過這些元件,所以你可以嘗試自行實作細節視圖。

好的,讓我教你如何建立細節視圖。如果你已經自行建立了細節視圖,你可以參考我的實作。

細節視圖的佈局有點複雜,因此最好將其分成幾個部分,以更容易實作:

  • 「橫列」是一個小圓角矩形。
  • 標題列包含細節視圖的標題。
  • 頭部視圖包含餐廳特色圖片、餐廳名稱與餐廳類型。
  • 細節資訊視圖包含餐廳資料,其中有地址、電話與描述。

我們將使用單獨的結構(struct )來實作上述每個部分,以讓程式碼更易編寫。現在使用「SwiftUI View」模板建立一個新檔案,並命名為RestaurantDetailView.swift。下面討論的所有程式碼都將放到這個新檔案中。

橫列

首先是「橫列」(handlebar ),它實際上是一個小圓角矩形。要建立它,我們需要做的是建立一個 Rectangle,並使其變成圓角。 在 RestaurantDetailView.swift 檔中插入下列的程式碼:

struct HandleBar: View {

    var body: some View {
        Rectangle()
            .frame(width: 50, height: 5)
            .foregroundStyle(Color(.systemGray5))
            .cornerRadius(10)
    }
}

標題列

接著是「標題列」(title bar ),實作很簡單,因為它只是一個文字視圖。我們為它建立另一個結構:

struct TitleBar: View {

    var body: some View {
        HStack {
            Text("Restaurant Details")
                .font(.headline)
                .foregroundStyle(.primary)

            Spacer()
        }
        .padding()
    }
}

這裡的「留白」(Spacer)是用來將文字靠左對齊。

頭部視圖

頭部視圖(header view )由一個圖片視圖與兩個文字視圖所組成。這兩個文字視圖會疊在圖片視圖上面。同樣的,我們將使用單獨的結構來實作頭部視圖。在 RestaurantDetailView.swift 中, 加入以下程式碼:

struct HeaderView: View {
    let restaurant: Restaurant

    var body: some View {
        Image(restaurant.image)
            .resizable()
            .scaledToFill()
            .frame(height: 300)
            .clipped()
            .overlay(
                HStack {
                    VStack(alignment: .leading) {
                        Spacer()
                        Text(restaurant.name)
                            .foregroundStyle(.white)
                            .font(.system(.largeTitle, design: .rounded))
                            .bold()

                        Text(restaurant.type)
                            .font(.system(.headline, design: .rounded))
                            .padding(5)
                            .foregroundStyle(.white)
                            .background(Color.red)
                            .cornerRadius(5)

                    }
                    Spacer()
                }
                .padding()
            )
    }
}

由於我們需要顯示餐廳資料,因此 HeaderView 具有 restaurant 屬性。對於這個佈局,我們建立一個圖片視圖,並設定它的內容模式為 scaleToFill,圖片高度固定為「300 點」。由於我們使用 scaleToFill 模式,我們需要加上 .clipped()修飾器,以隱藏超出圖片框邊緣的任何內容。

對於這兩個標籤,我們使用 .overlay 修飾器來疊加兩個文字視圖。

細節資訊視圖

最後是資訊視圖(information view )。如果你仔細看一下地址、電話與描述欄位,你會發現它們非常相似。地址與電話欄位在文字資訊旁都有一個圖示,而描述欄位則只包含文字。

因此,建立一個能靈活處理兩種欄位類型的視圖不是很好嗎?下列是程式碼片段:

struct DetailInfoView: View {
    let icon: String?
    let info: String

    var body: some View  {
        HStack {
            if icon != nil {
                Image(systemName: icon!)
                    .padding(.trailing, 10)
            }
            Text(info)
                .font(.system(.body, design: .rounded))

            Spacer()
        }.padding(.horizontal)
    }
}

DetailInfoView有兩個參數:iconinfoicon 參數是可選用的,表示它可以有值或是 nil。

當你需要顯示資料欄位及圖示時,你可以像這樣使用 DetailInfoView

DetailInfoView(icon: "map", info: self.restaurant.location)

另外,如果你只需要顯示一個文字欄位(如描述欄位),則可以像這樣使用 DetailInfoView :

DetailInfoView(icon: nil, info: self.restaurant.description)

如你所見,透過建立一個通用視圖來處理相似的佈局,可以使程式碼更具模組化及可重用性。

使用 VStack 組合元件

現在,我們已經建立了所有的元件,我們可以使用 VStack 組合它們,如下所示:

struct RestaurantDetailView: View {
    let restaurant: Restaurant

    var body: some View {
        VStack {
            Spacer()

            HandleBar()

            TitleBar()

            HeaderView(restaurant: self.restaurant)

            DetailInfoView(icon: "map", info: self.restaurant.location)
                .padding(.top)
            DetailInfoView(icon: "phone", info: self.restaurant.phone)
            DetailInfoView(icon: nil, info: self.restaurant.description)
                .padding(.top)
        }
        .background(.white)
        .cornerRadius(10, antialiased: true)
    }
}

上列的程式碼很容易理解,我們只使用前面章節中建立過的元件,並將它們嵌入到一個垂直堆疊中。原本 VStack 有一個透明背景,要確保細節視圖具有白色背景,我們加入 background修飾器並進行更改。

在測試細節視圖之前,必須修改 #Preview 的程式碼如下:

#Preview {
    RestaurantDetailView(restaurant: restaurants[0])
}

在程式碼中,我們傳送一個範例餐廳(即 restaurants[0] )進行測試。如果你正確實作, 則 Xcode 應該會在預覽畫布中顯示相似的細節視圖,如圖 18.6 所示。.

圖 18.6. 餐廳細節視圖
圖 18.6. 餐廳細節視圖

使視圖可滾動

你是否注意到細節視圖無法顯示完整的內容呢?要解決這個問題,我們必須將內容嵌入在 ScrollView,以讓細節視圖可滾動,如下所示:

struct RestaurantDetailView: View {
    let restaurant: Restaurant

    var body: some View {
        VStack {
            Spacer()

            HandleBar()

            ScrollView(.vertical) {
                TitleBar()

                HeaderView(restaurant: self.restaurant)

                DetailInfoView(icon: "map", info: self.restaurant.location)
                    .padding(.top)
                DetailInfoView(icon: "phone", info: self.restaurant.phone)
                DetailInfoView(icon: nil, info: self.restaurant.description)
                    .padding(.top);
            }
            .background(.white)
            .cornerRadius(10, antialiased: true)
        }
    }
}

除了橫列之外,其他視圖被包裹在滾動視圖中。如果你再次在預覽畫布中執行App, 細節視圖現在可滾動了。

帶出細節視圖

現在,細節視圖幾乎快完成了。我們回到清單視圖(即 ContentView.swift ),以在使用者選擇餐廳時,帶出細節視圖。

ContentView 結構中,宣告一個名為 selectedRestaurant 的狀態變數來儲存使用者選擇的餐廳。 此變數的類型為 Restaurant?,表示非必要有值:

@State private var selectedRestaurant: Restaurant?

如前面章節所學到的,你可以加上 onTapGesture 修飾器來偵測點擊手勢。因此,當識別到點擊,我們可以切換showDetail的值,並更新 selectedRestaurant 的值如下:

List {
    ForEach(restaurants) { restaurant in
        BasicImageRow(restaurant: restaurant)
            .onTapGesture {
                self.selectedRestaurant = restaurant
            }
    }
}

這個細節視圖基本上就是底部表,我們需要把它覆蓋在清單檢視的頂部。 以下程式碼先檢查詳細視圖是否已啟用並相應地對其進行初始化:

NavigationStack {
    .
    .
    .
}
.sheet(item: $selectedRestaurant) { restaurant in
    RestaurantDetailView(restaurant: restaurant)
        .ignoresSafeArea()
        .presentationDetents([.medium, .large])
}

我們將 .sheet 修飾器附加到 NavigationStack。 在閉包中,我們建立一個 RestaurantDetailView 實例,並使用 .presentationDetents 修飾器將其呈現為底部表。 這意味著當使用者選擇餐廳時,細節視圖將顯示為覆蓋目前視圖的底部表單。

圖 18.7. 顯示細節視圖
圖 18.7. 顯示細節視圖

由於 presentation detents 支援中號和大號,因此您可以向上拖曳底部表以將其展開。

隱藏拖曳指示器

presentationDetents 修飾器會在底部表的頂部邊緣附近自動產生拖曳指示器。 由於我們的細節視圖已經有了手把欄,因此我們可以隱藏預設指示器。 為此,請附加 presentationDragIndicator 修飾器並將其設為 .hidden

RestaurantDetailView(restaurant: restaurant)
        .ignoresSafeArea()
    .presentationDetents([.medium, .large])
    .presentationDragIndicator(.hidden)

使用分數和高度控制其大小

除了 .medium 等預設「detent」之外,您還可以使用 .height.fraction 來建立自訂 detent。 這是另一個例子:

.presentationDetents([.fraction(0.1), .height(200), .medium, .large])

現在底部支援 4 種不同的尺寸,包括:

  • 大約螢幕高度的10%
  • 固定高度200點
  • 標準尺寸
圖 18.8. 固定尺寸底頁樣本
圖 18.8. 固定尺寸底頁樣本

存放選定的定位

每次您關閉底部表時,presentation detent 都會重設為原始狀態。 換句話說,當您再次打開底部紙張時,它將始終以 .height(200) detetnt 開始。

.presentationDetents([.height(200), .medium, .large])

但是,如果您想記住最後選擇的定位並在重新開啟底部表單時恢復它,您可以聲明一個狀態變數來追蹤目前選擇的定位:

@State private var selectedDetent: PresentationDetent = .medium

對於 presentationDetents 修飾器,您可以在 selection 參數中指定狀態變數的綁定。 這允許 SwiftUI 將目前選定的定位器儲存在狀態變數中。

.presentationDetents([.height(200), .medium, .large], selection: $selectedDetent)

然後,SwiftUI 將最後選擇的定位儲存在狀態變數中,並在再次顯示底部表單時恢復。

總結

在本章中,我示範如何使用新的「presentationDetents」修飾器建立底部工作表。 這個備受注視的視圖元件是許多開發者期待已久的。 透過可自訂的底部工作表,您現在可以輕鬆顯示錨定到螢幕底部的補充內容。

如需進一步參考並探索底部表的完整方案,您可以從以下連結下載示範項目: