繪圖動畫基礎-圖片篇
-
Swift UI繪圖-8
===## 圖片輪播(Carousel)
圖片輪播是個非常實用的動畫效果,在網站或App應用中經常看到,很適合拿來播放廣告或展示商品,每次只顯示一個畫面,停留一段時間後,自動滑入下一個畫面。為了要達到最好的顯示效果,通常圖片會盡量佔滿整個螢幕,文字或操作按鈕浮在圖片上層,類似抖音(TikTok)的做法。
聽起來很簡單,不過實際做起來卻不容易,關鍵之處在於下一張水平滑入時,與前一張一起移動的距離,恰好到螢幕寬度(若是垂直滑入,則是螢幕高度)。所以跳轉時必須計算螢幕的寬或高,慢慢滑入下一張圖片,以 SwiftUI 現有的排版視圖,包括 ScrollView, LazyVGrid, List…等都無法做到理想的圖片輪播。
本單元我們嘗試利用畫布 Canvas 來製作圖片輪播的效果。
圖片內容從哪裡來呢?在第2單元,我們是將圖片檔案導入 Swift Playgrounds。現在我們使用之前的三個人物圖片。
操作步驟如下:
選取畫面左方的「+」號新增電子書頁面。

將新增的電子書面頁命名為「(33)SwiftUI動畫-8圖片篇」。

在「Main」模組中撰寫程式:
```swift=
// 4-9a Canvas 圖片顯示
// Created by Philip, Heman, Jean 2022/06/17
// Revised by Jean 2025/01/26
import PlaygroundSupport
import SwiftUIstruct 畫布: View {
@State var 圖片集: [UIImage] = [
UIImage(named: "girl.png")!,
UIImage(named: "boy.png")!,
UIImage(named: "boy2.png")!
]
var body: some View {
Canvas { 圖層, 尺寸 in
let 中心 = CGPoint(x: 尺寸.width/2, y: 尺寸.height/2)
let 照片 = 圖層.resolve(Image(uiImage: 圖片集.first!))
let 寬度比 = 尺寸.width / 照片.size.width
let 高度比 = 尺寸.height / 照片.size.height
let 縮放比 = min(寬度比, 高度比)
圖層.translateBy(x: 中心.x, y: 中心.y)
print(尺寸, 圖層.transform)
if 縮放比 < 1.0 {
圖層.scaleBy(x: 縮放比, y: 縮放比)
print(尺寸, 圖層.transform)
}
圖層.draw(照片, at: .zero)
}
.border(.red)
}
}PlaygroundPage.current.setLiveView(畫布())
```程式執行結果,如下圖。

## 圖片輪播
本單元我們正式做一個往左滑動的圖片輪播,需要三個程式段落:
1. 3張圖片
2. 將3張圖片水平排列,各自間隔一個螢幕寬度
3. 往左移動圖片,到一個螢幕寬度時停止移動類似下圖的效果:

在「Main」模組中撰寫程式:
```swift=
// 4-9b 圖片輪播
// Created by Philip, Heman, Jean 2022/06/20
// Revised by Jean 2025/01/26
import PlaygroundSupport
import SwiftUIstruct 畫布: View {
let 更新: Date
@State var 圖片集: [UIImage] = [
UIImage(named: "girl.png")!,
UIImage(named: "boy.png")!,
UIImage(named: "boy2.png")! ]
@State var 百分比: CGFloat = 0.0
@State var 張次 = 0
var body: some View {
Canvas { 圖層, 尺寸 in
let 中心 = CGPoint(x: 尺寸.width/2, y: 尺寸.height/2)
if 圖片集.isEmpty {
圖層.draw(Text("等待圖片下載..."), at: 中心, anchor: .center)
} else {
for i in 0..<3 { // 僅需顯示前3張
if i < 圖片集.endIndex {
let 照片 = 圖層.resolve(Image(uiImage: 圖片集[i]))
let 寬度比 = 尺寸.width / 照片.size.width
let 高度比 = 尺寸.height / 照片.size.height
let 縮放比 = min(寬度比, 高度比)
let 移動距離 = 百分比 < 1.0 ? 尺寸.width * 百分比 : 尺寸.width
圖層.drawLayer { 新圖層 in
新圖層.translateBy(
x: 中心.x + 尺寸.width * CGFloat(i) - 移動距離,
y: 中心.y)
if 縮放比 < 1.0 {
新圖層.scaleBy(x: 縮放比, y: 縮放比)
}
新圖層.draw(照片, at: .zero)
// print(尺寸, 新圖層.transform)
}
}
}
圖層.drawLayer { 文字圖層 in
let 底部 = CGPoint(
x: 中心.x,
y: 尺寸.height - 20)
var 標記 = AttributedString("")
for i in 0..<圖片集.count {
標記 += (i == 張次) ? "●" : "○"
}
// 標記.foregroundColor = .white
// 標記.backgroundColor = .gray.opacity(0.2)
文字圖層.draw(Text(標記), at: 底部)
}
}
}
.border(.red)
.onChange(of: 更新) { _ in
let 輪播週期 = 10.0
let 移動速率 = 0.05
if 圖片集.count > 1 { // 至少2張才需要輪替
if 百分比 > 輪播週期 { // 替換下一張
張次 = (張次 + 1) % 圖片集.count
if let 首張 = 圖片集.first { // 將第一張移至最後
圖片集.removeFirst()
圖片集.append(首張)
}
百分比 = 0.0
} else {
百分比 += 移動速率
}
}
}
}
}struct 圖片輪播: View {
var body: some View {
TimelineView(.animation) { 時間參數 in
畫布(更新: 時間參數.date)
}
}
}PlaygroundPage.current.setLiveView(圖片輪播())
```程式執行結果,如下圖。

* 說明
第一步是最簡單的,知道如何在Canvas載入圖片,並且將多張圖片,加入「圖片集」陣列中。
```swift=
@State var 圖片集: [UIImage] = [
UIImage(named: "girl.png")!,
UIImage(named: "boy.png")!,
UIImage(named: "boy2.png")!
]
```第二步也不難,在畫布Canvas中同樣用 for 迴圈與「圖層.draw()」顯示多張圖片,利用圖層位移 translateBy() 將圖片隔開,彼此距離恰等於一個螢幕寬度。由於每個圖層只能有一個變換矩陣,所以每張圖透過「圖層.drawLayer」顯示在不同圖層中:
```swift=
for i in 0..<圖片集.count {
let 照片 = 圖層.resolve(Image(uiImage: 圖片集[i]))
let 寬度比 = 尺寸.width / 照片.size.width
let 高度比 = 尺寸.height / 照片.size.height
let 縮放比 = min(寬度比, 高度比)
圖層.drawLayer { 新圖層 in
新圖層.translateBy(
x: 中心.x + 尺寸.width * CGFloat(i),
y: 中心.y)
if 縮放比 < 1.0 {
新圖層.scaleBy(x: 縮放比, y: 縮放比)
}
新圖層.draw(照片, at: .zero)
// print(尺寸, 新圖層.transform)
}
}
```第三步最關鍵,也最困難。主要的問題是如何控制時間,讓下一張滑入到定位,停頓一段時間,再繼續播放下一張?若已到「圖片集」最後一張,如何回到第一張循環播放?
要配合畫布控制時間,當然用時間軸視圖 TimelineView(.animation) 來驅動,若以 60 fps 計算,每1/60秒會更新一次,令每次移動螢幕寬度的5%(即0.05),移動20次就等於一個螢幕寬度,也就是1/3秒就滑到定位:
```swift=
struct 圖片輪播: View {
var body: some View {
TimelineView(.animation) { 時間參數 in
畫布(更新: 時間參數.date)
}
}
}struct 畫布: View {
let 更新: Date
@State var 圖片集: [UIImage] = []
@State var 百分比: CGFloat = 0.0var body: some View {
Canvas { 圖層, 尺寸 in
...
let 移動距離 = 尺寸.width * 百分比
圖層.drawLayer { 新圖層 in
新圖層.translateBy(
x: 中心.x + 尺寸.width * CGFloat(i) - 移動距離,
y: 中心.y)
...
}
}
.onChange(of: 更新) { _ in
let 移動速率 = 0.05
百分比 += 移動速率
}
}
}
```我們在 .onChange 中,讓「百分比」超過100%(即1.0),一直增加到1000%(即10.0)才重新歸零,如此一來,百分比 ≥ 1.0的時間,會維持約3秒鐘:
```swift=
.onChange(of: 更新) { _ in
let 輪播週期 = 10.0
let 移動速率 = 0.05
if 百分比 > 輪播週期 {
百分比 = 0.0
} else {
百分比 += 移動速率
}
}
```與此配合的,是在畫布中計算圖層位移時,「百分比」超過1.0的部份都以螢幕寬度為移動距離,只需改一行程式:
```swift=
let 移動距離 = 百分比 < 1.0 ? 尺寸.width * 百分比 : 尺寸.width
圖層.drawLayer { 新圖層 in
新圖層.translateBy(
x: 中心.x + 尺寸.width * CGFloat(i) - 移動距離,
y: 中心.y)
...
}
```最後,剩下一個小問題,整個「圖片集」輪播完畢後,如何回到開頭循環播放呢?可能有多種解法,這裡用較簡便的方法,並不更動陣列索引,而是將顯示過的第一張移到陣列末尾:
```swift=
.onChange(of: 更新) { _ in
let 輪播週期 = 10.0
let 移動速率 = 0.05
if 百分比 > 輪播週期 { // 替換下一張
張次 = (張次 + 1) % 圖片集.count
if let 首張 = 圖片集.first { // 將第一張移至最後
圖片集.removeFirst()
圖片集.append(首張)
}
百分比 = 0.0
} else {
百分比 += 移動速率
}
}
```為了清楚顯示輪播的進度,通常會在最下方做一個指標,以顯示目前輪播的畫面次序,需要多一個狀態變數「張次」來控制,指標就用String或AttributedString來做即可:
```swift=
圖層.drawLayer { 文字圖層 in
let 底部 = CGPoint(
x: 中心.x,
y: 尺寸.height - 20)
var 標記 = AttributedString("")
for i in 0..<圖片集.count {
標記 += (i == 張次) ? "●" : "○"
}
// 標記.foregroundColor = .white
// 標記.backgroundColor = .gray.opacity(0.2)
文字圖層.draw(Text(標記), at: 底部)
}
```