20.%d SwiftUI v.s. UIKit

Chun-Li 春麗
12 min readAug 30, 2021

--

目錄

⦿ 撲克牌比大小
⦿ 屬性包裝器
⦿ Action

撲克牌比大小

這個簡單抽牌比大小的遊戲,只是點選籌碼發牌,並與莊家的牌比分數,是許久之前跟著 SwiftUI 的教學影片做的,而今又寫了一個 UIKit 純程式碼的版本,看到如下:

左邊是 SwiftUI 的預覽,右邊是 UIKit 的預覽,其實看不出什麼差別。

再看到下面這張。

SwiftUI 沒有使用 Storyboard 但透過預覽的方式,能即時呈現你的 View,以初建 SwiftUI 專案,裡面只會有一個 ContentView,這個就是你的母視圖,在母視圖裡放入一個個大大小小的子視圖而形成你的 APP;而 UIKit 專案中,有 Storyboard,除了 IBInspectable、IBDesignable 可加入部份預覽的效果之外,多數呈現的非當時狀態(Compile),只能在 build(Runtime)後才能看到最後的結果。

其餘的地方,專案沒有不一樣,但設計理念肯定不同了。

在這兩個專案中,SwiftUI 約 150 行,而 UIKit 則有 300 多行,UIKit 因為純程式碼的關係,在產出 View 是相當辛苦的,尤其是這些 View 並沒有客製化的情況下,SwiftUI 用基本的 modifier 即可。

繼續閱讀|回目錄

屬性包裝器

在 Swift 中有所謂屬性包裝器,透過屬性包裝器,可以在宣告屬性的時候減少很多複雜的計算,比方說,我希望限制年齡在 18–26 歲之間,可以如下操作:

@propertyWrapper
struct AgeRange {
private var age: Int
private var maxAge: Int
private var minAge: Int

var wrappedValue: Int {
get { age }
set {
if newValue > maxAge {
age = maxAge
} else if newValue < minAge {
age = minAge
} else {
age = newValue
}
}
}

init(wrappedValue: Int, maxAge: Int = 26, minAge: Int = 18) {
self.maxAge = maxAge
self.minAge = minAge

if wrappedValue > maxAge {
self.age = maxAge
} else if wrappedValue < minAge {
self.age = minAge
} else {
self.age = wrappedValue
}
}
}


struct Person {
@AgeRange var age: Int
}

我們有一個 struct 叫做 AgeRange,讓 AgeRange 包裝了 age 這個屬性,透過包裝值在 get 時,直接取得它,在 set 時則有一些限制,如果超過限制年齡範圍分別以最大年齡最小年齡來呈現,否則為包裝值。

透過 AgeRange 在宣告時,我們就可讓 age 這個 Int 型別,於初始化時限制在 18–26,比方如下:

let personOne = Person(age: 40)
print(personOne.age) // 26
let personTwo = Person(age: 6)
print(personTwo.age) // 18
let personThree = Person(age: 20)
print(personThree.age) // 20

SwiftUI 中,哪裡有類似的 PropertyWrapper 的東西呢?看到如下:

@State private var playerCard = "club" + String(Int.random(in: 2..<14))@State private var cpuCard = "club" + String(Int.random(in: 2..<14))
@State private var playerScore = 0
@State private var cpuScore = 0

14 是 A,13 是 K,12 是 Q,11 是 J,所以就可以用 Int 比大小的方式去比撲克牌的大小。

這邊的四個屬性都加上了 @State 的前綴,這表示如果狀態改變的話,會即時反映到 UI 上,SwiftUI 就是以這種方式來做到所謂 Data Binding,Data Binding 的意涵就是 Data 跟 UI 其一改變,另一也會跟著改變,簡直是量子糾纏。

而這四個屬性,分別是存玩家卡片、莊家卡片、玩家分數、莊家分數。

接著看到如下:

SwiftUI 在使用上,可用簡單的方式(modifier)去調整 View,不再麻煩地去關注 Constraint,這即是說 SwfitUI 把在 UIKit 每個 UIElement 當作 View 來看待,造就了宣告(Declarative)式 UI 與命令(Imperative)式 UI 最大的不同。

若在 UIKit 裡,會看到下面的程式碼:

let titleImageView = UIImageView(frame: CGRect(x: 100, 
y: 100,
width: 180,
height: 165))
titleImageView.image = UIImage(named: "casino")

在 UIKit 中,必須要給定元件明確的位置跟大小,而如果 SwiftUI 裡不用 x 跟 y,要怎麼確定 View 的位置呢?但其實 SwiftUI 的位置已經透過 HStack、VStack 解決了 。如下:

同樣的東西在 UIKit 下就會變得相當繁雜:

let shadow1ImageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 90, height: 130))
let card1ImageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 90, height: 130))
let shadow2ImageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 90, height: 130))
let card2ImageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 90, height: 130))
shadow1ImageView.center = CGPoint(x: fullScreenSize.width * 0.5 - 85, y: fullScreenSize.height * 0.5 - 115 + offsetY)
shadow1ImageView.backgroundColor = UIColor.black
shadow1ImageView.layer.cornerRadius = 7
shadow1ImageView.layer.opacity = 0.25

card1ImageView.backgroundColor = UIColor.yellow
card1ImageView.center = CGPoint(x: fullScreenSize.width * 0.5 - 90, y: fullScreenSize.height * 0.5 - 120 + offsetY)
card1ImageView.image = UIImage(named: "club14")
card1ImageView.clipsToBounds = true
card1ImageView.layer.cornerRadius = 7

在純程式碼中,宣告的 UIElement 要記得設定 clipsToBounds = true,而若是以 @IB 繫結後,設定圓角則只要設定 layer.cornerRadius 即可。

由於 SuperView 跟 SubView 的關係,在加入這些元件時需特別注意先後順序,以免有的 View 應該是疊在上面,卻太早被加入,如下:

self.view.addSubview(shadow1ImageView)
self.view.addSubview(shadow2ImageView)
self.view.addSubview(card1ImageView)
self.view.addSubview(card2ImageView)

再回到 SwiftUI:

前述,除了用 Spacer() 擺入佔位空間之外,在子 View 本身,會用 padding 去做子 View 中呈現的文字、圖像與其 Content 上下左右的間距,這有點像是你在設定 Constraint 時,一併設定 margin。

而如果不透過 Auto Layout,UIKit 刻元件是相當麻煩的,沒有自適應的話,很容易在不同尺寸的設備中,元件被壓縮、被切邊等沒有正常顯示。

繼續閱讀|回目錄

Action

最後稍微比較一下動作邏輯的部份,即便兩個框架是以不同理念在設計,但在操作上的邏輯並沒有特別不同,差別僅在於,UIKit 需要橋接 Objective-C 時期的 message dispatch 的方式來去寫方法,所以在編譯時不一定直接發現錯誤,需等到執行時。

看到下方的 addTarget:

let playButton = UIButton(frame: CGRect(x: 0, y: 0, width: 170, height: 170))playButton.backgroundColor = .clear
playButton.center = CGPoint(x: fullScreenSize.width * 0.5, y: fullScreenSize.height * 0.5 + 120 - 30)
playButton.setImage(UIImage(named: "chip"), for: .normal)
playButton.isEnabled = true
playButton.addTarget(self, action: #selector(self.shuffle), for: .touchUpInside)
self.view.addSubview(playButton)var counter = 0
var c: Int = 0
var d: Int = 0
@objc func shuffle() {
let a = Int.random(in: 2...14)
let b = Int.random(in: 2...14)
self.card1ImageView.image = UIImage(named: "club\(a)")
self.card2ImageView.image = UIImage(named: "club\(b)")

if a > b {
c += 1
self.playerScoreLabel.text = "\(c)"
} else if a < b {
d += 1
self.cpuScoreLabel.text = "\(d)"
} else {
print("平局")
}

counter += 1
print(counter)
}

若是 SwiftUI,Button 的 Action 則是如下:

Button(action: {
// generate a random number between 2 and 14
var playerCardArray = [
2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14
]
playerCardArray.shuffle()

let cpuRandomCard = Int.random(in: 2...14)

// update the cards
playerCard = "club" + String(playerCardArray[0])
cpuCard = "club" + String(cpuRandomCard)

// update score
(playerCardArray[0] > cpuRandomCard) ? (playerScore += 1) : (cpuScore += 1)


},

另外,在 Assets 裡,我們擺放的圖片名稱,其實應該要跟你的邏輯(比大小)不要偏離太遠,這就像我們在設計計算機時,會用到 sender.currentTitle 去讀取 Button 現在顯示的 String,轉成 Double 後再來做數學運算。

看到如下:

最終成果如下:

左邊 SwiftUI,右邊 UIKit

而雖然 UIKit 可以借用 SwiftUI 的功能來預覽,在 Xcode 裡的預覽畫面很可能會跟你 build 後不一樣,即便是以相同尺寸的裝置在預覽,像右圖就是硬生生被提高了一段距離。

這次就分享到這,感謝您的閱讀。

繼續閱讀|回目錄

附上 GitHub:

SwiftUI

UIKit

--

--

Chun-Li 春麗

Do not go gentle into that good night, Old age should burn and rave at close of day; Rage, rage, against the dying of the light.