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

第 17 章
運用表單與相機

My biggest motivation? Just to keep challenging myself. I see life almost like one long University education that I never had. Every day I'm learning something new.

- Richard Branson

至目前為止,FoodPin App 只能顯示內容,我們現在需要為使用者提供新增新餐廳的能力。在本章中,我們將建立一個新畫面來顯示用於收集餐廳資訊的輸入表單,在表單中使用者將能從內建的相片庫選擇餐廳相片。在整個過程中,你會學到多種技術:

  • 使用 TextField 與 TextEditor 來建立表單輸入。
  • 存取內建的相片庫並使用相機。

圖 17.1 提供了我們將建立的畫面預覽,它展示了一個由文字欄位與文字視圖組成的簡單輸入表單。

圖 17.1. 建立新餐廳畫面來加入新餐廳
圖 17.1. 建立新餐廳畫面來加入新餐廳

了解 SwiftUI 的文字欄位

如果你從一開始就閱讀本書,你應該非常熟悉堆疊視圖。透過使用 VStack,你可以輕鬆建立表單佈局,問題是如何在 SwiftUI 中建立文字欄位呢?

這個框架提供一個名為「TextField」的視圖元件來建立文字欄位,你通常使用一個欄位及與其欄位值的綁定來初始化 TextField。以下是一個例子:

TextField("Name", text: $name)
    .font(.system(size: 20, weight: .semibold, design: .rounded))
    .padding(.horizontal)

這會渲染一個可編輯的文字欄位,其中使用者的輸入儲存在給定的綁定中。與 SwiftUI 中的其他視圖類型類似,你可以應用相關的修飾器來修改其外觀。

為使用者輸入建立通用表單欄位

佈局表單的最直接方式是逐一建立每個表單欄位,話雖如此,你應該注意到大多數表單欄位都具有相同的設計,基本上我們可以將表單欄位分成兩類:

  1. 具有標籤的文字欄位,用於獲得姓名、類型、地址與電話的輸入。
  2. 帶有標籤的文字視圖,用於獲得描述輸入。

在本例中,我們可以為每個類別建立一個通用表單欄位。如果你還沒有開啟 FoodPin 專案,是時候啟動 Xcode 了。

我們首先在「View」資料夾下建立一個名為「NewRestaurantView.swift」的新檔案,在「View」資料夾上按右鍵,並選擇「New file...」,然後選取「SwiftUI View」模板,將檔案命名為「NewRestaurantView.swift」。

我們的目標是建立一個通用文字欄位來接受標籤名稱以及用於存放欄位值的綁定。我不想直接跳到最後的答案,而是逐步來實作它。首先,我們將了解如何佈局其中一個文字欄位(例如:NAME),現在更新 NewRestaurantView 結構如下:

struct NewRestaurantView: View {

    @State var restaurantName = ""

    var body: some View {
        TextField("Fill in the restaurant name", text: $restaurantName)
            .font(.system(size: 20, weight: .semibold, design: .rounded))
            .padding(.horizontal)
    }
}

上列的程式碼實例化一個帶有占位符號及與 restaurantName 的綁定的文字欄位。在如圖 17.2 所示的預覽中,你應該會看到一個沒有邊框的文字欄位,如果你想填寫一些值,則必須執行 App。

圖 17.2. 一個簡單的文字欄位
圖 17.2. 一個簡單的文字欄位

要為文字欄位新增邊框,我們可以加上 overlay 修飾器,並在其周圍繪製一個圓角矩形。更新文字欄位如下:

TextField("Fill in the restaurant name", text: $restaurantName)
    .font(.system(size: 20, weight: .semibold, design: .rounded))
    .padding(.horizontal)
    .padding(10)
    .overlay(
        RoundedRectangle(cornerRadius: 5)
            .stroke(Color(.systemGray5), lineWidth: 1)
    )
    .padding(.vertical, 10)

要繪製一個空矩形,我們建立一個 RoundedRectangle 視圖,並應用 stroke 修飾器,該線寬設定為「1 點」,筆觸顏色設定為「淺灰色」,如圖 17.3 所示。

圖 17.3. 為文字欄位加上邊框
圖 17.3. 為文字欄位加上邊框

最後,我們使用 VStack 來包裹文字欄位,並為文字欄位加上標籤,如下所示:

VStack(alignment: .leading) {
    Text("NAME")
        .font(.system(.headline, design: .rounded))
        .foregroundColor(Color(.darkGray))

    TextField("Fill in the restaurant name", text: $restaurantName)
        .font(.system(size: 20, weight: .semibold, design: .rounded))
        .padding(.horizontal)
        .padding(10)
        .overlay(
            RoundedRectangle(cornerRadius: 5)
                .stroke(Color(.systemGray5), lineWidth: 1)
        )
        .padding(.vertical, 10)
}

程式碼非常簡單,我們加入一個 Text 視圖來顯示文字欄位的標籤,圖 17.4 顯示了結果。

圖 17.4. 為文字欄位加上標籤
圖 17.4. 為文字欄位加上標籤

現在你應該充分了解如何建立文字欄位,下一步是將其轉換為動態文字欄位,這樣我們在建立其他文字欄位時,就不需要複製程式碼了。

我們來建立一個名為「FormTextField」的新結構,如下所示:

struct FormTextField: View {
    let label: String
    var placeholder: String = ""

    @Binding var value: String

    var body: some View {
        VStack(alignment: .leading) {
            Text(label.uppercased())
                .font(.system(.headline, design: .rounded))
                .foregroundStyle(Color(.darkGray))

            TextField(placeholder, text: $value)
                .font(.system(.body, design: .rounded))
                .textFieldStyle(PlainTextFieldStyle())
                .padding(10)
                .overlay(
                    RoundedRectangle(cornerRadius: 5)
                        .stroke(Color(.systemGray5), lineWidth: 1)
                )
                .padding(.vertical, 10)

        }
    }
}

body 區塊中的程式碼基本上保持不變,但是欄位標籤、占位符號與欄位值現在由下列參數決定:

  1. label - 顯示在文字欄位上方的標籤。
  2. placeholder - 文字欄位的初始值。
  3. value - 與欄位值的綁定。

透過改變這些參數的值,我們可以輕鬆建立自訂文字欄位,例如:要重新建立我們之前討論過的文字欄位,你可以使用下列的程式碼片段:

FormTextField(label: "Name", placeholder: "Fill in the restaurant name", value: $restaurantName)

這不是很棒嗎?我們可以輕鬆建立更多的文字欄位,而無須編寫大量的程式碼。

要預覽 FormTextField,你可以新增另一個 #Preview 程式碼區塊,如下所示:

#Preview("FormTextField", traits: .fixedLayout(width: 300, height: 200)) {
    FormTextField(label: "NAME", placeholder: "Fill in the restaurant name", value: .constant(""))
}

然後 Xcode 將為文字欄位產生單獨的預覽。當你想要為特定的視圖元件建立預覽時, 這種方法已被證明是非常有用的。要預覽固定大小的佈局,則選擇 Selectable 模式,如圖 17.5 所示。

圖 17.5. 預覽文字欄位
圖 17.5. 預覽文字欄位

現在我們已經完成了文字欄位的實作,我們來談談多行文字視圖。對於多行輸入,你可以使用 SwiftUI 框架提供的TextEditor。

與 FormTextField 類似,我們為文字視圖建立一個獨立的視圖元件。我們將其命名為「FormTextView」,並實作如下:

struct FormTextView: View {

    let label: String

    @Binding var value: String

    var height: CGFloat = 200.0

    var body: some View {
        VStack(alignment: .leading) {
            Text(label.uppercased())
                .font(.system(.headline, design: .rounded))
                .foregroundStyle(Color(.darkGray))

            TextEditor(text: $value)
                .frame(maxWidth: .infinity)
                .frame(height: height)
                .padding(10)
                .overlay(
                    RoundedRectangle(cornerRadius: 5)
                        .stroke(Color(.systemGray5), lineWidth: 1)
                )
                .padding(.top, 10)

        }
    }
}

要使用 TextEditor, 你只需要傳送一個綁定來儲存使用者輸入的值。為了預覽 FormTextView,插入另一個 #Preview 程式碼區塊:

FormTextView(label: "Description", value: .constant(""))
    .previewLayout(.sizeThatFits)
    .previewDisplayName("FormTextView")

如果你所做的變更正確,Xcode 應該會在預覽窗格中渲染文字視圖,如圖 17.6 所示。

圖 17.6. 預覽文字視圖
圖 17.6. 預覽文字視圖

實作餐廳表單

實作 FormTextField 與 FormTextView 後, 我們現在可以建立餐廳表單了。更新 New RestaurantView 結構如下:

struct NewRestaurantView: View {

    var body: some View {
        NavigationStack {

            ScrollView {
                VStack {
                    FormTextField(label: "NAME", placeholder: "Fill in the restaurant name", value: .constant(""))

                    FormTextField(label: "TYPE", placeholder: "Fill in the restaurant type", value: .constant(""))

                    FormTextField(label: "ADDRESS", placeholder: "Fill in the restaurant address", value: .constant(""))

                    FormTextField(label: "PHONE", placeholder: "Fill in the restaurant phone", value: .constant(""))

                    FormTextView(label: "DESCRIPTION", value: .constant(""), height: 100)
                }
                .padding()

            }

            // Navigation bar configuration
            .navigationTitle("New Restaurant")
        }
    }
}

首先,我們有一個列標題為「New Restaurant」的導覽視圖。由於表單是擴展的,所以我們使用 ScrollView 來包裹表單欄位。在滾動視圖中,我們使用先前定義的子視圖來建立四個文字欄位與一個文字視圖。目前,欄位的值設定為「.constant("")」,在下一章中,我們會進一步修改程式碼。

你可以在預覽窗格中測試 App,它應該會顯示一個可編輯的表單,如圖 17.7 所示。

圖 17.7. 餐廳表單
圖 17.7. 餐廳表單

使用相片庫與相機

餐廳表單有一個欄位供使用者上傳餐廳相片,其可以從內建的相片庫中選擇,也可以使用裝置的相機拍攝。在本小節中,我們將研究其實作。

在我們開始建立相機功能之前,請下載圖片檔( https://www.appcoda.com/resources/swift53/newphotoicon.zip ),並將其加到素材目錄。

在餐廳表單中,我們將新增一個圖片視圖來存放餐廳相片。為此,我們需要一個狀態變數來追蹤使用者的選擇。在 NewRestaurantView 中宣告下列的變數:

@State private var restaurantImage = UIImage(named: "newphoto")!

我們使用 UIImage 以newphoto 圖片初始化變數。我們將圖片儲存為 UIImage 物件的原因是從相片庫回傳的圖片也有一個 UIImage 型別。

接下來,在第一個 FormTextField 之前插入下列的程式碼片段:

Image(uiImage: restaurantImage)
    .resizable()
    .scaledToFill()
    .frame(minWidth: 0, maxWidth: .infinity)
    .frame(height: 200)
    .background(Color(.systemGray6))
    .clipShape(RoundedRectangle(cornerRadius: 20.0))
    .padding(.bottom)

我們使用 Image 視圖載入 restaurantImage,並設定縮放模式為「scaledToFill」。在預覽中,它應該在所有的其他表單欄位的正上方顯示一個圖片視圖,如圖 17.8 所示。

圖 17.8. 餐廳表單
圖 17.8. 餐廳表單

當點擊圖片視圖時,App 將顯示動作表,並提示使用者選擇相片來源(相片庫或相機)。為了實現此功能,我們需要一個狀態變數來觸發動作表。在 NewRestaurantView 中宣告下列的變數:

@State private var showPhotoOptions = false

另外,宣告一個列舉來表示可用的相片來源及一個狀態變數,來存放相片來源的選擇:

enum PhotoSource: Identifiable {
    case photoLibrary
    case camera

    var id: Int {
        hashValue
    }
}

@State private var photoSource: PhotoSource?

接下來,將 .confirmationDialog 修飾器加到導覽堆疊:

.confirmationDialog("Choose your photo source", isPresented: $showPhotoOptions, titleVisibility: .visible) {

    Button("Camera") {
        self.photoSource = .camera
    }

    Button("Photo Library") {
        self.photoSource = .photoLibrary
    }
}

.confirmationDialog 修飾器監看 showPhotoOptions 變數的狀態。當 showPhotoOptions 設定為「true」時,它會觸發一個包含相機及相片庫選項的確認對話框的顯示。

最後,將 .onTapGesture 修飾器加到 Image 視圖,以觸發動作表:

.onTapGesture {
    self.showPhotoOptions.toggle()
}

要測試變更,則點擊預覽窗格的圖片。當你點擊圖片視圖時,你應該會看到動作表,如圖 17.9 所示。

圖 17.9. 顯示確認對話框
圖 17.9. 顯示確認對話框

那麼,如何使用 SwiftUI 存取相片庫及相機呢?SwiftUI 框架沒有用來使用相機的原生元件,我們必須使用 UIKit 中的 UIImagePickerController 類別。

在專案導覽器中,我們建立一個名為「Util」的新群組(在「FoodPin」上按右鍵,並選擇「New Group」),我們將在這裡建立一個 SwiftUI 版本的 UIImagePickerController。

接下來,在「Util」資料夾上按右鍵,並選擇「New File...」,然後選取「Swift File」模板,將檔案命名為「ImagePicker.swift」。

建立檔案後,將內容替換如下:

import UIKit
import SwiftUI

struct ImagePicker: UIViewControllerRepresentable {

    var sourceType: UIImagePickerController.SourceType = .photoLibrary

    @Binding var selectedImage: UIImage
    @Environment(\.dismiss) private var dismiss

    func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {

        let imagePicker = UIImagePickerController()
        imagePicker.allowsEditing = false
        imagePicker.sourceType = sourceType
        imagePicker.delegate = context.coordinator

        return imagePicker
    }

    func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {

    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {

        var parent: ImagePicker

        init(_ parent: ImagePicker) {
            self.parent = parent
        }

        func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {

            if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
                parent.selectedImage = image
            }

            parent.dismiss()
        }
    }
}

為了確保向下相容,Apple 在 iOS SDK 中導入了兩個新協定,即 UIViewRepresentable 與UIViewControllerRepresentable。這些協定可以讓你封裝UIKit 視圖(或視圖控制器), 並使其在你的 SwiftUI 專案中存取。

在本質上,你所需要做的就是在 SwiftUI 中建立一個遵循協定的 struct,使你能建立與處理 UIView 物件。以下是 UIKit 視圖的自訂包裹器的基本結構:

struct CustomView: UIViewRepresentable {

    func makeUIView(context: Context) -> some UIView {
        // Return the UIView object
    }

    func updateUIView(_ uiView: some UIView, context: Context) {
        // Update the view
    }
}

在實際的實作中,你可以將 「some UIView」 替換為你要包裹的特定 UIKit 視圖,在本例中它是UIImagePickerController。

ImagePicker 結構接受來源型別( 預設為相片庫)以及與所選圖片的綁定。在 makeUIViewController 方法中,我們建立一個 UIImagePickerController 的實例,並相應設定其來源型別,你可以透過這個方式來開啟相片庫或存取裝置相機。

Coordinator 類別負責將使用者選擇的相片儲存到 selectedImage 綁定中, 它遵循UIImagePickerControllerDelegate 協定,並實作 imagePickerController(_:didFinishPickingM ediaWithInfo) 方法。

現在切換回 NewRestaurantView.swift 來修改程式碼。將 .fullScreenCover 修飾器加到導覽堆疊:

.fullScreenCover(item: $photoSource) { source in
    switch source {
    case .photoLibrary: ImagePicker(sourceType: .photoLibrary, selectedImage: $restaurantImage).ignoresSafeArea()
    case .camera: ImagePicker(sourceType: .camera, selectedImage: $restaurantImage).ignoresSafeArea()
    }
}

.fullScreenCover 修飾器的功能與 .actionSheet 類似,但是以全螢幕樣式顯示模態視圖(Modal View )。在提供的程式碼中,我們監看 photoSource 的值變化,並相應顯示 ImagePicker。我們將 restaurantImage 的綁定傳送給 ImagePicker,當使用者照相或從相片庫中選擇一張相片時,選定的相片將儲存在 restaurantImage 中,因此 SwiftUI 將自動更新圖片視圖來顯示所選的圖片。

還有一個設定需要進行,基於隱私的原因,你必須明確描述你的 App 存取使用者相片庫或相機的原因。如果你不這麼做,你可能會出現錯誤,這就是為什麼你需要在 Info.plist 檔中加入兩個鍵 (NSPhotoLibraryUsageDescription 與 NSCameraUsageDescription ),並提供你的理由。

現在至專案導覽器中選擇「Info.plist」,在編輯器中的「Information Property List」上按右鍵,並選擇「Add Row」,接著選擇「Privacy - Photo Library Usage Description」作為鍵 ,並將值設定為「You need to grant the app access to your photo library so you can pick your favorite restaurant photo.」。重複同樣的過程來加入另一列,設定鍵為「Privacy - Camera Usage Description」,並將值設定為「You need to grant the app access to your camera in order to take photos.」,如圖 17.10 所示。

圖 17.10.  更新 Info.plist 檔
圖 17.10.  更新 Info.plist 檔

現在於模擬器或預覽窗格中測試 App,你應該能夠存取相片庫,如圖 17.11 所示。如果你在實機上測試 App,你還可以開啟內建相機。

圖 17.11. 載入相片庫
圖 17.11. 載入相片庫

新增工具列按鈕

表單中目前缺少了兩個按鈕:「Save」與「Cancel」,為了新增這些按鈕,我們將利用工具列來將它們放置在視圖的頂部區域。

在 NewRestaurant 中,於 .navigationTitle("New Restaurant") 的下方插入下列的程式碼:

.toolbar {
    ToolbarItem(placement: .navigationBarLeading) {
        Button(action: {
            dismiss()
        }) {
            Image(systemName: "xmark")
        }

    }

    ToolbarItem(placement: .navigationBarTrailing) {
        Text("Save")
            .font(.headline)
            .foregroundColor(Color("NavigationBarTitle"))
    }
}

.toolbar 修飾器在視圖的頂部空間建立工具列。在閉包中,我們建立兩個工具列項目: 一個用於「Cancel」按鈕,另一個用於「Save」按鈕。

「Cancel」按鈕將關閉目前的視圖,而「Save」按鈕將在下一章中實作。

為了使程式碼運作,我們還需要宣告 dismiss 變數如下:

@Environment(\.dismiss) var dismiss

「New Restaurant」畫面現在應該在「New Restaurant」標題上方顯示一個工具列,如圖 17.12 所示。

圖 17.12. 加入工具列項目
圖 17.12. 加入工具列項目

你可能會注意到「Close」按鈕是藍色的,要將其顏色更改為「黑色」,則你可以將 .tint 修飾器加到導覽堆疊:

.tint(.primary)

顯示新餐廳視圖

New Restaurant 視圖運作得很好,是時候到 RestaurantListView.swift 檔,並編輯程式碼來啟動這個畫面。我們將在清單視圖的導覽列中加入一個工具列項目,供使用者帶出表單視圖,如圖 17.13 所示。

圖 17.13. 在清單視圖中加入「+」按鈕
圖 17.13. 在清單視圖中加入「+」按鈕

在 RestaurantListView 中,宣告一個新的狀態變數:

@State private var showNewRestaurant = false

然後將 .sheet 修飾器加到導覽堆疊,以開啟 New Restaurant 視圖:

.sheet(isPresented: $showNewRestaurant) {
    NewRestaurantView()
}

.sheet 修飾器監看 showNewRestaurant 的狀態。如果其設定為「true」,它會以模態的方式來開啟視圖。

最後,建立一個工具列按鈕來供使用者加入新餐廳。在 .navigationBarTitleDisplayMo de(.automatic) 的下方插入下列的程式碼:

.toolbar {
    Button(action: {
        self.showNewRestaurant = true
    }) {
        Image(systemName: "plus")
    }
}

你可能需要更新 .tint 修飾器,並將顏色從「.white」變更為「.primary」:

.tint(.primary)

如此,你已經準備好進行最終測試。在模擬器中執行 App,然後點擊「+」按鈕來開啟 New Restaurant 視圖,如圖 17.14 所示。

圖 17.14. 帶出 New Restaurant 視圖
圖 17.14. 帶出 New Restaurant 視圖

本章小結

在本章中,你學習了如何使用 TextField 與 TextEditor 來為多行輸入建立文字欄位與文字視圖,你還深入了解如何利用 UIKit 的 UIImagePickerController 來存取內建的相片庫。

雖然 SwiftUI 框架相對較新,但是它已經適合生產開發了。然而,有個限制是它不提供所有的標準 UI 元件,有時你可能需要依賴舊的 UIKit 框架,儘管如此,當你掌握 UIViewRepresentable 協定的用法後,將 UIKit 視圖整合到SwiftUI 專案中就變得很簡單。

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

在下一章中,我們將討論 SwiftData,並了解如何將餐廳資料儲存在資料庫中。