WFU

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


程式碼如下:


import SwiftUI
struct ContentView: View {
    var body: some View {
        VStack {
            Text("Instant Developer")
                .fontWeight(.medium)
                .font(.system(size: 40))
                .foregroundColor(.indigo)
            Text("Get help from experts in 15 minutes")
        }
        .padding()
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}


.indigo 色


4.6 使用留白與間距

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


使用留白與間距


程式碼如下:


import SwiftUI
struct ContentView: View {
    var body: some View {
        VStack {
            VStack {
                Text("Instant Developer")
                    .fontWeight(.medium)
                    .font(.system(size: 40))
                    .foregroundColor(.indigo)
                Text("Get help from experts in 15 minutes")
            }
            Spacer()
        }
        .padding(.top, 30)
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}


4.7 使用圖片

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


Image("user1")


在螢幕上顯示圖片


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


Image("user1")
    .resizable()


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


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


Image("user1")
    .resizable()
    .scaledToFit()


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


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

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


            HStack {
                Image("user1")
                    .resizable()
                .scaledToFit()
                
                Image("user2")
                    .resizable()
                .scaledToFit()
                
                Image("user3")
                    .resizable()
                .scaledToFit()
            }


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


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


            HStack {
                Image("user1")
                    .resizable()
                .scaledToFit()
                
                Image("user2")
                    .resizable()
                .scaledToFit()
                
                Image("user3")
                    .resizable()
                .scaledToFit()
            }
            .padding(.horizontal, 20)
            Spacer()
        }


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


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


            HStack(alignment:  .bottom, spacing: 10){
                Image("user1")
                    .resizable()
                .scaledToFit()
                
                Image("user2")
                    .resizable()
                .scaledToFit()
                
                Image("user3")
                    .resizable()
                .scaledToFit()
            }


以底部對齊


4.9 在圖片下方加入標籤

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


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


在圖片下方加入標籤


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


VStack(spacing:20) {


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


4.10 使用堆疊視圖佈局按鈕

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


//
//  ContentView.swift
//  StackViewDemo
//
//  Created by 楊于葳 on 2023/4/15.
//
import SwiftUI
struct ContentView: View {
    var body: some View {
        VStack(spacing:20) {
            VStack{
                Text("Instant Developer")
                    .fontWeight(.medium)
                    .font(.system(size: 40))
                    .foregroundColor(.indigo)                
                Text("Get help from experts in 15 minutes")
            }
            HStack(alignment:  .bottom, spacing: 10){
                Image("user1")
                    .resizable()
                .scaledToFit()               
                Image("user2")
                    .resizable()
                .scaledToFit()         
                Image("user3")
                    .resizable()
                .scaledToFit()
            }
            .padding(.horizontal, 20)
            Text("Need help with coding parblems. Register!")
            Spacer()
            VStack {
                Button {
                } label: {
                    Text("Sign Up")
                }
                .frame(width:200)
                .padding()
                .foregroundColor(.white)
                .background(Color.indigo)
                .cornerRadius(10)
                Button {
                } label: {
                    Text("Log In")
                }
                .frame(width:200)
                .padding()
                .foregroundColor(.white)
                .background(Color.gray)
                .cornerRadius(10)
            }
        }
        .padding(.top, 30)
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}


加入兩個按鈕


4.11 使用不同的裝置預覽UI

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


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
            .previewDisplayName("iphone 14 Pro")
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
            .previewDisplayName("iphone 14 Pro")
            .previewInterfaceOrientation(.landscapeLeft)
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro Max"))
            .previewDisplayName("iphone 14 Pro Max")   
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iPad Air (5th generation)"))
            .previewDisplayName("iPad Air")
    }
}


更新模擬器


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


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

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

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


ExtractedView()


下方也會有一個 ExtractedView 區塊


struct ExtractedView: View {
    var body: some View {
        VStack {
            Button {
            } label: {
                Text("Sign Up")
            }
            .frame(width:200)
            .padding()
            .foregroundColor(.white)
            .background(Color.indigo)
            .cornerRadius(10)         
            Button {                
            } label: {
                Text("Log In")
            }
            .frame(width:200)
            .padding()
            .foregroundColor(.white)
            .background(Color.gray)
            .cornerRadius(10)
        }
    }
}


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


預設名稱 ExtractedView 改成 VSignUpButtonGroup


完整程式碼如下:


//
//  ContentView.swift
//  StackViewDemo
//
//  Created by 楊于葳 on 2023/4/15.
//
import SwiftUI
struct ContentView: View {
    var body: some View {
        VStack(spacing:20) {
            VStack{
                Text("Instant Developer")
                    .fontWeight(.medium)
                    .font(.system(size: 40))
                    .foregroundColor(.indigo)
                Text("Get help from experts in 15 minutes")
            }
            HStack(alignment:  .bottom, spacing: 10){
                Image("user1")
                    .resizable()
                .scaledToFit()
                Image("user2")
                    .resizable()
                .scaledToFit()
                Image("user3")
                    .resizable()
                .scaledToFit()
            }
            .padding(.horizontal, 20)
            Text("Need help with coding parblems. Register!")
            Spacer()
            VSignUpButtonGroup()
        }
        .padding(.top, 30)
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
            .previewDisplayName("iphone 14 Pro")
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
            .previewDisplayName("iphone 14 Pro")
            .previewInterfaceOrientation(.landscapeLeft)
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro Max"))
            .previewDisplayName("iphone 14 Pro Max")
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iPad Air (5th generation)"))
            .previewDisplayName("iPad Air")
    }
}
struct VSignUpButtonGroup: View {
    var body: some View {
        VStack {
            Button {               
            } label: {
                Text("Sign Up")
            }
            .frame(width:200)
            .padding()
            .foregroundColor(.white)
            .background(Color.indigo)
            .cornerRadius(10)
            Button {
            } label: {
                Text("Log In")
            }
            .frame(width:200)
            .padding()
            .foregroundColor(.white)
            .background(Color.gray)
            .cornerRadius(10)
        }
    }
}


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 中獲取的垂直尺寸類別。


@Environment(\.verticalSizeClass) var verticalSizeClass


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


if verticalSizeClass == .compact {
HSignUpButtonGroup()
} else {
VSignUpButtonGroup()
}


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


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


//
//  ContentView.swift
//  StackViewDemo
//
//  Created by 楊于葳 on 2023/4/15.
//
import SwiftUI
struct ContentView: View {
    @Environment(\.verticalSizeClass) var verticalSizeClass
    var body: some View {
        VStack(spacing:20) {
            VStack{
                Text("Instant Developer")
                    .fontWeight(.medium)
                    .font(.system(size: 40))
                    .foregroundColor(.indigo)
                Text("Get help from experts in 15 minutes")
            }
            HStack(alignment:  .bottom, spacing: 10){
                Image("user1")
                    .resizable()
                .scaledToFit()
                Image("user2")
                    .resizable()
                .scaledToFit()
                Image("user3")
                    .resizable()
                .scaledToFit()
            }
            .padding(.horizontal, 20)
            Text("Need help with coding parblems. Register!")
            Spacer()
            if verticalSizeClass == .compact {
                HSignUpButtonGroup()
            } else {
                VSignUpButtonGroup()
            }
        }
        .padding(.top, 30)
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
            .previewDisplayName("iphone 14 Pro")
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
            .previewDisplayName("iphone 14 Pro")
            .previewInterfaceOrientation(.landscapeRight)
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro Max"))
            .previewDisplayName("iphone 14 Pro Max")
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iPad Air (5th generation)"))
            .previewDisplayName("iPad Air")
    }
}
struct VSignUpButtonGroup: View {
    var body: some View {
        VStack {
            Button {
            } label: {
                Text("Sign Up")
            }
            .frame(width:200)
            .padding()
            .foregroundColor(.white)
            .background(Color.indigo)
            .cornerRadius(10)
            Button {
            } label: {
                Text("Log In")
            }
            .frame(width:200)
            .padding()
            .foregroundColor(.white)
            .background(Color.gray)
            .cornerRadius(10)
        }
    }
}
struct HSignUpButtonGroup: View {
    var body: some View {
        HStack {
            Button {
            } label: {
                Text("Sign Up")
            }
            .frame(width:200)
            .padding()
            .foregroundColor(.white)
            .background(Color.indigo)
            .cornerRadius(10)
            Button {  
            } label: {
                Text("Log In")
            }
            .frame(width:200)
            .padding()
            .foregroundColor(.white)
            .background(Color.gray)
            .cornerRadius(10)
        }
    }
}


4.14 保存向量資料

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


啟用保存向量資料的功能


4.15 你的作業:建立新UI

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


ch4 作業:建立新UI


完整程式碼如下:



//
//  ContentView.swift
//  StackViewDemo
//
//  Created by 楊于葳 on 2023/4/15.
//
import SwiftUI

struct ContentView: View {
    @Environment(\.verticalSizeClass) var verticalSizeClass
    var body: some View {
        VStack(spacing:20) {
            VStack{
                Text("Instant Developer")
                    .fontWeight(.medium)
                    .font(.system(size: 40))
                    .foregroundColor(.white)
                Text("Get help from experts in 15 minutes")
                    .foregroundColor(.white)
            }
            HStack(alignment:  .bottom, spacing: 10){
                Image("student")
                    .resizable()
                .scaledToFit()
                Image("tutor")
                    .resizable()
                .scaledToFit()
            }
            .padding(.horizontal, 50)
            Text("Need help with coding parblems? Register!")
                .foregroundColor(.white)
            Spacer()
            if verticalSizeClass == .compact {
                HSignUpButtonGroup()
            } else {
                VSignUpButtonGroup()
            }
        }
        .background{
            Image("background")
                .resizable()
                .ignoresSafeArea()
        }
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
            .previewDisplayName("iphone 14 Pro")
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro"))
            .previewDisplayName("iphone 14 Pro")
            .previewInterfaceOrientation(.landscapeRight)
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iphone 14 Pro Max"))
            .previewDisplayName("iphone 14 Pro Max")
        ContentView()
            .previewDevice(PreviewDevice(rawValue: "iPad Air (5th generation)"))
            .previewDisplayName("iPad Air")
    }
}
struct VSignUpButtonGroup: View {
    var body: some View {
        VStack {
            Button {                
            } label: {
                Text("Sign Up")
            }
            .frame(width:200)
            .padding()
            .foregroundColor(.white)
            .background(Color.indigo)
            .cornerRadius(10)            
            Button {                
            } label: {
                Text("Log In")
            }
            .frame(width:200)
            .padding()
            .foregroundColor(.white)
            .background(Color.gray)
            .cornerRadius(10)
        }
    }
}
struct HSignUpButtonGroup: View {
    var body: some View {
        HStack {
            Button {                
            } label: {

                Text("Sign Up")
            }
            .frame(width:200)
            .padding()
            .foregroundColor(.white)
            .background(Color.indigo)
            .cornerRadius(10)            
            Button {               
            } label: {
                Text("Log In")
            }
            .frame(width:200)
            .padding()
            .foregroundColor(.white)
            .background(Color.gray)
            .cornerRadius(10)
        }
    }
}


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


4.16 本章小結


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