2023年4月18日 星期二

CHAPTER 04 使用堆疊視圖設計UI

讀者:楊于葳




本文為《快速精通 iOS 16 程式設計:從零開始活用 Swift 與 SwiftUI 開發技巧》這本書的閱讀筆記與實作記錄的目錄,目的是記錄自己的學過程,以及幫助想利用 Swift 從頭建立一個自己心目中的 App 的人。以下是「CHAPTER 04 使用堆疊視圖設計UI」的筆記內容。




UI 指的是使用者介面(User Interface),在軟體開發中,UI 設計師負責設計和開發人與電腦系統之間的互動介面,讓使用者可以更加直覺的操作電腦系統。

我們第一個建立的 App 非常簡單,如果 App 的 UI 變得越來越複雜的時候,就會需要使用不同型態的堆疊視圖建立使用者介面,還要學習要怎麼做,才能讓 UI 相容各式各樣的螢幕大小。

本章會介紹所有類型的堆疊視圖,建立更全面的 UI,以及介紹顯示圖片的常見 SwiftUI 元件。


4.1 VStack、HStack與ZStack介紹

SwiftUI 提供三種不同類型的堆疊:
  • HStack:x 軸方向
  • VStack:y 軸方向
  • ZStack:z 軸方向


堆疊視圖


4.2 範例App

如果之前有使用過 UIKit,就會知道使用自動佈局來建立相容所有螢幕尺寸的 UI 是必須的,而且自動布局對於初學者而言,非常複雜而且又不容易學會,好消息是 SwiftUI 不再使用自動佈局,建立相容所有螢幕尺寸的 UI 會變得非常容易。


4.3 建立新專案

開啟 Xcode 建立一個新的專案。選擇 iOS ➜ Application ➜ App ➜ Next,填寫以下資訊:

  • 專案名稱(Project name):StackViewDemo(為什麼專案名稱的文字間都不空白呢❓❓❓
  • 團隊(Team):不更動。(❓❓❓)
  • 組織識別碼(Organization Identifier):此為反向域名(❓❓❓)如果有自己的網站可以填入,如果沒有可以使用「com.」後面接自己的名字。
  • 套件識別碼(Bundle Identifier):不需填寫,Xcode 會自動生成。
  • 介面(Interface):選擇 SwiftUI。
  • 語言(Language):選擇 Swift。
  • 使用 Core Data(Use Core Data):不用勾選。
  • 包含測試(Include Test):不用勾選。

輸入以上資訊後,設定資料夾儲存位置,再點選「Create」按鈕。


4.4 加入圖片至Xcode專案中

每個 Xcode 專案中都包含一個素材目錄(Assets),用來管理 App 使用的圖片和圖示。本章不會介紹 App 圖示和強調色(Accent Color)。

iOS 可支援「點陣圖」(Raster Image)及「向量圖」(Vector Image)。PNG 和 JPEG 都屬於點陣圖,放大後會出現品質不佳的問題,因此 Apple 建議開發者使用 PNG 圖檔時,要提供三種不同解析度的圖片。 

本書範例圖片,總共有五個:
  • user1.pdf:適用非視網膜螢幕的舊裝置 iPad 2。
  • user2.png:適用非視網膜螢幕的舊裝置 iPad 2。
  • user2@2x.png:@2x 這張圖片,適用於 iPhone SE/8/13/14。
  • user2@3x.png:@3x 這張圖片有較高的解析度,適用於 iPhone 8 Plus 、iPhone 13/14 Pro、iPhone 13/14 Mac。
  • user3.pdf:適用非視網膜螢幕的舊裝置 iPad 2。

要區分不同解析度的圖片,我們會在照片名稱後加上後綴「@2」、「@3」以供系統辨識。想知道如何使用圖片的細節,可以查看 Apple 開發者網站的介紹


Apple 開發者網站的 Scale factors


「向量圖」的格式通常是 PDF 或 SVG,我們可以用 Sketch 或 Pixelmator 來建立向量圖,不會因為放大圖片而失真。Sketch 和 Pixelmator 都是支援 Mac 系統的 UI 設計工具。Adobe 的 UI 設計工具叫 Adobe XD。

這裡為了示範,所以在範例圖片裡有兩種圖檔(PNG 和 PDF),真正在開發時只需要其中一種就可以了,通常會使用 PDF 檔,因為整體的檔案比較小,放大後也不會失真。

要把加入這些範例圖檔到素材目錄(Assets),只需要將這些圖片拖曳到裡面就可以了,系統會自動歸類這些圖片到不同的位置。


將圖片拖曳到素材目錄


系統會自動歸類


4.5 使用堆疊視圖佈局標題標籤

在 ContentView.swift 裡面建立兩個標籤,產生標題和副標題,並使用 foregroundColor 修飾器,把顏色設定成「.indigo」。


建立兩個標籤,並把標題色設定成 .indigo


程式碼如下:


  1. import SwiftUI
  2. struct ContentView: View {
  3. var body: some View {
  4. VStack {
  5. Text("Instant Developer")
  6. .fontWeight(.medium)
  7. .font(.system(size: 40))
  8. .foregroundColor(.indigo)
  9. Text("Get help from experts in 15 minutes")
  10. }
  11. .padding()
  12. }
  13. }
  14. struct ContentView_Previews: PreviewProvider {
  15. static var previews: some View {
  16. ContentView()
  17. }
  18. }


.indigo 色


4.6 使用留白與間距

接著要將標題與副標題移動到畫面的上方。首先,要在 VStack 嵌入另一個 VStack,方法是在原本的 VStack 標籤上,按住 command 鍵不放,選擇 Embed in VStack。接著插入留白視圖( Spacer ),會把標籤推到螢幕的頂部。


使用留白與間距


程式碼如下:


  1. import SwiftUI
  2. struct ContentView: View {
  3. var body: some View {
  4. VStack {
  5. VStack {
  6. Text("Instant Developer")
  7. .fontWeight(.medium)
  8. .font(.system(size: 40))
  9. .foregroundColor(.indigo)
  10. Text("Get help from experts in 15 minutes")
  11. }
  12. Spacer()
  13. }
  14. .padding(.top, 30)
  15. }
  16. }
  17. struct ContentView_Previews: PreviewProvider {
  18. static var previews: some View {
  19. ContentView()
  20. }
  21. }


4.7 使用圖片

在螢幕上顯示圖片的程式碼:


  1. Image("user1")


在螢幕上顯示圖片


在螢幕上顯示的圖片,使用 resizable 修飾器,填滿可用區域:


  1. Image("user1")
  2. .resizable()


使用 resizable 修飾器,填滿可用區域


如果想維持圖片原來的長寬比,可以使用 scakedToFit 修飾器,程式碼如下:


  1. Image("user1")
  2. .resizable()
  3. .scaledToFit()


使用 scakedToFit 修飾器,維持圖片原來的長寬比


4.8 使用水平堆疊視圖來排列圖片

接下來要使用 HStack 讓圖片可以水平排列,程式碼如下:


  1. HStack {
  2. Image("user1")
  3. .resizable()
  4. .scaledToFit()
  5. Image("user2")
  6. .resizable()
  7. .scaledToFit()
  8. Image("user3")
  9. .resizable()
  10. .scaledToFit()
  11. }


使用 HStack 讓圖片可以水平排列


如果感覺圖片太靠近邊緣了,可以使用 padding 修飾器,加一些間距到 HStack 裡,程式碼如下:


  1. HStack {
  2. Image("user1")
  3. .resizable()
  4. .scaledToFit()
  5. Image("user2")
  6. .resizable()
  7. .scaledToFit()
  8. Image("user3")
  9. .resizable()
  10. .scaledToFit()
  11. }
  12. .padding(.horizontal, 20)
  13. Spacer()
  14. }


使用 padding 修飾器,加一些間距到 HStack 裡


仔細看這三張圖片,會發現他們並不是完全對齊的(最下方的線是參差不齊的),如果我們想要將三張圖片都以底部對齊,可以使用兩種參數調整。一個是 alignment,另一個是 spacing,只要輸入合適的參數,就可讓圖片以底部對齊。程式碼如下:


  1. HStack(alignment: .bottom, spacing: 10){
  2. Image("user1")
  3. .resizable()
  4. .scaledToFit()
  5. Image("user2")
  6. .resizable()
  7. .scaledToFit()
  8. Image("user3")
  9. .resizable()
  10. .scaledToFit()
  11. }


以底部對齊


4.9 在圖片下方加入標籤

在圖片下方加入標籤,程式碼如下:


  1. Text("Need help with coding parblems. Register!")


在圖片下方加入標籤


接下來要調整文字與圖片之間的距離,我們要在 VStack 也加入一些間距程式碼如下:


  1. VStack(spacing:20) {


調整文字與圖片之間的距離


4.10 使用堆疊視圖佈局按鈕

接下來要在畫面的最下方加入兩個按鈕,這兩個按鈕的寬度固定都是200點。第一個按鈕是「紫色背景的 Sign Up」第二個按鈕是「灰色背景的 Log In」,完整程式碼如下:


  1. //
  2. // ContentView.swift
  3. // StackViewDemo
  4. //
  5. // Created by 楊于葳 on 2023/4/15.
  6. //
  7. import SwiftUI
  8. struct ContentView: View {
  9. var body: some View {
  10. VStack(spacing:20) {
  11. VStack{
  12. Text("Instant Developer")
  13. .fontWeight(.medium)
  14. .font(.system(size: 40))
  15. .foregroundColor(.indigo)
  16. Text("Get help from experts in 15 minutes")
  17. }
  18. HStack(alignment: .bottom, spacing: 10){
  19. Image("user1")
  20. .resizable()
  21. .scaledToFit()
  22. Image("user2")
  23. .resizable()
  24. .scaledToFit()
  25. Image("user3")
  26. .resizable()
  27. .scaledToFit()
  28. }
  29. .padding(.horizontal, 20)
  30. Text("Need help with coding parblems. Register!")
  31. Spacer()
  32. VStack {
  33. Button {
  34. } label: {
  35. Text("Sign Up")
  36. }
  37. .frame(width:200)
  38. .padding()
  39. .foregroundColor(.white)
  40. .background(Color.indigo)
  41. .cornerRadius(10)
  42. Button {
  43. } label: {
  44. Text("Log In")
  45. }
  46. .frame(width:200)
  47. .padding()
  48. .foregroundColor(.white)
  49. .background(Color.gray)
  50. .cornerRadius(10)
  51. }
  52. }
  53. .padding(.top, 30)
  54. }
  55. }
  56. struct ContentView_Previews: PreviewProvider {
  57. static var previews: some View {
  58. ContentView()
  59. }
  60. }


加入兩個按鈕


4.11 使用不同的裝置預覽UI

Xcode 會根據我們選擇的模擬器讓我們預覽 UI 的樣貌。如果我們想要再多個模擬器上預覽時,只要在預覽的程式碼進行修改就可以了,程式碼如下:


  1. struct ContentView_Previews: PreviewProvider {
  2. static var previews: some View {
  3. ContentView()
  4. .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
  5. .previewDisplayName("iphone 14 Pro")
  6. ContentView()
  7. .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
  8. .previewDisplayName("iphone 14 Pro")
  9. .previewInterfaceOrientation(.landscapeLeft)
  10. ContentView()
  11. .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro Max"))
  12. .previewDisplayName("iphone 14 Pro Max")
  13. ContentView()
  14. .previewDevice(PreviewDevice(rawValue: "iPad Air (5th generation)"))
  15. .previewDisplayName("iPad Air")
  16. }
  17. }


更新模擬器


其中,書中的 iPad 使用的是「iPad Air (4th generation)」,系統沒有反應,改成「5th generation」就可以看到變化了。


4.12 取出視圖使程式碼有更好的結構

當我們要建立一個包含許多複雜元件的 UI 時,程式碼最後會變成一個難以閱讀的巨大程式碼,最好的方式是把大區塊分割成小區塊,這樣會更方便日後的閱讀和維護。

例如,我們要將存放兩個按鈕的 VStack 取出,可以按住 command 鍵,點擊 VStack,選擇 Extract Subview。此時程式碼區塊會自動縮減成一行:


  1. ExtractedView()


下方也會有一個 ExtractedView 區塊


  1. struct ExtractedView: View {
  2. var body: some View {
  3. VStack {
  4. Button {
  5. } label: {
  6. Text("Sign Up")
  7. }
  8. .frame(width:200)
  9. .padding()
  10. .foregroundColor(.white)
  11. .background(Color.indigo)
  12. .cornerRadius(10)
  13. Button {
  14. } label: {
  15. Text("Log In")
  16. }
  17. .frame(width:200)
  18. .padding()
  19. .foregroundColor(.white)
  20. .background(Color.gray)
  21. .cornerRadius(10)
  22. }
  23. }
  24. }


為了方便日後閱讀,我們可以把預設名稱 ExtractedView 改成 VSignUpButtonGroup,讓我們日後容易辨識。


預設名稱 ExtractedView 改成 VSignUpButtonGroup


完整程式碼如下:


  1. //
  2. // ContentView.swift
  3. // StackViewDemo
  4. //
  5. // Created by 楊于葳 on 2023/4/15.
  6. //
  7. import SwiftUI
  8. struct ContentView: View {
  9. var body: some View {
  10. VStack(spacing:20) {
  11. VStack{
  12. Text("Instant Developer")
  13. .fontWeight(.medium)
  14. .font(.system(size: 40))
  15. .foregroundColor(.indigo)
  16. Text("Get help from experts in 15 minutes")
  17. }
  18. HStack(alignment: .bottom, spacing: 10){
  19. Image("user1")
  20. .resizable()
  21. .scaledToFit()
  22. Image("user2")
  23. .resizable()
  24. .scaledToFit()
  25. Image("user3")
  26. .resizable()
  27. .scaledToFit()
  28. }
  29. .padding(.horizontal, 20)
  30. Text("Need help with coding parblems. Register!")
  31. Spacer()
  32. VSignUpButtonGroup()
  33. }
  34. .padding(.top, 30)
  35. }
  36. }
  37. struct ContentView_Previews: PreviewProvider {
  38. static var previews: some View {
  39. ContentView()
  40. .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
  41. .previewDisplayName("iphone 14 Pro")
  42. ContentView()
  43. .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
  44. .previewDisplayName("iphone 14 Pro")
  45. .previewInterfaceOrientation(.landscapeLeft)
  46. ContentView()
  47. .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro Max"))
  48. .previewDisplayName("iphone 14 Pro Max")
  49. ContentView()
  50. .previewDevice(PreviewDevice(rawValue: "iPad Air (5th generation)"))
  51. .previewDisplayName("iPad Air")
  52. }
  53. }
  54. struct VSignUpButtonGroup: View {
  55. var body: some View {
  56. VStack {
  57. Button {
  58. } label: {
  59. Text("Sign Up")
  60. }
  61. .frame(width:200)
  62. .padding()
  63. .foregroundColor(.white)
  64. .background(Color.indigo)
  65. .cornerRadius(10)
  66. Button {
  67. } label: {
  68. Text("Log In")
  69. }
  70. .frame(width:200)
  71. .padding()
  72. .foregroundColor(.white)
  73. .background(Color.gray)
  74. .cornerRadius(10)
  75. }
  76. }
  77. }


4.13 使用尺寸類別調整堆疊視圖

自適應佈局(Adaptive Layout)可以讓 UI 自動適應特定的裝置或方向,要達到這個目的就要使用「尺寸類別」(Size Classes)的觀念,定義「常規」(Regular)和「緊湊」(Compact)。

透過尺寸類別,可以描述不同的顯示尺寸,並將他們區分成四個象限:
  • 「常規寬度 - 常規高度」(Regular width - Regular height)
  • 「常規寬度 - 緊湊高度」(Regular width - Compact height)
  • 「緊湊寬度 - 常規高度」(Compact width - Regular height)
  • 「緊湊寬度 - 緊湊高度」(Compact width - Compact height)

不同的裝置參照表,可至 Apple 開發者網站查看。


iPhone 14 Pro 的顯示尺寸


@Enviroment 屬性包裹器(Property Wrapper)可以用來取得垂直尺寸的類別,將結果儲存在 verticalSizeClass 變數中,之後只要裝置發生變化, verticalSizeClass 的值就會自動更新。

@Environment 是一個屬性包裝器,提供了一種在應用程式的層次結構中傳遞數據的方式。

\.verticalSizeClass 是一個 key path,表示垂直尺寸的類別。

var verticalSizeClass 是一個變數,用來存儲從 Environment 中獲取的垂直尺寸類別。


  1. @Environment(\.verticalSizeClass) var verticalSizeClass


透過 var verticalSizeClass 變數,我們可以將 VSignUpButtonGroup() 替換成下列程式碼:


  1. if verticalSizeClass == .compact {
  2. HSignUpButtonGroup()
  3. } else {
  4. VSignUpButtonGroup()
  5. }


橫放時,按鈕呈現左右水平的樣子


調整過後的程式碼,在介面是橫放時按鈕會呈現左右水平的樣子,直放的時候按鈕會呈現上下擺放的位置,完整程式碼如下:


  1. //
  2. // ContentView.swift
  3. // StackViewDemo
  4. //
  5. // Created by 楊于葳 on 2023/4/15.
  6. //
  7. import SwiftUI
  8. struct ContentView: View {
  9. @Environment(\.verticalSizeClass) var verticalSizeClass
  10. var body: some View {
  11. VStack(spacing:20) {
  12. VStack{
  13. Text("Instant Developer")
  14. .fontWeight(.medium)
  15. .font(.system(size: 40))
  16. .foregroundColor(.indigo)
  17. Text("Get help from experts in 15 minutes")
  18. }
  19. HStack(alignment: .bottom, spacing: 10){
  20. Image("user1")
  21. .resizable()
  22. .scaledToFit()
  23. Image("user2")
  24. .resizable()
  25. .scaledToFit()
  26. Image("user3")
  27. .resizable()
  28. .scaledToFit()
  29. }
  30. .padding(.horizontal, 20)
  31. Text("Need help with coding parblems. Register!")
  32. Spacer()
  33. if verticalSizeClass == .compact {
  34. HSignUpButtonGroup()
  35. } else {
  36. VSignUpButtonGroup()
  37. }
  38. }
  39. .padding(.top, 30)
  40. }
  41. }
  42. struct ContentView_Previews: PreviewProvider {
  43. static var previews: some View {
  44. ContentView()
  45. .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
  46. .previewDisplayName("iphone 14 Pro")
  47. ContentView()
  48. .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
  49. .previewDisplayName("iphone 14 Pro")
  50. .previewInterfaceOrientation(.landscapeRight)
  51. ContentView()
  52. .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro Max"))
  53. .previewDisplayName("iphone 14 Pro Max")
  54. ContentView()
  55. .previewDevice(PreviewDevice(rawValue: "iPad Air (5th generation)"))
  56. .previewDisplayName("iPad Air")
  57. }
  58. }
  59. struct VSignUpButtonGroup: View {
  60. var body: some View {
  61. VStack {
  62. Button {
  63. } label: {
  64. Text("Sign Up")
  65. }
  66. .frame(width:200)
  67. .padding()
  68. .foregroundColor(.white)
  69. .background(Color.indigo)
  70. .cornerRadius(10)
  71. Button {
  72. } label: {
  73. Text("Log In")
  74. }
  75. .frame(width:200)
  76. .padding()
  77. .foregroundColor(.white)
  78. .background(Color.gray)
  79. .cornerRadius(10)
  80. }
  81. }
  82. }
  83. struct HSignUpButtonGroup: View {
  84. var body: some View {
  85. HStack {
  86. Button {
  87. } label: {
  88. Text("Sign Up")
  89. }
  90. .frame(width:200)
  91. .padding()
  92. .foregroundColor(.white)
  93. .background(Color.indigo)
  94. .cornerRadius(10)
  95. Button {
  96. } label: {
  97. Text("Log In")
  98. }
  99. .frame(width:200)
  100. .padding()
  101. .foregroundColor(.white)
  102. .background(Color.gray)
  103. .cornerRadius(10)
  104. }
  105. }
  106. }


4.14 保存向量資料

保存向量資料(Preserve Vector Data)的功能,可以讓我們保存圖片的向量資料。這項功能預設是停用的,如果要啟用它,可以到 Assets.xcassets,選取其中一張圖片,在屬性檢閱器中,勾選「Preserve Vector Data」來啟用這個功能。


啟用保存向量資料的功能


4.15 你的作業:建立新UI

到指定網站,下載練習圖檔,並完成書籍上的要求。


ch4 作業:建立新UI


完整程式碼如下:



  1. //
  2. // ContentView.swift
  3. // StackViewDemo
  4. //
  5. // Created by 楊于葳 on 2023/4/15.
  6. //
  7. import SwiftUI
  8.  
  9. struct ContentView: View {
  10. @Environment(\.verticalSizeClass) var verticalSizeClass
  11. var body: some View {
  12. VStack(spacing:20) {
  13. VStack{
  14. Text("Instant Developer")
  15. .fontWeight(.medium)
  16. .font(.system(size: 40))
  17. .foregroundColor(.white)
  18. Text("Get help from experts in 15 minutes")
  19. .foregroundColor(.white)
  20. }
  21. HStack(alignment: .bottom, spacing: 10){
  22. Image("student")
  23. .resizable()
  24. .scaledToFit()
  25. Image("tutor")
  26. .resizable()
  27. .scaledToFit()
  28. }
  29. .padding(.horizontal, 50)
  30. Text("Need help with coding parblems? Register!")
  31. .foregroundColor(.white)
  32. Spacer()
  33. if verticalSizeClass == .compact {
  34. HSignUpButtonGroup()
  35. } else {
  36. VSignUpButtonGroup()
  37. }
  38. }
  39. .background{
  40. Image("background")
  41. .resizable()
  42. .ignoresSafeArea()
  43. }
  44. }
  45. }
  46. struct ContentView_Previews: PreviewProvider {
  47. static var previews: some View {
  48. ContentView()
  49. .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
  50. .previewDisplayName("iphone 14 Pro")
  51. ContentView()
  52. .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
  53. .previewDisplayName("iphone 14 Pro")
  54. .previewInterfaceOrientation(.landscapeRight)
  55. ContentView()
  56. .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro Max"))
  57. .previewDisplayName("iphone 14 Pro Max")
  58. ContentView()
  59. .previewDevice(PreviewDevice(rawValue: "iPad Air (5th generation)"))
  60. .previewDisplayName("iPad Air")
  61. }
  62. }
  63. struct VSignUpButtonGroup: View {
  64. var body: some View {
  65. VStack {
  66. Button {
  67. } label: {
  68. Text("Sign Up")
  69. }
  70. .frame(width:200)
  71. .padding()
  72. .foregroundColor(.white)
  73. .background(Color.indigo)
  74. .cornerRadius(10)
  75. Button {
  76. } label: {
  77. Text("Log In")
  78. }
  79. .frame(width:200)
  80. .padding()
  81. .foregroundColor(.white)
  82. .background(Color.gray)
  83. .cornerRadius(10)
  84. }
  85. }
  86. }
  87. struct HSignUpButtonGroup: View {
  88. var body: some View {
  89. HStack {
  90. Button {
  91. } label: {
  92.  
  93. Text("Sign Up")
  94. }
  95. .frame(width:200)
  96. .padding()
  97. .foregroundColor(.white)
  98. .background(Color.indigo)
  99. .cornerRadius(10)
  100. Button {
  101. } label: {
  102. Text("Log In")
  103. }
  104. .frame(width:200)
  105. .padding()
  106. .foregroundColor(.white)
  107. .background(Color.gray)
  108. .cornerRadius(10)
  109. }
  110. }
  111. }


一開始沒有參考提示的架構的時候,我所想到的方法,是使用ZStack的方式完成,沒想到也可以直接用 background 的方式設定。


4.16 本章小結


終於完成第四章所有的小節了!在這一章學會如何使用「堆疊視圖」和「尺寸類別」來建立自適應 UI,這可以說是本書最多節次的一個章節了。我發現新版本的書,在作業的要求上,完成難度似乎沒有舊版本還來得高,而且作業內容感覺更有趣了!