最近「底部表」(bottom sheet )越來越受歡迎,你可以在 Facebook 與 Uber 等知名 App 中找到這個功能,它更像是動作表(action sheet )的加強版,底部表會從螢幕底部向上滑動,並覆蓋在原始視圖的上面,來提供上下文訊息(contextual information )或者可供使用者選擇的其他選項。舉例而言,當你將照片儲存在 Instagram 的照片集中,該 App 會顯示一個底部表,以選擇照片集。而在 Facebook App 中,當點選貼文的「⋯」(ellipsis )按鈕,它會顯示帶有其他的動作項目表。Uber App 也使用底部表來顯示所選擇的行程價格。
底部表的大小取決於想要顯示的上下文訊息。在某些情況下,底部表往往較大(也稱為「背幕」(backdrop )),占據了螢幕的 80~90%。通常,使用者可以使用拖曳手勢與表互動。你可以向上滑動來展開它,或者向下滑動來最小化或解除表。
在本章中,我們將使用 SwiftUI 手勢建立類似的展開式底部表(expandable bottom sheet )。範例 App 在主視圖中顯示餐廳清單,當使用者點擊其中一筆餐廳紀錄時,該 App 會帶出一個底部表來顯示餐廳的詳細資訊。你可以向上滑動來展開表,而要解除表則是向下滑動,如圖 18.2 所示
在 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)尺寸。 最初呈現時,底頁以中等尺寸顯示。 但是,您可以透過向上拖曳工作表將其擴展到大尺寸。
為了節省你從頭建立範例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 章實作過清單。
底部表實際上包含餐廳細節與一個小橫列(handlebar ),因此我們要做的第一件事是建立如圖 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
有兩個參數:icon
與 info
。icon
參數是可選用的,表示它可以有值或是 nil。
當你需要顯示資料欄位及圖示時,你可以像這樣使用 DetailInfoView
:
DetailInfoView(icon: "map", info: self.restaurant.location)
另外,如果你只需要顯示一個文字欄位(如描述欄位),則可以像這樣使用 DetailInfoView
:
DetailInfoView(icon: nil, info: self.restaurant.description)
如你所見,透過建立一個通用視圖來處理相似的佈局,可以使程式碼更具模組化及可重用性。
現在,我們已經建立了所有的元件,我們可以使用 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 所示。.
你是否注意到細節視圖無法顯示完整的內容呢?要解決這個問題,我們必須將內容嵌入在 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
修飾器將其呈現為底部表。 這意味著當使用者選擇餐廳時,細節視圖將顯示為覆蓋目前視圖的底部表單。
由於 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 種不同的尺寸,包括:
每次您關閉底部表時,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」修飾器建立底部工作表。 這個備受注視的視圖元件是許多開發者期待已久的。 透過可自訂的底部工作表,您現在可以輕鬆顯示錨定到螢幕底部的補充內容。
如需進一步參考並探索底部表的完整方案,您可以從以下連結下載示範項目: