雖然這樣說有點事後孔明,但我真的覺得如果 Apple 在推出 SwiftUI 1.0 時,就引入 matchedGeomtryEffect
primitive,SwiftUI 一定會有更好的開始。
畢竟,我認為對於經驗豐富的 UIKit 程式設計師來說,使用 SwiftUI 時最困難的就是佈局 (layout)。
讓我們來實作一個範例來展示 matchedGeometry 的意義吧!在這篇文章中,我們會一起在 iOS 15 中使用 Swift,配合一些 UnitPoint
對齊方式 (alignment) 等方法,來構建一款九宮格遊戲。
簡介
一張圖片勝過千言萬語,以下就是這篇文章要實作的遊戲,目標是移動方塊,以讓方塊按數字順序排列。下圖的 1 號方塊位置時正確的,但其他方塊的位置全都不對。
當然,我們也可以就這樣把方塊拖放到正確位置,但這樣就太容易了。所以,我們要利用空的方塊逐格移動。
程式碼簡介
了解完這篇文章的目標後,讓我們來進行實作吧!我們會使用 SwiftUI 的畫布 (canvas),並把數字放在畫布上。我可以使用文本物件 (text object),但我還是想用圖像來做範例,所以我們會使用畫布。這是一個網格 (grid),所以裡面有 LazyVGrid
。我計劃使用拖動手勢 (drag gesture) 觸發移動,然後利用 matchedGeometry
進行實際移動。我也希望 App 可以告訴我們完成了移動,並報告所花費的時間,這樣我們才可以逐漸改善和進步。
概念驗證 (Proof of concept)
讓我們從概念驗證開始,我們會看到基本的動態。雖然圖中只有一個數字,而且有些移動路線不是我們想要的效果,但原理就是這樣。我所做的,其實是在變更畫布視圖之間的對齊方式,當中的程式碼是參考我之前的這篇文章。現在,動畫看起來像是一個數字在九個方格之間移動,但實際上,方格的網格並沒有參與其中,我只是在變更數字與中間紅色矩形的對齊方式。我還沒有在這裡實作拖動手勢,而是用了點擊手勢 (tap gesture)。
我們使用基於 Combine 框架的 Publisher,來發送手勢和 anchor 數值之間的連接更改。
下一步
接下來,我們需要把其他數字隨機放在網格內。我參考了這篇文章,來在 set 中隨機取得一個整數,然後編寫了一個 SwiftUI 的方法:
var usedRange:Set<Int> = []
func randomF(randomSet:Set<Int>) -> Int {
if usedRange.isEmpty {
usedRange = randomSet
}
let r = usedRange.first!
usedRange.remove(at: usedRange.startIndex)
usedRange = Set(usedRange.shuffled())
return r
}
func randomF(reset: Bool) {
if reset {
usedRange = []
}
}
簡單來說,以上的程式碼會從 set 中隨機選擇一個數字,並儲存這個決定,讓我們在下一次呼叫這個方法時,可以得到一個不同的數字。我會在 build 的時候,在畫布視圖中呼叫這個方法。不過請注意,我們其實不能這樣隨機創建九宮格,我們會再說說這個問題。
下一階段
我們要進入下一個階段了,這個部分會有一點困難。我暫時不會提供 capture 畫布的方法給大家,不過不用擔心,我已經寫好了程式碼,把 8 個數字儲存為圖像,並放在網格中,留下最後一個為空格。
試過幾次之後,我終於知道如何利用 matched geometryEffect
來得到想要的結果。每張圖片都有佈局 anchor 數值和 freeSpace
變數,當中包含了未使用的方格的對齊方式。以下是範例程式碼:
struct ImageViewsV: View {
let columns = [
GridItem(.fixed(64)),
GridItem(.fixed(64)),
GridItem(.fixed(64))
]
@State var selected:Int? = nil
@Binding var images:[UIImage]
@Namespace private var ns
@State var anchorValue: [UnitPoint] = changerViews
@State var freeSpace:UnitPoint = changerViews[8]
let namespaces = ["id1","id2","id3","id4","id5","id6","id7","id8"]
fileprivate func makeMove(_ select: Int, _ nextFree: UnitPoint) {}
fileprivate func doMove() {}
var body: some View {
ZStack {
ForEach((0...7), id: \.self) {value in
Rectangle()
.fill(Color.clear)
.matchedGeometryEffect(id: namespaces[value], in: ns, anchor: anchorValue[value])
.border(Color.clear)
.frame(width: 144, height: 144, alignment: .center)
.onReceive(publishChange) { _ in
doMove()
}
}
LazyVGrid(columns: columns, spacing: 8) {
ForEach((0...7), id: \.self) {value in
Image(uiImage: images[value])
.resizable()
.matchedGeometryEffect(id: namespaces[value], in: ns, properties: .position, anchor: .center, isSource: false)
.border(Color.orange)
.frame(width: 64, height: 64)
.onTapGesture() {
selected = value
publishChange.send()
}
}
}
}
}
}
我在兩個 loop 中都提到了 matchedGeometryEffect
。我們把圖像佈局成 3×3 正方形後,matchedGeometryEffect
tag 就負責控製圖像中間那個矩形的佈局。你可以在這篇文章的 GIF 動畫中看到這個紅色矩形。
我暫時會繼續使用點擊手勢,畢竟這樣比較簡單。我們隨意點擊一個數字方格,它就會移動到空格上。
雖然這個範例看起來不錯,但眼尖的大家可能都注意到,這裡用了一種作弊的方法來玩這個遊戲。因為我們沒有定立規則,規定方格只能移動到旁邊的 freespace
,現在任何方格都可以移動到空格上。如果你想的話,這可以是遊戲的入門等級。
實作規則
我簡單寫了一點需要的規則。我會把遊戲分為三個等級,在困難等級,使用者只可以垂直或水平移動方格;在中等等級,使用者可以對角 (diagonal) 移動方格;而入門等級,就是任何方格都可以移動。
我將規則編碼到陣列的 dictionary 中,每個陣列都記錄了空格旁邊的有效移動。然後,當使用者要求移動方格時,就可以簡單檢查這是否一個有效的移動。
添加了以上的規則之後,我們還需要一個按鈕,來選擇不同等級。我決定使用之前的文章中的程式碼,來創建一個三段式按鈕。
完成了!以上是這個遊戲的示範。
在沒有規則 (none) 的時候,我可以把數字 8 的方格對角移動到空格上。
在有些規則 (some) 的時候,我可以把空格附近的數字 3 對角移動到空格上,但就無法跨過其他方格。
最後,在正常規則 (norm) 底下,我們只可以垂直或水平移動方格到空格上。
你可以在 bitbucket 上找到專案的完整程式碼。如有任何問題,歡迎在下面留言,我會盡力解決。
還沒解決的問題
這篇文章到此為止,但這個範例還沒有完成,以下的要求還沒有做到:
- 我沒有實作拖動手勢,
- 我沒有實作計時器
- 我沒有實作排名表
- 我沒有實作完成遊戲的信息/提示
- 我沒有嘗試把照片加載到圖像
不過最重要的是,如果我想將這個 App 上傳到 App Store,我們不可以像範例這樣隨機創建一個九宮格,因為這樣創建出來的九宮格,有可能是無法完成的拼圖。因此,在推出之前,一定要解決這個問題。