• 8

Swift程式設計[第4單元] SwiftUI動畫與繪圖

4-7d 仿射變換(CGAffineTransform)

第5課提過,畫布(Canvas)的底層是一個以向量(Vector)為基礎的平面(2D)繪圖系統,以線條為基本元素,透過畫筆(Path)可以畫出直線、弧線、曲線等各種線條,在第5課4-5b曾列出這些「畫筆」功能,有畫直線的addLine(), addLines()、畫圓弧的addArc(),以及畫曲線addCurve(), addQuadCurve()等。

畫筆描繪的線條輪廓要落到「圖層」中才能顯示出來,就需要用到圖層的物件方法,目前為止已用過的包括 draw(), stroke(), fill(), resolve(), translateBy()等等,其他還有若干未用到的圖層方法,稍作整理如下表:
# 圖層方法及屬性 主要參數/類型 用途說明 章節
1 stroke() 畫筆(路徑) 畫線條(筆觸),指定顏色、線寬 4-5b
2 fill() 畫筆(封閉路徑) 封閉區域(不含邊線)填滿顏色 4-6a
3 draw() 文字、圖片 顯示文字、圖片或解析過的視圖 4-5a
4 drawLayer() 匿名函式 新增(上層)圖層 4-7d
5 clip() 畫筆(封閉路徑) 新增顯示遮罩(任何形狀) -
6 clipBoundingRect 視框(CGRect) 指定遮罩(剪裁)視框範圍 -
7 resolve() 文字、圖片 解析文字或圖片(尺寸) 4-7a
8 resolveSymbol() 視圖 顯示其他視圖物件 -
9 translatBy() 位移向量(x, y) 圖層位移 4-6b
10 scaleBy() 寬、高(x, y) 圖層縮放 4-9a
11 rotate() 角度(Angle) 圖層旋轉 -
12 addFilter() 濾鏡選項 新增圖層濾鏡(彩度、灰階、模糊...) -
13 opacity 實數(0.0 ~ 1.0) 圖層的透明度 4-6d
14 transform 變換矩陣
(CGAffineTransform)
圖層的(仿射)變換矩陣 4-7d
15 blendMode 混合選項 圖層相疊時的(顏色)混合模式 -

這其中功能最強大的,應該是 transform,transform 是一個屬性(變數),而不是方法(函式),資料類型是CGAffineTransform,CG 是 Core Graphics 縮寫,Affine Transform 數學上稱為「仿射變換」,所以transform 可稱為「仿射變換」或是「變換矩陣」。

「仿射變換」是利用數學「變換矩陣」對畫筆繪出的圖形加以操作,透過圖層的 transform,可同時做到平移、旋轉、縮放、鏡像等效果。

例如,在上一節用兩條二階貝茲曲線畫出一個葉片的形狀,我們就可用「鏡像」複製成對稱葉片,再用縮放+平移,將這對葉片加以複製並逐漸縮小,如下圖,這種葉片排列方式在植物學上稱為「對生葉序」。


要產生「對生葉序」,須同時操作鏡像、縮放、平移、旋轉,若是用 translateBy(), scaleBy(), rotate() 逐步操作,會相當麻煩,改成 transform 就可一次搞定,只要設定好矩陣內容,圖形就能自動轉換。完整範例程式如下:
// 4-7d 葉片--變換矩陣(CGAffineTransform)
// Created by Heman, 2022/05/20
import PlaygroundSupport
import SwiftUI

struct 葉片: View {
var unitX = 0.05 // unitX = x/寬
var unitY = 0.05 // unitY = y/高
var 說明 = "變換矩陣(CGAffineTransform)\n(c)2022 Heman Lu"
var body: some View {
Canvas { 圖層, 尺寸 in
let 文字圖層 = 圖層
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 左上角 = CGPoint.zero
let 右下角 = CGPoint(x: 寬, y: 高)
let 控制點1 = CGPoint( // 以左下角為原點的數學座標
x: 寬 * unitX, // 轉換為螢幕座標(左上角為原點)
y: 高 - 高 * unitY)
let 控制點2 = CGPoint(
x: 寬 * (1.0 - unitX),
y: 高 - 高 * (1.0 - unitY))
// print(尺寸, 控制點1, 控制點2)

var 畫筆 = Path() // 葉片:兩條二階貝茲曲線
畫筆.move(to: 左上角)
畫筆.addQuadCurve(to: 右下角, control: 控制點1)
畫筆.addQuadCurve(to: 左上角, control: 控制點2)

let 縮放 = 0.2
let 角度 = -CGFloat.pi/4
let 層次 = 7.0
for i in stride(from: 1.4, to: 層次, by: 0.5) {
let 縮放矩陣 = CGAffineTransform(
a: 縮放/i, b: 0,
c: 0, d: 縮放/i,
tx: 0, ty: 0)
let 旋轉位移 = CGAffineTransform(
a: cos(角度), b:sin(角度),
c:-sin(角度), d: cos(角度),
tx: 寬-寬/i, ty: 高/i)
let 水平鏡像 = CGAffineTransform(
a: 1, b: 0,
c: 0, d: -1,
tx: 0, ty: 0)
圖層.drawLayer { 新圖層 in
新圖層.transform = 水平鏡像.concatenating(縮放矩陣).concatenating(旋轉位移)
新圖層.fill(畫筆, with: .color(.green))
}
圖層.transform = 縮放矩陣.concatenating(旋轉位移)
圖層.fill(畫筆, with: .color(.green))
}

var 文字 = 文字圖層.resolve(Text(說明)) // 說明文字
let 文字尺寸 = 文字.measure(in: 尺寸)
let 文字框 = CGRect(
x: 寬 - 文字尺寸.width - 10,
y: 高 - 文字尺寸.height - 10,
width: 文字尺寸.width,
height: 文字尺寸.height)
// print(文字尺寸, 文字框)
文字.shading = .color(.gray)
文字圖層.draw(文字, in: 文字框)
}
}
}

struct 畫布: View {
var body: some View {
Label("[SwiftUI]4-7d 平移旋轉縮放", systemImage: "swift")
.font(.title)
.foregroundColor(.orange)
.padding()
葉片()
.border(.red)
}
}

PlaygroundPage.current.setLiveView(畫布())

transform 背後的數學用到矩陣乘法,高中雖然已教過,但如果學過線性代數會更清楚,在此無法多做說明,可參考註解一及註解二。

根據Apple原廠文件,transform 的資料類型 CGAffineTransform 格式如下,共有a, b, c, d, tx, ty等6個參數,a, b, c, d 對應二維座標(x, y)的變換,tx, ty 對應位移向量:


據此(並參考註解一),我們定義三個變換矩陣,注意其中「縮放矩陣」和「水平鏡像」的結構是一樣的,也就是說,若將圖案的垂直(y值)縮放「-1倍」,就會產生X軸的鏡像:
let 縮放矩陣 = CGAffineTransform(
a: 縮放/i, b: 0,
c: 0, d: 縮放/i,
tx: 0, ty: 0)
let 旋轉位移 = CGAffineTransform(
a: cos(角度), b:sin(角度),
c:-sin(角度), d: cos(角度),
tx: 寬-寬/i, ty: 高/i)
let 水平鏡像 = CGAffineTransform(
a: 1, b: 0,
c: 0, d: -1,
tx: 0, ty: 0)

仿射變換 transform 的操作都是以「原點」為軸心,因此我們先以左上角的螢幕原點為中心,畫出從螢幕左上角到右下角的葉片:
var 畫筆 = Path()                // 葉片:兩條二階貝茲曲線
畫筆.move(to: 左上角)
畫筆.addQuadCurve(to: 右下角, control: 控制點1)
畫筆.addQuadCurve(to: 左上角, control: 控制點2)

再用一個新圖層,鏡像複製一片對稱葉片:
圖層.drawLayer { 新圖層 in 
新圖層.transform = 水平鏡像
新圖層.fill(畫筆, with: .color(.green))
}

然後利用 for 迴圈控制變換矩陣的參數,畫出逐漸縮小的對稱葉片,一半葉片畫在原「圖層」,另一半鏡射葉片畫在「新圖層」:
let 縮放 = 0.2
let 角度 = -CGFloat.pi/4
let 層次 = 7.0
for i in stride(from: 1.4, to: 層次, by: 0.5) {
let 縮放矩陣 = CGAffineTransform(
a: 縮放/i, b: 0,
c: 0, d: 縮放/i,
tx: 0, ty: 0)
let 旋轉位移 = CGAffineTransform(
a: cos(角度), b:sin(角度),
c:-sin(角度), d: cos(角度),
tx: 寬-寬/i, ty: 高/i)
let 水平鏡像 = CGAffineTransform(
a: 1, b: 0,
c: 0, d: -1,
tx: 0, ty: 0)
圖層.drawLayer { 新圖層 in
新圖層.transform = 水平鏡像.concatenating(縮放矩陣).concatenating(旋轉位移)
新圖層.fill(畫筆, with: .color(.green))
}
圖層.transform = 縮放矩陣.concatenating(旋轉位移)
圖層.fill(畫筆, with: .color(.green))
}

在此,for 迴圈的變數範圍用全域函式 stride() 來計算,可用實數來當間距,對向量繪圖比較方便,也可避免上一節處理浮點小數誤差的問題。stride 是名詞也是動詞,有跨步、邁步、步伐、進展的意思。
for i in stride(from: 1.4, to: 層次, by: 0.5)

for 迴圈的變數 i 會從1.4開始,每次增加0.5,直到大於或等於「層次」(7.0)為止,共執行12次迴圈,產生12對葉片。這12對葉片,都是從原來兩條貝茲曲線所畫的葉片「仿射變換」而來,如下圖,原始葉片為虛線部分:


注意仿射變換 transform 的前後順序是有關係的,上面的:
圖層.transform = 縮放矩陣.concatenating(旋轉位移)

會先縮放,再旋轉位移。若改成:
圖層.transform = 旋轉位移.concatenating(縮放矩陣)

則變成先旋轉位移再縮放,將會出現完全不同的結果。

concatenating 是「前後相連」的意思,原形動詞為concatenate,字串或陣列的加法(+)基本上也是 concatenate,例如 “you” + “Tube” == “youTube”,原文就稱為 String concatenation,是有前後順序的。

💡 註解
  1. 變換矩陣的數學,可參考北一女中蘇鴻傑老師的簡介:平面上基本的線性變換:旋轉、鏡射、伸縮、推移,數學表達方式與原廠文件稍有不同,但兩者都正確。
  2. 有關仿射變換的數學說明,請參考交大周志成老師「線代啟示錄」
  3. CGAffineTransform 官方文件連結如下:https://developer.apple.com/documentation/coregraphics/cgaffinetransform
  4. 矩陣運算對2D/3D電腦繪圖非常重要,也是人工智慧(AI)背後的數學基礎,在工程各領域應用非常廣。
  5. 現在電腦的繪圖晶片(GPU)都有硬體矩陣運算的功能,速度比軟體運算快很多,而且可以多核心並行運算,因此使用矩陣變換會讓圖形運算速度加快。
  6. 仿射變換的功能,除了畫布圖層之外,在畫筆(Path)或視圖(View)也都有,語法稍有差異,但功能完全相同,畢竟底層都同樣來自Core Graphics,至於在哪個地方用比較好,就須因地制宜,沒有成規。
  7. for 迴圈為什麼不從1.0開始?縮放為什麼是0.2?角度改成 -CGFloat.pi/3 可以嗎?這些都可以動手試試看。
  8. 使用 stride() 不但可以用實數當間距,還可以往下數,不過要注意「邊界條件」,最後一個值(to: 末值)是不會用到的,也就是說若迴圈變數等於末值,會停止迴圈,並不會採用末值。參考以下測試:
    // Tested by Heman, 2022/08/27
    // https://developer.apple.com/documentation/swift/stride(from:to:by:)

    print("第一段")
    for i in stride(from: 0.0, to: 10.0, by: 2.0) {
    print(i)
    }
    print("第二段")
    for i in stride(from: 100.0, to: -100.0, by: -20.0) {
    print(i)
    }
第8課 正多邊形 — Shape

上一節提到的「仿射變換」,可說是「向量繪圖」的最佳拍檔,有了仿射變換,即使是以正規化座標繪製的圖案,透過縮放、旋轉、位移、鏡像等操作,也能在不同尺寸的螢幕上,獲得最適合的顯示效果。

而且前一節還提到,transform 是畫布「圖層」的一個屬性,而不是方法,這意謂著 transform 是隨時都發生作用的,而不需要額外呼叫。事實上,當我們呼叫圖層方法 translateBy() 來產生「位移」時,其實就是變更 transform 屬性內容而發生作用,其他 rotate() 與 scaleBy() 也是如此。

當畫布Canvas送兩個參數「圖層」與「尺寸」進到匿名函式時,就已初始化這兩個物件,所以一開始 transform 就有初始值,讀者可以自行嘗試用「print(圖層.transform)」來觀察。

另外,不知道大家有沒有注意到,在前面這幾課的畫布(Canvas)範例程式,使用畫筆(Path)描繪圖形的程式碼佔了大部分篇幅,畫完後要落到圖層顯示出來,反而只需幾行程式而已。

如果畫布裡面需要描繪的圖形較多,畫筆(Path)的程式碼會變得非常冗長,有沒有可能將畫筆(Path)物件單獨抽離出來呢?最好是能個別描繪好圖形,再到畫布中構圖擺放。

Shape 就是這樣的物件類型,專門蒐集Path畫筆所描繪出來的圖形物件,之後可以用於畫布或視圖中顯示出來。Shape 原意是形狀、外形,在本課稱為「圖形」,由「畫筆(Path)」的線條所組成的輪廓外形。

從語法來看,Shape 是 SwiftUI 課程到目前為止,除了視圖(View)之外學到的第2個「規範」(Protocol),在第2單元第1課(2-1b)提過,規範是一種較大的物件類型,對物件的屬性或方法有所規定。Shape 規範必須含有一個名為 path() 的物件方法,會回傳一個已畫好圖形的畫筆(Path)物件。

若比較 View 與 Shape 兩個規範,語法上有何不同呢? View 規範的標準句型,視圖內容要放在主體(body)屬性裡面:
struct 視圖: View {
var body: some View {
...
}
}

Shape 規範的標準句型如下,所有畫筆的操作,都置於 path() 方法之中:
struct 圖形: Shape {
func path(in 畫框: CGRect) -> Path {
...
var 畫筆 = Path()
...
return 畫筆
}
}

注意 path() 方法有個特別的參數,參數名稱為「畫框」(或別的名稱),「畫框」前面的 in 稱為參數「標籤」(label),當要呼叫 path() 時所傳入的參數,就可以用 path(in: 左框) 來取代 path(畫框: 左框),閱讀起來比較順暢,所以參數標籤可看成參數名稱的別名。

下圖定義一個符合圖形(Shape)規範的「正多邊形」,正多邊形頂點座標的計算方法,與前面第6課4-6a「圓周運動」完全相同:


可以看出,之前在畫布(Canvas)中用畫筆描繪的程式碼,完全可以移入 Shape 的 path() 方法裡面,唯一的差別,在於畫布左上角是畫布內區域座標的原點(0, 0),但是在 Shape 的 path() 得到的「畫框」則是相對位置,畫框左上角(origin)未必是原點,因此在計算位置時要加上左上角座標。

在以下範例程式,我們利用上圖定義的「正多邊形」Shape,分別落在畫布Canvas與視圖View中顯示出來,以比較兩者差異:


首先,畫布圖層用的是圖形的畫筆path()部分,可以指定圖形所在的(畫框)精確位置。而視圖用的是整個圖形物件,靠自動排版來安排畫框位置,用法比較簡單,不必費心計算座標,但變化較少。

完整範例程式如下:
// 4-8a 正多邊形 Shape
// Last Modified by Heman, 2022/05/22
import PlaygroundSupport
import SwiftUI

struct 正多邊形: Shape {
var 邊數: Int
init(_ n: Int = 3) { 邊數 = n < 3 ? 3 : n }
func path(in 畫框: CGRect) -> Path {
let 寬 = 畫框.width
let 高 = 畫框.height
let 半徑 = min(寬, 高) / 2
let 左上角 = 畫框.origin
let 中心 = CGPoint(
x: 左上角.x + 寬/2,
y: 左上角.y + 高/2)
let 圓心角弧度 = CGFloat.pi * 2 / CGFloat(邊數)
var 頂點陣列: [CGPoint] = []
for i in 0..<邊數 {
let 頂點 = CGPoint(
x: 中心.x + 半徑 * sin(圓心角弧度 * CGFloat(i)),
y: 中心.y - 半徑 * cos(圓心角弧度 * CGFloat(i)))
頂點陣列 += [頂點]
}
var 畫筆 = Path()
// 畫筆.move(to: 頂點陣列[0])
畫筆.addLines(頂點陣列)
畫筆.closeSubpath()
return 畫筆
}
}

struct 畫布: View {
var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 半幅 = CGSize(width: 寬/2, height: 高)
let 全框 = CGRect(origin: .zero, size: 尺寸)
let 左半框 = CGRect(origin: .zero, size: 半幅)
let 右半框 = CGRect(
origin: CGPoint(x: 中心.x, y: 0),
size: 半幅)
let 原點半框 = CGRect(
origin: CGPoint(x: -寬/4, y: -高/2),
size: 半幅)

圖層.fill(
正多邊形(5).path(in: 左半框),
with: .color(.cyan))
for i in 0...12 {
let 縮放 = pow(0.85, Double(i))
let 弧度 = Double(i) * CGFloat.pi/12 // 15°
let 縮放矩陣 = CGAffineTransform(
a: 縮放, b: 0,
c: 0, d: 縮放,
tx: 0, ty: 0)
let 旋轉位移 = CGAffineTransform(
a: cos(弧度), b:sin(弧度),
c:-sin(弧度), d: cos(弧度),
tx: 半幅.width*1.5, ty: 半幅.height*0.5) //右半框
圖層.transform = 縮放矩陣.concatenating(旋轉位移)
圖層.stroke(
正多邊形(6).path(in: 原點半框),
with: .color(.cyan),
lineWidth: 3)
}
}
}
}

struct 九宮格: View {
var body: some View {
Label("[SwiftUI]4-8a 正多邊形(Shape)", systemImage: "swift")
.font(.title)
.foregroundColor(.orange)
.padding()
畫布()
.border(.red)
let 寬高 = 120.0
let 欄位 = [GridItem(.adaptive(minimum: 寬高))]
LazyVGrid(columns: 欄位) {
ForEach(3..<12, id: \.self) { i in
正多邊形(i)
.frame(width: 寬高, height: 寬高)
.border(.red)
.foregroundColor(.green)
}
}
Text("(c)2022 Heman Lu")
.italic()
.opacity(0.3)
}
}

PlaygroundPage.current.setLiveView(九宮格())

主畫面的「九宮格」視圖包含了4個子視圖,視圖階層如下圖,其中Label 與 Text 都是由字體大小決定尺寸,Canvas 與 LazyVGrid 則沒有固定大小(若無 .frame() 指定的話),通常會將剩餘空間盡量配置出去,不過兩者並非平分,而是由Canvas取得大部分空間,剩下才給LazyVGrid,因此程式裡特別用 .frame() 指定 LazyVGrid 每個畫框尺寸。LazyVGrid 相關語法請參考第2單元第8課(2-8b)


在畫布右半框,我們利用上一課所學的「仿射變換」,將正六角形複製12次,每次縮小0.85倍並旋轉15度,做法就是先以畫布原點為中心,在「原點半框」中畫出六邊形,經過縮小旋轉後,平移到右半框,如下圖所示:


上圖用一個新的全域函式 pow(0.85, Double(i)) 計算 0.85的 i次方,pow 是 power (次方)的簡寫,0.85 稱為底數(base),i 稱為指數(exponent),這樣的指數運算又稱為乘冪或次方。

畫布Canvas透過仿射變換做出的效果,用視圖 ZStack, ForEach 加上縮放、旋轉等修飾語也可以做得出來,但可能沒有這般方便。

💡 註解
  1. Swift 的規範(Protocol)類型當然不只 View 與 Shape,第2單元2-7a介紹「傑森解碼器」時,提過 Codable, Identifiable 規範,第3單元提過 Gesture (3-5b), Error (3-8a)等規範,除此之外,還有上百種原廠定義的規範,例如與動畫相關的,還有 Animatable 規範,這些暫時無需深入了解。
  2. Swift 也允許程式設計師自行定義新規範,宣告指令就是 protocol,甚至對於現存的任何規範,也可用 extension 加以延伸,就像延伸現存物件類型一樣。
  3. 對SwiftUI來說,View 與 Shape 是其中最基本的兩個規範。
  4. 下面這段程式碼可以在主控台觀察「圖層.transform」的變化:
    // Tested by Heman, 2022/05/22
    import PlaygroundSupport
    import SwiftUI

    struct 畫布: View {
    var body: some View {
    Canvas { 圖層, 尺寸 in
    print("#1(default)", 圖層.transform)

    圖層.translateBy(x: 10, y: 10)
    print("#2(translated)", 圖層.transform)

    圖層.scaleBy(x: 2, y: 2)
    print("#3(scaled)", 圖層.transform)

    圖層.rotate(by: .degrees(90))
    print("#4(rotated)", 圖層.transform)

    圖層.transform = CGAffineTransform(
    a: 1, b: 0,
    c: 0, d: 1,
    tx: -10, ty: -10)
    print("#5(assigned)", 圖層.transform)
    }
    }
    }

    PlaygroundPage.current.setLiveView(畫布())
4-8b 漸層多角星(Gradient)

Shape 圖形規範的類型,是由畫筆(Path)描繪線條輪廓,就像在心中打草圖一樣,並不涉及色彩,通常也不會固定大小,而是等到畫布圖層或視圖之中顯示時,才決定具體的顏色與寬高尺寸,這樣可以讓 Shape 圖形提供最有彈性的運用。

上一節的正多邊形 Shape,稍作變化,就可變成多角星形,例如五邊形變成五角星,只要在邊線中點往內凹即可。程式碼僅需增加一組內凹的「內頂點」,原來頂點改稱「外頂點」,再加一個參數 r (半徑比)表示內凹程度,如下圖:


Shape 圖形的邊線與內部,在畫布圖層或視圖中可用 stroke() 與 fill() 分別上色,除了單色之外,還可以使用「漸層色」(Gradient),所謂「漸層」是指兩色或多色之間加以過渡所顯示的效果,怎麼做呢?以下範例將用同一組配色做出三種漸層效果,如下圖:


上面Shape圖形的顏色,均採用「藍-黃」雙色漸層,不過,在 SwiftUI 中有多種不同的漸層過渡方式,包括:

1. 線性漸層 linearGradient — 如上圖右下九宮格,均由左上角(藍)線性過渡到右下角(黃)
2. 放射狀漸層 radialGradient — 如上方五角星,由中心點(黃)向外放射過渡到邊線(藍)
3. 扇形或圓錐形漸層 conicGradient/angularGradient — 如16角星,由右側(藍)扇形過渡到左側(黃)
4. 橢圓漸層 ellipticalGradient — 橢圓放射狀的過渡,下一節(4-8c)再展示

套用漸層色可分成兩個步驟,第一、先用 Gradient() 物件定義好漸層色,第二、再指定上述過渡方式。用法是不是跟第1課動畫(Animation)很像?例如:
let 黃藍漸層 = Gradient(colors: [.yellow, .blue])
let 藍黃藍 = Gradient(colors: [.blue, .yellow, .blue])

圖層.fill(
多角星(5, r: 0.75).path(in: 左半框),
with: .radialGradient(
黃藍漸層,
center: 左中心,
startRadius: .zero,
endRadius: 半徑))
圖層.fill(
多角星(16, r: 0.9).path(in: 右半框),
with: .conicGradient(藍黃藍, center: 右中心))

先以 Gradient 物件定義由黃、藍雙色構成「黃藍漸層」,Gradient 的參數為一組顏色陣列,裡面可任意包含2個以上的顏色。

再以「黃藍漸層」加到放射狀漸層 radialGradient() 之中即可,放射狀漸層還須額外指定中心點與半徑等參數。

16角星是用圓錐形漸層 conicGradient,conic 就是圓錐形的意思,也可以想成是環形或扇形,顏色會從右方(三點鐘方向)沿著扇形過渡,最後回到右方,顏色陣列的頭尾最好同色,否則會出現很突兀的顏色界線,因此採用「藍黃藍」三色漸層。

線性漸層的程式碼如下,在視圖中畫9個多角星,每個背景均設為藍黃漸層,linearGradient 須指定線性過渡的起點與終點,分別用 .topLeading 指定左上角,以及 .bottomTrailing 右下角:
let 藍黃漸層 = Gradient(colors: [.blue, .yellow])
ForEach(3..<12, id: \.self) { i in
多角星(i, r: 0.4)
.frame(width: 寬高, height: 寬高)
.border(.red)
.foregroundColor(.white)
.background(
.linearGradient(
藍黃漸層,
startPoint: .topLeading,
endPoint: .bottomTrailing))
}

以下為完整範例程式,其中額外加上一點動畫效果:在畫布中,將「黃藍漸層」的黃色,改成動態的 RGB 顏色,透過時間軸 TimelineView,從最亮的黃色(r: 1, g: 1, b: 0)到最暗的黑色(r: 0, g: 0, b: 0)之間來回變化。
// 4-8b 漸層多角星(Gradient)
// Last modified by Heman, 2022/05/25
import PlaygroundSupport
import SwiftUI

struct 多角星: Shape {
let 邊數: Int
let 半徑比: CGFloat
init(_ n: Int = 3, r: CGFloat) {
邊數 = n < 3 ? 3 : n ; 半徑比 = r
}
func path(in 畫框: CGRect) -> Path {
let 寬 = 畫框.width
let 高 = 畫框.height
let 左上角 = 畫框.origin
let 半徑 = min(寬, 高) / 2
let 中心 = CGPoint(x: 左上角.x + 寬/2, y: 左上角.y + 高/2)
let 圓心角弧度 = CGFloat.pi * 2 / CGFloat(邊數)

var 頂點陣列: [CGPoint] = []
for i in 0..<邊數 {
let 外頂點 = CGPoint(
x: 中心.x + 半徑 * sin(圓心角弧度 * CGFloat(i)),
y: 中心.y - 半徑 * cos(圓心角弧度 * CGFloat(i)))
頂點陣列 += [外頂點]
let 內頂點 = CGPoint(
x: 中心.x + 半徑 * 半徑比 * sin(圓心角弧度*CGFloat(i) + 圓心角弧度/2),
y: 中心.y - 半徑 * 半徑比 * cos(圓心角弧度*CGFloat(i) + 圓心角弧度/2))
頂點陣列 += [內頂點]
}
var 畫筆 = Path()
// 畫筆.move(to: 頂點陣列[0])
畫筆.addLines(頂點陣列)
畫筆.closeSubpath()
return 畫筆
}
}

struct 漸層畫布: View {
let 時間: Date
@State var 顏色係數 = 0.0
@State var 遞減 = false
var body: some View {
let 層次黃 = Color(red: 顏色係數, green: 顏色係數, blue: 0)
let 黃藍漸層 = Gradient(colors: [層次黃, .blue])
let 藍黃藍 = Gradient(colors: [.blue, 層次黃, .blue])
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 半徑 = min(寬, 高) / 2
let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 半幅 = CGSize(width: 寬/2, height: 高)
let 左半框 = CGRect(origin: .zero, size: 半幅)
let 左中心 = CGPoint(x: 寬*0.25, y: 高*0.5)
let 右半框 = CGRect(origin: CGPoint(x: 寬/2, y: 0), size: 半幅)
let 右中心 = CGPoint(x: 寬*0.75, y: 高*0.5)
圖層.fill(
多角星(5, r: 0.75).path(in: 左半框),
with: .radialGradient(
黃藍漸層,
center: 左中心,
startRadius: .zero,
endRadius: 半徑))
圖層.fill(
多角星(16, r: 0.9).path(in: 右半框),
with: .conicGradient(藍黃藍, center: 右中心))
}
.onChange(of: 時間) { _ in
if 顏色係數 > 1.0 {
遞減 = true; 顏色係數 = 1.0
} else if 顏色係數 < 0.0 {
遞減 = false; 顏色係數 = 0.0
}
顏色係數 = 遞減 ? 顏色係數 - 0.01 : 顏色係數 + 0.03
// print(漸變係數)
}
}
}

struct 漸層多角星: View {
var body: some View {
Label("[SwiftUI]4-8b 漸層多角星", systemImage: "swift")
.font(.title)
.foregroundColor(.orange)
.padding()
TimelineView(.animation) { 時間參數 in
漸層畫布(時間: 時間參數.date)
//.frame(height: 300)
.border(.red)
}
let 寬高 = 120.0
let 欄位 = [GridItem(.adaptive(minimum: 寬高))]
let 藍黃漸層 = Gradient(colors: [.blue, .yellow])
LazyVGrid(columns: 欄位) {
ForEach(3..<12, id: \.self) { i in
多角星(i, r: 0.4)
.frame(width: 寬高, height: 寬高)
.border(.red)
.foregroundColor(.white)
.background(
.linearGradient(
藍黃漸層,
startPoint: .topLeading,
endPoint: .bottomTrailing))
}
}
Text("(c)2022 Heman Lu")
.italic()
.opacity(0.3)
}
}

PlaygroundPage.current.setLiveView(漸層多角星())


動態漸層色的效果顯示如下:
4-8c 生命之花 Flower of Life

無意中在網路上看到 “Flower of Life”,搜尋圖片後發現是一種正六角形與圓形構成的圖案,有人認為就像費波那契數,其中也隱含生命的密碼,具有不可思議的奧妙,甚至維基百科也有相關條目,如下圖。


仔細觀察「生命之花」,乍看之下似乎相當複雜,若在紙上用圓規直尺來畫,可能要費點功夫。但明顯有規律可循,利用我們學過的圓與正多邊形,應該也可用 SwiftUI 程式畫出來,最終花費幾天時間做成 Shape 圖形,並且還從六邊形延伸到任意多邊形。

首先,圖案最內圈是一個小圓,小圓半徑恰為大圓的1/3;第二圈是以小圓內接正六邊形頂點為圓心,再各畫一個小圓,中間形成六個花瓣的圖案,很像單子葉植物「蔥蘭」的花形:


上圖「頂點陣列」共得到7個點(中心點加六邊形頂點),以其為圓心畫出7個小圓(半徑為大圓的1/3)。接下來就多出6個部分重疊的正六邊形,同樣要計算所有頂點,加入頂點陣列中。為了方便重複使用,改以函式計算頂點座標:


第三圈會多出12個頂點,連內圈一共19個頂點當圓心,可畫出19個小圓,如下圖:


重複以上過程就可往外蔓延,圖案會越來越繁複,程式裡面將圈數設為變數屬性(改名「層次」,中心點算第一層),另外,利用本課4-8a學過的「正多邊形」,將「邊數」同樣設為屬性,六邊形就可改成任意多邊形,同樣可以計算頂點,以正12邊形為例,畫出來的圖案更令人驚嘆,這應該很難用手工繪出:


最後,我們再給背景加上漸層色,若比較素色(無背景)、單色背景與漸層色,雖然都是同樣圖案,是不是感覺氛圍就有所不同?色彩與聲音一樣,對人的影響相當大,所以在設計App時,千萬不要忽略色彩與音效,不過程式設計師大多不擅長配色與音樂,最好找朋友一起創作。


以下為最後版本的完整範例程式:
// 4-8 生命之花 Flower of Life
// Last Modified by Heman, 2022/05/29
import PlaygroundSupport
import SwiftUI

struct 生命之花: Shape {
var 邊數 = 6
var 層次 = 4
func 計算頂點(_ n: Int, center: CGPoint, r: CGFloat) -> [CGPoint] {
let 圓心角弧度 = CGFloat.pi * 2 / Double(n)
var 座標陣列: [CGPoint] = []
for i in 0 ..< n {
let 座標 = CGPoint(
x: center.x + r * sin(圓心角弧度 * Double(i)),
y: center.y - r * cos(圓心角弧度 * Double(i)))
座標陣列 += [座標]
}
return 座標陣列
}
func path(in 畫框: CGRect) -> Path {
let 寬 = 畫框.width
let 高 = 畫框.height
let 原點 = 畫框.origin
let 大圓半徑 = min(寬, 高) / 2.0
let 小圓半徑 = 大圓半徑 / Double(層次)
let 中心 = CGPoint(x: 原點.x + 寬/2, y: 原點.y + 高/2)
let 圓心角弧度 = CGFloat.pi * 2 / Double(邊數)
var 頂點陣列: [CGPoint] = [中心]
var 圈數 = 層次 - 1
while 圈數 > 0 {
var 暫存: [CGPoint] = []
for 頂點 in 頂點陣列 {
暫存 += 計算頂點(邊數, center: 頂點, r: 小圓半徑)
}
for 座標 in 暫存 {
if !頂點陣列.contains(座標) { 頂點陣列.append(座標) }
}
// print(頂點陣列)
圈數 -= 1
}
var 畫筆 = Path()
for 圓心 in 頂點陣列 {
畫筆.move(to: CGPoint(x: 圓心.x + 小圓半徑, y: 圓心.y))
畫筆.addArc(
center: 圓心,
radius: 小圓半徑,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false)
}
return 畫筆
}
}

struct 漸層背景: View {
var body: some View {
let 晚霞 = Gradient(colors: [.yellow, .indigo])
let 深空 = Gradient(colors: [.black, .blue])
let 橢圓漸層 = EllipticalGradient(gradient: 晚霞)
let 放射漸層 = RadialGradient(
gradient: 深空,
center: .center,
startRadius: 0,
endRadius: 500)

Label("[SwiftUI]4-8c 生命之花", systemImage: "swift")
.font(.title)
.foregroundColor(.orange)
.padding()
生命之花()
.stroke(.black, lineWidth: 1.5)
.background(橢圓漸層)
Spacer()
生命之花(邊數: 12, 層次: 3)
.stroke(.yellow, lineWidth: 1)
.background(放射漸層)
Text("(c)2022 Heman Lu")
.italic()
.opacity(0.3)
}
}

PlaygroundPage.current.setLiveView(漸層背景())

這次特別示範使用「橢圓漸層」,中心黃色—外層靛紫色(indigo),漸層色彩命名為「晚霞」,搭配六邊形生命之花有種黃昏朦朧的美感,相當合適:
let 晚霞 = Gradient(colors: [.yellow, .indigo])
let 橢圓漸層 = EllipticalGradient(gradient: 晚霞)
生命之花()
.background(橢圓漸層)

另外,程式還用到一個新語法,其實非常簡單,不必解說也能看懂:
if !頂點陣列.contains(座標) { 頂點陣列.append(座標) }

如果「頂點陣列」中已含有目前的頂點座標,就不必再重複加入,如果沒有,才加到末尾。contains() 與 append() 都是陣列物件的方法,陣列的 append() 其實與陣列加法(+)是一樣的,上面這行也可寫成我們慣用的寫法:
if !頂點陣列.contains(座標) { 頂點陣列 = 頂點陣列 + [座標] }
// 或是
if !頂點陣列.contains(座標) { 頂點陣列 += [座標] }
// 或是
頂點陣列 += 頂點陣列.contains(座標) ? [] : [座標]


💡 註解
  1. cyan 與 indigo 都有人翻譯為「靛青」色,實在不好區分,兩者都以藍色為底,cyan 比較偏綠,indigo 偏紫。
端午節愉快

第4單元已經接近尾聲,不知不覺已經過了三個月,最後兩課預計寫個App來綜合前面所學,需要大約兩週時間來構想,預計六月中開始更新。

下週(台灣時間6/7 Tue凌晨) Apple 召開 WWDC 2022 全球發表會,很值得期待。前陣子發佈 Swift Playgrounds 4.1,用 Mac 版已可以產出 macOS App,實測結果還不錯,本課程所學的程式,幾乎都可以順利產出App。

目前Swift Playgrounds 不管是 Mac版或 iPad版,都已經可以用來創作App,對初學者來說,是非常友善的開發環境,值得持續投入,相信今年的 WWDC 也會對 SwiftUI 與 Swift Playgrounds 持續改善,讓我們一起期待。
第9課 圖片輪播(Carousel)

圖片輪播是個非常實用的動畫效果,在網站或App應用中經常看到,很適合拿來播放廣告或展示商品,每次只顯示一個畫面,停留一段時間後,自動滑入下一個畫面。為了要達到最好的顯示效果,通常圖片會盡量佔滿整個螢幕,文字或操作按鈕浮在圖片上層,類似抖音(TikTok)的做法。

聽起來很簡單,不過實際做起來卻不容易,關鍵之處在於下一張水平滑入時,與前一張一起移動的距離,恰好到螢幕寬度(若是垂直滑入,則是螢幕高度)。所以跳轉時必須計算螢幕的寬或高,慢慢滑入下一張圖片,以 SwiftUI 現有的排版視圖,包括 ScrollView, LazyVGrid, List…等都無法做到理想的圖片輪播。

本課我們嘗試利用畫布 Canvas 來製作圖片輪播的效果。

圖片內容從哪裡來呢?有兩種來源。在第2單元,我們是將圖片檔案導入 Swift Playgrounds,與App包在一起;在第3單元則是直接從網路上抓圖,為方便起見,本課利用第3單元學過的網路程式 URLSession 來抓圖。

4-9a Canvas 圖片下載

我們還未曾在畫布Canvas 中用過網路程式,如果還記得第3單元課程,其實配合起來非常簡單,參考「表3-8 非同步+錯誤處理指令」,使用非同步的 URLSession.shared.data() 下載圖片,可以用 Task-await + do-try-catch 的句型,如下:
let 網址 = "https://picsum.photos/1080/1920"
Task {
if let myURL = URL(string: 網址) {
do {
let (內容, 回應碼) = try await URLSession.shared.data(from: myURL)
if let 轉圖 = UIImage(data: 內容) {
...
}
} catch {
print("有錯誤發生")
}
}
}

這段程式碼會從「網址」隨機下載一張寬1080x高1920解析度的圖片資料,剛下載的「內容」稱為原始資料(raw data),須經過 UIImage() 轉換成圖片格式(如果是JSON資料,則透過傑森解碼器轉換),才可用 Image 視圖或畫布 Canvas 顯示出來。

上面句型是命令式語法,若要用在視圖中,只須將 Task 改成 .task 修飾語即可,.task 相當於 .onAppear 的非同步版本,因此會在視圖一出現時就執行。以下程式我們先讓畫布顯示一個空的「圖片集」,然後透過 .task 到網址下載圖片,等圖片加入「圖片集」,因為是狀態變數,會自動更新視圖,將圖片顯示出來。

完整程式碼如下:
// 4-9a Canvas 圖片下載
// Created by Heman, 2022/06/17
import PlaygroundSupport
import SwiftUI

let 網址 = "https://picsum.photos/1080/1920"
// 備用網址:
// let 網址 = "https://source.unsplash.com/collection/1027750/1080x1920"

struct 畫布: View {
@State var 圖片集: [UIImage] = []

var body: some View {
Canvas { 圖層, 尺寸 in
let 中心 = CGPoint(x: 尺寸.width/2, y: 尺寸.height/2)
if 圖片集 == [] {
圖層.draw(Text("等待圖片下載..."), at: 中心, anchor: .center)
} else {
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)
.task {
if let myURL = URL(string: 網址) {
do {
let (內容, 回應碼) = try await URLSession.shared.data(from: myURL)
print(回應碼)
if let 轉圖 = UIImage(data: 內容) {
圖片集.append(轉圖)
}
} catch {
print("無法下載圖片")
}
}
}
}
}

PlaygroundPage.current.setLiveView(畫布())


畫布在一開始(圖片集 == [])會顯示一段文字:"等待圖片下載...",這是使用圖層的 draw() 來顯示,參數 at: 指定畫布中的一個點座標,這個位置會對準整行文字的中央,也就是第3個參數 anchor: .center 的用途,anchor 是船錨或錨定的意思。
if 圖片集 == [] {
圖層.draw(Text("等待圖片下載..."), at: 中心, anchor: .center)
}

接下來 .task 會立刻發生作用,到「網址」下載圖片資料,轉成 UIImage 格式,加入「圖片集」陣列中,準備顯示到畫布中。

畫布同樣用「圖層.draw()」來顯示圖片,不過我們必須將圖片(維持寬高比例)縮放到螢幕尺寸,相當於過去直接用 Image 視圖的習慣用法:
Image(uiImage: 圖片集.first!)
.resizable()
.scaledToFit()

要達到同樣效果,在Canvas中得自己計算縮放的比例,先用「圖層.resolve()」來解析圖片尺寸,如果圖片寬或高大於螢幕,就都按照較小的比例縮小,若圖片寬高都小於螢幕,則不必縮放,圖層縮放的方法為「圖層.scaleBy()」:
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)
}

第7課4-7d提過,畫布圖層的縮放或旋轉都是以左上角(原點)為軸心,因此縮放完還必須加上位移,用「圖層.translateBy()」將圖片(以中央為錨點)從原點移到畫布中心點。

注意上面程式碼中,位移「圖層.translateBy()」反而放在縮放「圖層.scaleBy()」之前,因為如果次序反過來,位移值會同樣被縮小,這種情況不好debug,只能用 print(圖層.transform)在主控台觀察到。當然,如果像4-7d直接設定 transform 變換矩陣,就不會有這個問題。

最後執行結果如下:


💡 註解
  1. Carousel 源自法文,即旋轉木馬(通俗說法為“merry-go-round”)或機場的行李轉盤,因為會一直循環轉圈,所以循環播放圖片或影片的效果,就叫 Carousel (輪播)。
  2. 在程式內取得圖片還有第三個來源,就是「相簿」,只要是 Apple 產品,包括 Mac, iPad, iPhone, TV, Watch 等,都有內建「相簿」功能。不過遺憾的是,SwiftUI 過去並沒有取用相簿的功能,必須借助舊的 UIKit 來橋接,但今(2022)年WWDC終於增加了 PhotosPicker 視圖,讓 SwiftUI 可以直接取用相簿,等年底 Swift Playgrounds 新版發行之後,我們再來測試。
4-9b 網路圖片輪播(Carousel)

本節我們正式做一個往左滑動的圖片輪播,需要三個程式段落:

1. 下載多張圖片
2. 將多張圖片水平排列,各自間隔一個螢幕寬度
3. 往左移動圖片,到一個螢幕寬度時停止移動

類似下圖的效果:


第一步是最簡單的,知道如何在Canvas下載一張圖片後,當然就可用 for 迴圈下載多張圖片,由於 URLSession 非同步的特性,多張圖片會同時開始連線(但不會同時完成),陸續下載後加入「圖片集」陣列中:
.task {
let 圖片數 = 5
if let myURL = URL(string: 網址) {
do {
for i in 0..<圖片數 {
let (內容, 回應碼) = try await URLSession.shared.data(from: myURL)
// print(回應碼)
if let 轉圖 = UIImage(data: 內容) {
圖片集.append(轉圖)
} else {
print("下載資料無法轉成圖片")
}
}
} catch {
print("網站連線錯誤")
}
}

第二步也不難,在畫布Canvas中同樣用 for 迴圈與「圖層.draw()」顯示多張圖片,利用圖層位移 translateBy() 將圖片隔開,彼此距離恰等於一個螢幕寬度。由於每個圖層只能有一個變換矩陣,所以每張圖透過「圖層.drawLayer」顯示在不同圖層中:
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秒就滑到定位:
struct 圖片輪播: View {
var body: some View {
TimelineView(.animation) { 時間參數 in
畫布(更新: 時間參數.date)
}
}
}

struct 畫布: View {
let 更新: Date
@State var 圖片集: [UIImage] = []
@State var 百分比: CGFloat = 0.0

var body: some View {
Canvas { 圖層, 尺寸 in
...
let 移動距離 = 尺寸.width * 百分比
圖層.drawLayer { 新圖層 in
新圖層.translateBy(
x: 中心.x + 尺寸.width * CGFloat(i) - 移動距離,
y: 中心.y)
...
}
}
.onChange(of: 更新) { _ in
let 移動速率 = 0.05
百分比 += 移動速率
}
}
}

注意這裡仿照4-7b正規化座標,每次更新的是寬度「百分比」,因為在 .onChange 裡面無法得知實際的螢幕寬度。

那麼,如何滑到定位後停頓一段時間呢?解法是曾在3-2d淡出淡入及4-1b文字閃爍用過的小技巧,那時將透明度(opacity)最大設為1.2,這樣就可以在完全不透明時(opacity≥1.0)多停留一會,因為透明度若超過1.0,效果是跟1.0一樣的。

所以我們在 .onChange 中,讓「百分比」超過100%(即1.0),一直增加到1000%(即10.0)才重新歸零,如此一來,百分比 ≥ 1.0的時間,會維持約3秒鐘:
 .onChange(of: 更新) { _ in
let 輪播週期 = 10.0
let 移動速率 = 0.05
if 百分比 > 輪播週期 {
百分比 = 0.0
} else {
百分比 += 移動速率
}
}

與此配合的,是在畫布中計算圖層位移時,「百分比」超過1.0的部份都以螢幕寬度為移動距離,只需改一行程式:
let 移動距離 = 百分比 < 1.0 ? 尺寸.width * 百分比 : 尺寸.width
圖層.drawLayer { 新圖層 in
新圖層.translateBy(
x: 中心.x + 尺寸.width * CGFloat(i) - 移動距離,
y: 中心.y)
...
}

最後,剩下一個小問題,整個「圖片集」輪播完畢後,如何回到開頭循環播放呢?可能有多種解法,這裡用較簡便的方法,並不更動陣列索引,而是將顯示過的第一張移到陣列末尾:
.onChange(of: 更新) { _ in
let 輪播週期 = 10.0
let 移動速率 = 0.05
if 百分比 > 輪播週期 { // 替換下一張
張次 = (張次 + 1) % 圖片集.count
if let 首張 = 圖片集.first { // 將第一張移至最後
圖片集.removeFirst()
圖片集.append(首張)
}
百分比 = 0.0
} else {
百分比 += 移動速率
}
}

為了清楚顯示輪播的進度,通常會在最下方做一個指標,以顯示目前輪播的畫面次序,需要多一個狀態變數「張次」來控制,指標就用String或AttributedString來做即可:
圖層.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: 底部)
}

這樣就大功告成了,若能再補上一些文字、滑動手勢或操作按鈕,就是一個類似抖音或Instagram的App了。完整的程式碼如下:
// 4-9b 網路圖片輪播
// Updated by Heman, 2022/06/20
import PlaygroundSupport
import SwiftUI

let 網址 = "https://picsum.photos/1080/1920"
// 備用網址:
// let 網址 = "https://source.unsplash.com/random"

struct 畫布: View {
let 更新: Date
@State var 圖片集: [UIImage] = []
@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 {
百分比 += 移動速率
}
}
}
.task {
let 圖片數 = 5
if let myURL = URL(string: 網址) {
do {
for i in 0..<圖片數 {
let (內容, 回應碼) = try await URLSession.shared.data(from: myURL)
// print(回應碼)
if let 轉圖 = UIImage(data: 內容) {
圖片集.append(轉圖)
} else {
print("下載資料無法轉成圖片")
}
}
} catch {
print("網站連線錯誤")
}
}
}
}
}

struct 圖片輪播: View {
var body: some View {
TimelineView(.animation) { 時間參數 in
畫布(更新: 時間參數.date)
}
}
}

PlaygroundPage.current.setLiveView(圖片輪播())

以下是加上「拖曳手勢」的示範影片,拖曳時會暫停計時,並可明顯看出前一張與下一張圖片的排列位置:


💡 註解
  1. 有可能改成垂直輪播嗎?其實很簡單,只要更動3行程式碼,將計算平移的 width 改成 height,放在y座標即可。
  2. 在Canvas中非常容易犯一個錯誤,就是在Canvas中直接修改狀態變數(@State var),這是SwiftUI不允許的,其他View視圖都不允許命令式語法,所以會被提示錯誤,只有Canvas可用命令式語法,因此不容易檢查出來。
  3. 這是由於SwiftUI背後更新視圖(refresh View)的機制,SwiftUI 不允許在視圖主體(body)中修改狀態變數,而只能在事件修飾語中才可更改。
  4. 以下程式在 Canvas 中變更狀態變數,語法上都正確,但從執行結果可以看出,Canvas 中修改狀態變數並不會生效。
    // Tested by Heman, 2022/06/22
    import PlaygroundSupport
    import SwiftUI

    struct 狀態變數實驗: View {
    @State var x: Int = 0
    var body: some View {
    Text("x = \(x)")
    .font(.largeTitle)
    .onAppear {
    print("x in onAppear = \(x)")
    x = 10
    }
    .onChange(of: x) { _ in
    print("x in onChange = \(x)")
    x = 20
    }
    Canvas { 圖層, 尺寸 in
    let 中心 = CGPoint(x: 尺寸.width/2, y: 尺寸.height/2)
    x = 30
    print("x in Canvas = \(x)")
    圖層.draw(Text("x in Canvas = \(x)"), at: 中心)
    }
    }
    }

    PlaygroundPage.current.setLiveView(狀態變數實驗())
雪白西丘斯

本文第一步解說有誤,for 迴圈之中,圖片(URLSession.shared.data)並不會同時開始連線,而是依序連線(前一張下載完畢,才開始下一張連線),想想看為什麼呢?

2024-08-22 0:13
4-9c 手動輪播(一)

不知道大家有沒有發現,iPhone 主畫面左右滑動切換頁面,或是抖音用上下滑動切換頁面的操作,與上一節圖片輪播非常類似,只是自動輪播改成手動輪播。手動輪播要如何做呢?

如果仔細觀察iPhone主畫面或抖音的手勢操作,會發現一個關鍵細節:
-- 若滑動速率不夠,會返回原畫面
-- 當滑動快慢超過一定速率,才會繼續自動滑到下一頁

因此,手動輪播其實有兩段行程,第一段是用「拖曳」手勢,控制左右滑動或上下滑動;當拖曳手勢結束時,若滑動速率不夠,則回到原畫面,若速率夠快,則啟動第二段行程,自動繼續滑到下一頁停止。概念如下圖:


第一段手動行程比較簡單,用「拖曳」手勢來控制畫布的左右滑動,當手勢結束時,測量水平滑動的速度,若速率小於300(點/秒),則回到起始點,若大於300(點/秒)則停在原處不動(接續第二段自動行程)。

第一段手動行程的完整程式如下:
// 4-9c 手動輪播(拖曳手勢) -- 第一段
// Created by Heman, 2022/06/24
import PlaygroundSupport
import SwiftUI

struct 畫布: View {
@State var 拖曳位移: CGSize = .zero
@State var 拖曳開始 = Date()
var 拖曳: some Gesture {
DragGesture()
.onChanged { 拖曳參數 in
if 拖曳位移 == .zero { 拖曳開始 = 拖曳參數.time }
拖曳位移 = 拖曳參數.translation
}
.onEnded { 拖曳參數 in
let 時間差 = 拖曳參數.time.timeIntervalSince(拖曳開始)
let 速度 = 拖曳參數.translation.width / 時間差
if abs(速度) < 300 {
拖曳位移 = .zero
}
// print(時間差, 拖曳參數.translation)
// print(速度)
}
}
var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 半徑 = min(寬/2, 高/2)
let 中心 = CGPoint(x: 寬/2, y: 高/2)
圖層.translateBy(x: 拖曳位移.width, y: 0)
var 畫筆 = Path()
for i in [-1.0, 0.0, 1.0] {
let 圓心 = CGPoint(x: 中心.x + 寬*i, y: 中心.y)
let 起點 = CGPoint(x: 中心.x + 寬*(i+0.5), y: 中心.y)
畫筆.move(to: 起點)
畫筆.addArc(
center: 圓心,
radius: 半徑,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false)
}
圖層.stroke(畫筆, with: .color(.cyan), lineWidth: 20.0)
}
.border(.red)
.gesture(拖曳)
Circle()
.frame(height: 200)
.border(.blue)
.foregroundColor(.blue)
.offset(拖曳位移)
.gesture(拖曳)
}
}

PlaygroundPage.current.setLiveView(畫布())

先看看執行的過程:


上方較大的空心圓是用畫布(Canvas)所繪,下方較小的實心圓則是Circle()視圖,兩者都顯示視框邊界(.border()),也同樣套用「拖曳」手勢(.gesture()),可以藉此觀察到幾個特點:
  1. 手勢是作用在整個視框範圍,而不是只有圖形上。
  2. 拖曳時,兩個圓形共享「拖曳位移」,所以會一起移動。
  3. 下方Circle()透過.offset()套用拖曳位移,隨著手勢上下左右均可活動,而且是整個視框移動。
  4. 上方空心圓形的移動,是透過畫布的計算,將拖曳位移轉為圖層位移,只取其水平值(x: 拖曳位移.width),所以只會左右移動,而且移動時視框範圍不會跑掉。


如何計算滑動速度呢?在第3單元3-5b曾詳細說明過拖曳手勢,在此定義一個手勢變數「拖曳」如下,傳入匿名函式的「拖曳參數」會包含位移與時間資訊,在一開始先取得「拖曳開始」的時間,當拖曳結束時,再計算「時間差」,將水平位移除以時間差,就得到水平移動的速度。
@State var 拖曳位移: CGSize = .zero
@State var 拖曳開始 = Date()
var 拖曳: some Gesture {
DragGesture()
.onChanged { 拖曳參數 in
if 拖曳位移 == .zero { 拖曳開始 = 拖曳參數.time }
拖曳位移 = 拖曳參數.translation
}
.onEnded { 拖曳參數 in
let 時間差 = 拖曳參數.time.timeIntervalSince(拖曳開始)
let 速度 = 拖曳參數.translation.width / 時間差
if abs(速度) < 300 {
拖曳位移 = .zero
}
}
}

要注意這裡拖曳參數的「位移(translation)」是有正負值的,跟螢幕座標一樣,往左為負,往右為正。因此我們用絕對值 abs(速度) < 300,表示不管往左或往右,只要移動速率小於300(點/秒),就將「拖曳位移」歸零,讓圖形回到起始位置,否則就留在原地不動。

在畫布內畫出三個空心圓,彼此左右相隔一個螢幕寬度,圖形的移動則是利用「圖層.translateBy()」取拖曳位移的寬(有正負值),因此只會左右移動。
圖層.translateBy(x: 拖曳位移.width, y: 0)
var 畫筆 = Path()
for i in [-1.0, 0.0, 1.0] {
let 圓心 = CGPoint(x: 中心.x + 寬*i, y: 中心.y)
let 起點 = CGPoint(x: 中心.x + 寬*(i+0.5), y: 中心.y)
畫筆.move(to: 起點)
畫筆.addArc(
center: 圓心,
radius: 半徑,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false)
}


💡 註解
  1. 本節「速度」與「速率」採用物理的定義,速度是向量,有方向性,速率是純量,只論大小,無正負之分。
  2. 為什麼臨界速率是300點/秒呢?這是筆者測試後選定的,目前 iPhone 螢幕解析度約300dpi左右,也就是一英吋300點(pixel),一英吋等於2.54公分,所以300點/秒相當於一秒鐘滑動2.54公分,是較緩慢但不至於停頓的速率。
  3. 手勢套用在整個視圖是很重要的特點,也就是說,若在畫布中畫出多個圖形,並無法讓每個圖形都套用各自的手勢。
4-9d 手動輪播(二)

接續上一節,當滑動速度超過一定速率(300點/秒)時,就開始第二段行程,自動滑入到下一頁,也就是往左或往右移動到一個螢幕寬度後停止。

自動滑入的做法,可以仿照4-9b,利用螢幕寬度的「百分比」來控制移動距離,不過,由於第一段已走了一部分距離(即最後的「拖曳位移」),所以判斷是否到達一個螢幕寬度,要將「拖曳位移.width + 螢幕寬 * 百分比」加總起來:
var 水平位移加總 = 拖曳位移.width + 寬 * 百分比
if abs(水平位移加總) > 寬 { 水平位移加總 = 往右嗎 ? 寬 : -寬 }

要記得「拖曳位移」是有方向性的,往右為正,因此我們要增加一個狀態變數「往右嗎」來指示往右滑還是往左滑,根據往左或往右將「百分比」加上正負,並且令「水平位移加總」不超過一個螢幕寬度。

拖曳手勢也要相對修改,以判斷往左滑或往右滑,若拖曳位移為正,就是往右滑,位移為負就是往左滑;當拖曳手勢的滑動速率超過300點/秒時,我們就啟動「自動模式」:
@State var 拖曳位移: CGSize = .zero
@State var 拖曳開始 = Date()
@State var 自動模式 = false
@State var 往右嗎 = false
@State var 百分比 = 0.0
let 時間: Date
var 拖曳: some Gesture {
DragGesture()
.onChanged { 拖曳參數 in
if 拖曳位移 == .zero { // 開始拖曳
拖曳開始 = 拖曳參數.time
百分比 = 0.0 // 百分比在此歸零
}
拖曳位移 = 拖曳參數.translation
}
.onEnded { 拖曳參數 in
let 時間差 = 拖曳參數.time.timeIntervalSince(拖曳開始)
let 速度 = 拖曳參數.translation.width / 時間差
if 速度 < -300 {
自動模式 = true // 開啟自動模式,往左
往右嗎 = false
} else if 速度 < 300 {
自動模式 = false // 手動模式,歸零
拖曳位移 = .zero
} else {
自動模式 = true // 開啟自動模式,往右
往右嗎 = true
}
}
}

自動模式仍靠時間軸視圖(TimelineView)來驅動,當時間更新時,「百分比」的更新速率要根據往右或往左,加上正負符號;不管往左或往右,「百分比」最多算到100%(即±1.0),然後歸零回到手動模式:
.onChange(of: 時間) { _ in
let 更新速率 = 往右嗎 ? 0.05 : -0.05
if abs(百分比) > 1.0 {
自動模式 = false
拖曳位移 = .zero
} else {
百分比 += 自動模式 ? 更新速率 : 0.0
}
}

這樣就完成手動輪播了,往左或往右滑都可以,完整程式碼如下:
// 4-9d 手動輪播(拖曳手勢) -- 完整版
// Revised by Heman, 2022/06/27
import PlaygroundSupport
import SwiftUI

struct 畫布: View {
@State var 拖曳位移: CGSize = .zero
@State var 拖曳開始 = Date()
@State var 百分比 = 0.0
@State var 自動模式 = false
@State var 往右嗎 = false
let 時間: Date
var 拖曳: some Gesture {
DragGesture()
.onChanged { 拖曳參數 in
if 拖曳位移 == .zero { // 開始拖曳
拖曳開始 = 拖曳參數.time
百分比 = 0.0 // 百分比在此歸零
}
拖曳位移 = 拖曳參數.translation
}
.onEnded { 拖曳參數 in
let 時間差 = 拖曳參數.time.timeIntervalSince(拖曳開始)
let 速度 = 拖曳參數.translation.width / 時間差
if 速度 < -300 {
自動模式 = true // 開啟自動模式,往左
往右嗎 = false
} else if 速度 < 300 {
自動模式 = false // 手動模式,歸零
拖曳位移 = .zero
} else {
自動模式 = true // 開啟自動模式,往右
往右嗎 = true
}
// print(時間差, 拖曳參數.translation)
// print(速度)
}
}
var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 半徑 = min(寬/2, 高/2)
let 中心 = CGPoint(x: 寬/2, y: 高/2)
var 水平位移加總 = 拖曳位移.width + 寬 * 百分比
if abs(水平位移加總) > 寬 { 水平位移加總 = 往右嗎 ? 寬 : -寬 }
// print(寬, 百分比, 水平位移)
圖層.translateBy(
x: 自動模式 ? 水平位移加總 : 拖曳位移.width,
y: 0)
var 畫筆 = Path()
for i in [-1.0, 0.0, 1.0] {
let 圓心 = CGPoint(x: 中心.x + 寬*i, y: 中心.y)
let 起點 = CGPoint(x: 寬*(i+0.5) + 半徑, y: 中心.y)
畫筆.move(to: 起點)
畫筆.addArc(
center: 圓心,
radius: 半徑,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false)
}
圖層.stroke(畫筆, with: .color(.cyan), lineWidth: 20.0)
}
.border(.red)
.gesture(拖曳)
.onChange(of: 時間) { _ in
let 更新速率 = 往右嗎 ? 0.05 : -0.05
if abs(百分比) > 1.0 {
自動模式 = false
拖曳位移 = .zero
} else {
百分比 += 自動模式 ? 更新速率 : 0.0
}
}
Circle()
.frame(height: 200)
.border(.blue)
.foregroundColor(.blue)
.offset(拖曳位移)
.gesture(拖曳)
}
}

struct 手動輪播: View {
var body: some View {
TimelineView(.animation) { 時間參數 in
畫布(時間: 時間參數.date)
}
}
}

PlaygroundPage.current.setLiveView(手動輪播())


執行過程如下,完全符合預期,相當完美:
4-9e 相簿瀏覽器App

今(2022)年在WWDC舉辦之前,Apple 先發布了新版 Swift Playgrounds 4.1,其中最大的更新,就是讓 macOS 版也能製作App(原來的4.0只有iPad版可以),雖然還有些功能不完善,也尚無法跨平台(macOS版只能產出macOS App;iPad版只能產出 iOS App),但未來會逐步改善的趨勢已是相當明顯。

本節就用這個新功能,來製作一個macOS App,步驟如下:

1. 開啟 macOS 版 Swift Playgrounds 4.1 (需macOS Monterey 12.4以上版本)
2. 按一下左下角「+ App」,會新增一個App,預設名稱為「我的App」,如下圖:


3. 雙擊點開「我的App」,可以看到預設檔名為「我的App.swiftpm」
4. 在「程式碼」按右鍵,選「加入Swift檔案」,命名為「開啟相簿」,如下圖:


5. 將第一段程式貼進去,會產生一個「預覽」畫面,如下圖:


6. 在「程式碼」按右鍵,選「加入Swift檔案」,命名為「相簿圖片輪播」,將第二段程式貼進去,同樣會有「預覽」畫面:


7. 在「預覽」畫面選擇5張圖片,點一下右上角「加入」,就可開始左右滑動手動輪播:


8. 將MyApp裡面,「ContentView()」 換成「相簿圖片輪播()」,左側欄的 ContentView 檔案就可以刪除了:


9. 將App名稱改為「相簿瀏覽器」,選擇「App設定」,最底下出現「安裝在此Mac上」,就可輸出為 macOS App:


10. 程式會安裝在「應用程式」裡面,就可當做一般程式執行:


第一段程式如下:
// 開啟相簿.swift
// Revised by Heman, 2022/07/03
import SwiftUI
import PhotosUI

struct 開啟相簿: UIViewControllerRepresentable {
@Binding var result: [UIImage]
@Binding var popup: Bool
var limit = 0 // 0 means unlimited

func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration(photoLibrary: .shared())
config.filter = .images
config.selectionLimit = limit
let controller = PHPickerViewController(configuration: config)
controller.delegate = context.coordinator
return controller
}

func updateUIViewController(
_ uiViewController: PHPickerViewController,
context: Context) {
return
}

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

class Coordinator: PHPickerViewControllerDelegate {
private let parent: 開啟相簿

init(_ parent: 開啟相簿) {
self.parent = parent
}

func picker(
_ picker: PHPickerViewController,
didFinishPicking results: [PHPickerResult]) {
parent.result = []
for item in results {
if item.itemProvider.canLoadObject(ofClass: UIImage.self) {
item.itemProvider.loadObject(ofClass: UIImage.self) {
(image, error) in
if let err = error {
print(err.localizedDescription)
} else {
self.parent.result.append(image as! UIImage)
}
}
}
}
parent.popup = false
}
}
}

struct 選擇照片: View {
@State var 圖片集: [UIImage] = []
@State var 開關 = true
var body: some View {
ZStack {
Color.black
if 圖片集.isEmpty {
Text("請選擇一張照片")
.font(.title)
} else {
Image(uiImage: 圖片集.first!)
.resizable()
.scaledToFit()
}
}
.sheet(isPresented: $開關) {
開啟相簿(result: $圖片集, popup: $開關, limit: 1)
}
.onTapGesture {
開關.toggle()
}
}
}

struct 相簿預覽: PreviewProvider {
static var previews: some View {
選擇照片()
}
}


第二段程式如下:
// 相簿圖片輪播.swift
// Revised by Heman, 2022/07/03
import SwiftUI

struct 畫布: View {
let 更新: Date
@State var 圖片集: [UIImage] = []
@State var 百分比: CGFloat = 0.0
@State var 張次 = 1
@State var 拖曳開始 = Date()
@State var 拖曳位移: CGSize = .zero
@State var 自動模式 = false
@State var 相簿開關 = true
@State var 向右嗎 = false

var 拖曳: some Gesture {
DragGesture()
.onChanged { 拖曳參數 in
if 拖曳位移 == .zero {
拖曳開始 = 拖曳參數.time
自動模式 = false
}
拖曳位移 = 拖曳參數.translation
}
.onEnded { 拖曳參數 in
let 時間差 = 拖曳參數.time.timeIntervalSince(拖曳開始)
let 速度 = 拖曳參數.translation.width / 時間差
print(拖曳參數.time, 速度)
if 速度 < -300 {
向右嗎 = false
自動模式 = true
} else if 速度 < 300 {
拖曳位移 = .zero
自動模式 = false
} else {
向右嗎 = true
自動模式 = true
}
}
}

var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 中心 = CGPoint(x: 寬/2, y: 高/2)
if 圖片集.isEmpty {
圖層.draw(Text("請選擇至少2張圖片..."), at: 中心)
} else {
for i in 0..<3 {
if i < 圖片集.endIndex {
let 照片 = 圖層.resolve(Image(uiImage: 圖片集[i]))
let 寬度比 = 寬 / 照片.size.width
let 高度比 = 高 / 照片.size.height
let 縮放比 = min(寬度比, 高度比)
var 水平位移 = 自動模式 ? 寬 * 百分比 + 拖曳位移.width : 拖曳位移.width
if abs(水平位移) > 寬 {
水平位移 = 向右嗎 ? 寬 : -寬
}
圖層.drawLayer { 新圖層 in
新圖層.translateBy(
x: 中心.x + 寬 * CGFloat(i-1) + 水平位移,
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)
.sheet(isPresented: $相簿開關) {
開啟相簿(result: $圖片集, popup: $相簿開關)
}
.gesture(拖曳)
.onTapGesture {
相簿開關.toggle()
}
.onChange(of: 更新) { _ in
let 移動速率 = 向右嗎 ? 0.05 : -0.05
if 圖片集.count > 1 { // 至少2張才需要輪替
百分比 += 自動模式 ? 移動速率 : 0.0
if abs(百分比) > 1.0 {
if 向右嗎 {
if 張次 == 0 {
張次 = 圖片集.count - 1
} else {
張次 = 張次 - 1
}
if let 末張 = 圖片集.last {
圖片集.removeLast()
圖片集.insert(末張, at: 0)
}
} else {
張次 = (張次+1) % 圖片集.count
if let 首張 = 圖片集.first { // 輪換照片順序
圖片集.removeFirst()
圖片集.append(首張)
}
}
自動模式 = false
百分比 = 0.0
拖曳位移 = .zero
}
}
}

}
}

struct 相簿圖片輪播: View {
var body: some View {
ZStack(alignment: .trailing) {
Color.black
TimelineView(.animation) { 時間參數 in
畫布(更新: 時間參數.date)
}
VStack {
Group {
Button(
action: {
print("Save to Photo Library")
},
label: {
Image(systemName: "square.and.arrow.down.fill")
})
Button(
action: {
print("Update photos from the web")
},
label: {
Image(systemName: "arrow.clockwise.icloud.fill")
})
Button(
action: {
print("Open Photo Library")
}, label: {
Image(systemName: "photo.on.rectangle.angled")
})
}
.font(.title)
.foregroundColor(.white)
.shadow(color: .blue, radius: 40, x: 0, y: 0)
.padding()
}
}
}
}

struct 相簿輪播預覽: PreviewProvider {
static var previews: some View {
相簿圖片輪播()
}
}

最後的主程式如下:
// 相簿瀏覽器.swift
import SwiftUI

@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
相簿圖片輪播()
}
}
}


💡 註解
  1. 本節先操作,下一節再做說明。
  2. 若使用 iPad 版 Swift Playgrounds 4.0,操作稍有不同,但程式同樣可正常執行。
  3. 程式會用到作業系統的相簿(即「照片」App),記得要先啟用,並加入若干照片,才能順利執行。
  4. App的副檔名 .swiftpm,表示為 Swift Package Manager 的格式;若新增 playground,則副檔名為 .playgroundbook,兩者格式不同,用途也不一樣。
  • 8
內文搜尋
X
評分
評分
複製連結
請輸入您要前往的頁數(1 ~ 8)
Mobile01提醒您
您目前瀏覽的是行動版網頁
是否切換到電腦版網頁呢?