• 8

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

[第5單元]Swift人工智慧已發布

對人工智慧程式設計有興趣的同學,歡迎繼續學習:
[第5單元]Swift 人工智慧程式基礎 https://www.mobile01.com/topicdetail.php?f=482&t=6734952&p=1#86780672

另外,第4單元整合的App也已更新版本,包括 iOS, iPadOS, macOS 三種版本:
動畫與繪圖
https://apps.apple.com/tw/app/動畫與繪圖/id1642880905
雪白西丘斯 wrote:
作業圓周運動可以做出...(恕刪)


拜老師無私的分享指導,從今年2月下旬至今學習到此單元,今天試著作出心形圓動畫。

遇到的問題: animationCircle 若改為@State var animationCircle: [CGPoint] = [ ], 在 body(computed property) 裡執行 animationCircle += [AaniCircleXY] + [BaniCircleXY] ,在主控台 print 觀察 animationCircle 結果全為 [空 ] ,因此無動畫,我想應該是跟 body為computed property有關,但又不知真正原因為何?

暫決方法: 改用 static var XY ,就可在 body 裡執行 animationCircle.XY += [AaniCircleXY] + [BaniCircleXY]且有所有角度的AB點XY儲存到 animationCircle.XY裡。


import SwiftUI
import PlaygroundSupport

struct animationCircle: View {
@State private var degreesC = 0.0
let date: Date
static var XY: [CGPoint] = []
var body: some View {
Canvas { context, size in
let W = size.width
let H = size.height
let C = CGPoint(x: W/2, y: H/2)
let radius = min(W, H)/2
var aniPath = Path()
let AdX = radius * sin(degreesC * .pi/180)
let AdY = radius * cos(degreesC * .pi/180)
let AaniCircleXY = CGPoint(x: C.x + AdX, y: C.y - AdY)
let BdX = radius * sin(2 * degreesC * .pi/180)
let BdY = radius * cos(2 * degreesC * .pi/180)
let BaniCircleXY = CGPoint(x: C.x + BdX, y: C.y - BdY)
animationCircle.XY += [AaniCircleXY] + [BaniCircleXY]
print(animationCircle.XY) // check XY-Array
aniPath.addLines(animationCircle.XY)
context.stroke(aniPath, with: .color(.brown), lineWidth: 2)
}
.onChange(of: date) { _ in
degreesC += 1
(animationCircle.XY, degreesC) = degreesC <= 360 ? (animationCircle.XY, degreesC) : ([], 0.0)
}
}
}

struct showAll: View {
@State private var T = Date()
var body: some View {
Label("Swift 4-6a 圓周運動(同心圓) Canvas + TimelineView", systemImage: "swift")
.font(.largeTitle)
.foregroundColor(.orange)
.padding()
TimelineView(.animation()) { context in
animationCircle(date: context.date)
}
Text("\(Date())")
.font(.title)
.padding()
}
}
PlaygroundPage.current.setLiveView(showAll())

ps: 為何發文前編輯程式碼排版無問題,發文後全變成置左對齊了?
雪白西丘斯

水喔!

2023-04-10 11:16
雪白西丘斯

回覆主題時,若是用「所見所得」模式,程式碼的編排就會亂掉(但預覽正確),所以我都用「傳統編輯」,手動輸入 HTML Tag。

2023-04-10 11:46
你原來寫法是不是像這樣:

import SwiftUI
import PlaygroundSupport

struct AnimationCircle: View {
@State private var degreesC = 0.0
let date: Date
@State var animationCircle: [CGPoint] = []
var body: some View {
Canvas { context, size in
let W = size.width
let H = size.height
let C = CGPoint(x: W/2, y: H/2)
let radius = min(W, H)/2
var aniPath = Path()
let AdX = radius * sin(degreesC * .pi/180)
let AdY = radius * cos(degreesC * .pi/180)
let AaniCircleXY = CGPoint(x: C.x + AdX, y: C.y - AdY)
let BdX = radius * sin(2 * degreesC * .pi/180)
let BdY = radius * cos(2 * degreesC * .pi/180)
let BaniCircleXY = CGPoint(x: C.x + BdX, y: C.y - BdY)
animationCircle += [AaniCircleXY] + [BaniCircleXY]
print(animationCircle) // check XY-Array
aniPath.addLines(animationCircle)
context.stroke(aniPath, with: .color(.brown), lineWidth: 2)
}
.onChange(of: date) { _ in
degreesC += 1
(animationCircle, degreesC) = degreesC <= 360 ? (animationCircle, degreesC) : ([], 0.0)
}
}
}

struct showAll: View {
@State private var T = Date()
var body: some View {
Label("Swift 4-6a 圓周運動(同心圓) Canvas + TimelineView", systemImage: "swift")
.font(.largeTitle)
.foregroundColor(.orange)
.padding()
TimelineView(.animation()) { context in
AnimationCircle(date: context.date)
}
Text("\(Date())")
.font(.title)
.padding()
}
}
PlaygroundPage.current.setLiveView(showAll())

語法都正確,但執行的結果,中間動畫完全空白,而且 animationCircle 值都是 [] 空陣列:


上面這個程式之所以無法畫出圖形,主要原因是 SwiftUI 有個「隱藏限制」,就是在 body (同一執行緒)裡面不能更新狀態變數(@State var),要更改狀態變數,必須在 body 以外的非同步事件中(也就是在不同執行緒中),如 onChange, onTapGesture, ...等地方,才能變更狀態變數的值。

這個錯誤訊息如下,可參考 這篇 SwiftUI Lab 文章
“Modifying state during view update, this will cause undefined behavior”

你用 static var 來解這個問題,是很有創意的做法。static var 是用來定義「類型變數」(type property),有點全域變數(global variable)的味道,不會綁在物件實例裡面,所以可在 body 裡面變更。

附帶說明,以英文命名時,用 struct 所定義的資料類型,語法習慣是字首大寫,也就是寫為 AnimationCircle,而用 var 定義的變數,則字首小寫,也就是 animationCircle。所以,字首大寫的是資料類型,小寫的是變數、常數或是函式,很容易辨認。不過這並非強制,只是 Swift 官方建議(也是大家會遵守)的慣例。
雪白西丘斯 wrote:
你原來寫法是不是像這...(恕刪)

感謝老師的指導!
原本寫法確實是這樣,結果因空字串造成 addLines無接點可連結成畫面。

原來語法習慣字首大寫的是資料類型,小寫的是變數、常數或是函式,之前看著 Apple Developer Documentation 指令就想著要怎麼背這是 Structure, instance method, type method, instance property, type property,現在知道大小寫就能先大至分辨資料類型還是(變、常數 或函式)了,感謝老師點出這個問題!

雖然用static var可解決@State var無法儲存字串問題,但是我心想著一個問題:
static var 屬於 type property,這意謂著它並不會因為建立新的物件實例而重置,例如下列程式碼新增另一個心形圓,執行結果因 static var 被二個物件不斷修改已錯亂,目前解法方法只能另編一個獨立的Structure來顯示另一個心形圖,但總覺得這個解決方案造成 Structure 只能單獨使用無法發揮 Structure 模具印出物件實例的優勢,總覺得應該有辦法才對...今天又試了幾個方法也失敗...

struct ShowAll: View {
@State private var T = Date()
var body: some View {
Label("Swift 4-6a 圓周運動(心形圓) Canvas + TimelineView", systemImage: "swift")
.font(.largeTitle)
.foregroundColor(.orange)
.padding()
TimelineView(.animation()) { context in
AnimationCircle(date: context.date)
}
TimelineView(.periodic(from: Date(), by: 1)) { context in
var newAnimationCircle = AnimationCircle(date: context.date)
newAnimationCircle
}
Text("\(Date())")
.font(.title)
.padding()
}
}
PlaygroundPage.current.setLiveView(ShowAll())





編輯已用 "傳統編輯" + "HTML 標籤",排版正確但送出發文又置左對齊了

雪白西丘斯

「這個解決方案造成 Structure 只能單獨使用無法發揮 Structure 模具印出物件實例的優勢」→ 觀念很正確!

2023-04-11 7:51
雪白西丘斯

程式碼的前後要用 < pre>...< /pre> 括起來,否則會重新排版,這是 HTML 的特性。pre 是 preformatted 的意思。

2023-04-11 7:54
ooya wrote:
感謝老師的指導!原本...(恕刪)


找到方法了,可使用@StateObject,參考以下文章:
https://www.hackingwithswift.com/quick-start/swiftui/what-is-the-stateobject-property-wrapper



import SwiftUI
import PlaygroundSupport

class SaveXY: ObservableObject {
var xy: [CGPoint] = []
}
struct AnimationCircle: View {
@State private var degreesC = 0.0
@StateObject private var saveXY = SaveXY()
let date: Date
var body: some View {
Canvas { context, size in
let w = size.width
let h = size.height
let c = CGPoint(x: w/2, y: h/2)
let radius = min(w, h)/2
var aniPath = Path()
let adX = radius * sin(degreesC * .pi/180)
let adY = radius * cos(degreesC * .pi/180)
let aAniCircleXY = CGPoint(x: c.x + adX, y: c.y - adY)
let bdX = radius * sin(2 * degreesC * .pi/180)
let bdY = radius * cos(2 * degreesC * .pi/180)
let bAniCircleXY = CGPoint(x: c.x + bdX, y: c.y - bdY)
saveXY.xy += [aAniCircleXY] + [bAniCircleXY]
print(saveXY.xy.count) // check two instance saveXY.xt.count
aniPath.move(to: c)
aniPath.addLines(saveXY.xy)
context.stroke(aniPath, with: .color(.brown), lineWidth: 2)
}
.onChange(of: date) { _ in
degreesC += 1
(saveXY.xy, degreesC) = degreesC <= 360 ? (saveXY.xy, degreesC) : ([], 0.0)
}
}
}


struct ShowAll: View {
@State private var T = Date()
var body: some View {
Label("Swift 4-6a 圓周運動(心形圓) Canvas + TimelineView", systemImage: "swift")
.font(.largeTitle)
.foregroundColor(.orange)
.padding()
TimelineView(.periodic(from: Date(), by: 0.01)) { context in
var oneAnimationCircle = AnimationCircle(date: context.date)
oneAnimationCircle
}
TimelineView(.periodic(from: Date(), by: 0.03)) { context in
var newAnimationCircle = AnimationCircle(date: context.date)
newAnimationCircle
}
Text("\(Date())")
.font(.title)
.padding()
}
}
PlaygroundPage.current.setLiveView(ShowAll())
貢獻一下我的解法(還未發表過),我在 Canvas 裡面用 while 迴圈來畫直線,這樣就不必儲存陣列了。

// 4-6f 兩點圓周運動 Canvas + TimelineView
// Updated by Heman, 2022/04/30
import SwiftUI

struct 兩點圓周運動畫布: View {
let 時間: Date
@State var 圓心角 = CGFloat.zero
@State var 暫停 = false
@State var 倍數 = 1.5
var body: some View {
Canvas { 圖層, 尺寸 in
let 寬 = 尺寸.width
let 高 = 尺寸.height
let 中心 = CGPoint(x: 寬/2, y: 高/2)
let 半徑 = min(寬, 高) / 2

var 畫筆 = Path()
畫筆.addArc(
center: 中心,
radius: 半徑,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false
)
// 圖層.fill(畫筆, with: .color(.black))

畫筆 = Path()
var 角度 = 0.0
while 角度 <= 圓心角 {
let 頂點 = CGPoint(
x: 中心.x + 半徑 * sin(.pi * 角度 / 180),
y: 中心.y - 半徑 * cos(.pi * 角度 / 180))
let 端點 = CGPoint(
x: 中心.x + 半徑 * sin(.pi * 角度 * 倍數 / 180),
y: 中心.y - 半徑 * cos(.pi * 角度 * 倍數 / 180))
畫筆.move(to: 頂點)
畫筆.addLine(to: 端點)
角度 += 2.5
}
圖層.stroke(畫筆, with: .color(.cyan), lineWidth: 1)
}
.onTapGesture {
暫停.toggle()
}
.onChange(of: 時間) { _ in
if !暫停 {
圓心角 += 2.5
if 圓心角 >= 720 + 180 {
圓心角 = .zero
// 倍數 = 倍數 < 6 ? 6.0 : 2.0
}
}
}
}
}

struct 兩點圓周運動: View {
var body: some View {
VStack {
TimelineView(.animation) { 時間參數 in
兩點圓周運動畫布(時間: 時間參數.date)
兩點圓周運動畫布(時間: 時間參數.date, 倍數: 2.0)
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(兩點圓周運動())


產生多個物件實例也沒有問題:
ooya

感謝老師分享! 目前已解疑惑繼續努力按步就班學習後續單元。

2023-04-11 21:03
雪白西丘斯

嗯,一起加油!

2023-04-11 23:46
雪白西丘斯 wrote:
挑戰題:蜂巢形遮罩(mask...(恕刪)


雪白西丘斯 wrote:
挑戰題:蜂巢形遮罩(mask...(恕刪)


蜂巢形遮罩挑戰終於成功了,最初想用CGAffineTransform來排列,但是找了相關平面舖磚的文章後感覺太深奧了,最後還是用旋轉變換找各個六角形中心就好了,這過程中也遇到一些問題來訓練自己想辦法 debug。

1. 以下是用 macOS.swiftpm 編輯,使用 PhotosPicker 可顯示照片 (Xcode也可顯示),但是用 macOS.playgroundbook反而顯示 "找不到圖庫",還找不到原因?

2. 程式碼排版錯亂,前後有加 < pre> < /pre> 可是下面截圖顯示其它段落程式碼全加在其中二段中?


3.這才是正確的






// HexagonShapeMask
// Refer to Heman, https://hemanlu.notion.site/hemanlu/4-SwiftUI-f1761468228240228fb8dd7bf55a3b62
// Modified by oya, 2023/05/17
import SwiftUI

import SwiftUI

struct HexagonShapeMask: Shape {
let sides = 6
var cycles = 5
var pitch: Int
init(cycles: Int, pitch distance: Int) {
self.cycles = (cycles > 1) ? cycles : 1
self.pitch = (distance > 1) ? distance : 1
}

private func collectPathPoints(
in sides: Int,
point center: CGPoint,
radius: CGFloat) -> [CGPoint] {
let radians = .pi*2/CGFloat(sides)
let points = (0..<sides).map {="" p="" in="" return="" cgpoint(="" x:="" center.x="" +="" radius*sin(radians*cgfloat(p)),="" y:="" center.y="" -="" radius*cos(radians*cgfloat(p)))="" }="" points="" private="" func="" collectcenter(origin="" center:="" cgpoint,="" radius:="" cgfloat)=""> [CGPoint] {
let radians = .pi*2/CGFloat(sides)
let r = 2*radius
let centerPoints = (0..<sides).map {="" return="" cgpoint(="" x:="" center.x="" +="" r*cos(radians*cgfloat($0)),="" y:="" center.y="" r*sin(radians*cgfloat($0)))="" }="" centerpoints="" func="" path(in="" rect:="" cgrect)="" -=""> Path {
let w = rect.width
let h = rect.height
let center = CGPoint(x: rect.midX, y: rect.midY)
let r = min(w, h)/2/CGFloat(sides)
var centerPoints: [CGPoint] = [center]
var cycle = cycles
while cycle > 0 {
let tempCenterPoints = centerPoints.map { center in
collectCenter(origin: center, radius: r)
}
let _ = tempCenterPoints.map { point in
if !centerPoints.contains(point) {
centerPoints.append(contentsOf: point)
}
}
cycle -= 1
}

var path = Path()
let radians = .pi*2/(CGFloat(6*2 + pitch)) // pitch若為0則內部無遮罩
let r1 = r / cos(radians)
for center in centerPoints {
let tempPoints = collectPathPoints(in: sides,
point: center,
radius: r1)
path.move(to: tempPoints[0])
path.addLines(tempPoints)
path.closeSubpath()
}
return path
}
}


import PhotosUI
//@available(iOS 16.0, macOS 13.0, *)
struct PhotosPickerView: View {
@State private var photosPickerItem: PhotosPickerItem?
@Binding var photo: UIImage
@Binding var sw: Bool

var body: some View {
VStack {
PhotosPicker(selection: $photosPickerItem,
matching: .images) {
Label("Pick a photo", systemImage: "photo")
}
.buttonBorderShape(.capsule)
.buttonStyle(.borderedProminent)
.onChange(of: photosPickerItem) { item in
Task {
do {
if let data = try await item?.loadTransferable(
type: Data.self) {
if let transData = UIImage(data: data) {
photo = transData
}
}
} catch {
print("Error!!!", error)
}
}
sw = true
}
}
}
}


struct ContentView: View {
@State private var selectedPhoto = UIImage()
@State private var switcher = false
@State private var newCycles = 3
var body: some View {
PhotosPickerView(photo: $selectedPhoto, sw: $switcher)
.sheet(isPresented: $switcher) {
VStack {
Stepper("Cycle numbers: \(newCycles)",
value: $newCycles,
in: 1...6,
step: 1)
Image(uiImage: selectedPhoto)
.resizable()
.scaledToFit()
.mask(alignment: .center) {
HexagonShapeMask(cycles: newCycles, pitch: 2)
}
.onTapGesture {
switcher = false
}
.padding()
}
}
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
</sides).map></sides).map>
雪白西丘斯

太棒了!你是第一個挑戰成功的。

2023-05-18 9:13
ooya

這多虧老師字字珠璣的文章,讓我這種中英語文閱讀能力不好的人也能看得懂,有了這些基礎之後,現在再接觸網路分享的文章及developer範例,已有能力劃分區塊找資源學習,不再因完全不懂而頹喪卻步了。

2023-05-18 19:08
ooya wrote:
蜂巢形遮罩挑戰終於成...(恕刪)


Mobile01 遇到小於符號 < 就會誤認為是 HTML Tag,所以在 let points = (0..<sides).map 後面全部亂掉,避開方法是將小於符號設為斜體或是前後加空格。

重新編排你的程式(我只加最後兩行)如下:
// HexagonShapeMask
// Refer to Heman, https://hemanlu.notion.site/hemanlu/4-SwiftUI-f1761468228240228fb8dd7bf55a3b62
// Modified by oya, 2023/05/17
import SwiftUI

struct HexagonShapeMask: Shape {
let sides = 6
var cycles = 5
var pitch: Int
init(cycles: Int, pitch distance: Int) {
self.cycles = (cycles > 1) ? cycles : 1
self.pitch = (distance > 1) ? distance : 1
}
private func collectPathPoints(
in sides: Int,
point center: CGPoint,
radius: CGFloat) -> [CGPoint] {
let radians = .pi*2/CGFloat(sides)
let points = (0..<sides).map { p in
return CGPoint(
x: center.x + radius*sin(radians*CGFloat(p)),
y: center.y - radius*cos(radians*CGFloat(p)))
}
return points

}
private func collectCenter(origin center: CGPoint,
radius: CGFloat) -> [CGPoint] {
let radians = .pi*2/CGFloat(sides)
let r = 2*radius
let centerPoints = (0..<sides).map {
return CGPoint(
x: center.x + r*cos(radians*CGFloat($0)),
y: center.y + r*sin(radians*CGFloat($0)))
}
return centerPoints
}
func path(in rect: CGRect) -> Path {
let w = rect.width
let h = rect.height
let center = CGPoint(x: rect.midX, y: rect.midY)
let r = min(w, h)/2/CGFloat(sides)
var centerPoints: [CGPoint] = [center]
var cycle = cycles
while cycle > 0 {
let tempCenterPoints = centerPoints.map { center in
collectCenter(origin: center, radius: r)
}
let _ = tempCenterPoints.map { point in
if !centerPoints.contains(point) {
centerPoints.append(contentsOf: point)
}
}
cycle -= 1
}

var path = Path()
let radians = .pi*2/(CGFloat(6*2 + pitch)) //pitch若為0則內部無遮罩
let r1 = r / cos(radians)
for center in centerPoints {
let tempPoints = collectPathPoints(in: sides,
point: center,
radius: r1)
path.move(to: tempPoints[0])
path.addLines(tempPoints)
path.closeSubpath()
}
return path
}
}

import PhotosUI
//@available(iOS 16.0, macOS 13.0, *)
struct PhotosPickerView: View {
@State private var photosPickerItem: PhotosPickerItem?
@Binding var photo: UIImage
@Binding var sw: Bool

var body: some View {
VStack {
PhotosPicker(selection: $photosPickerItem,
matching: .images) {
Label("Pick a photo", systemImage: "photo")
}
.buttonBorderShape(.capsule)
.buttonStyle(.borderedProminent)
.onChange(of: photosPickerItem) { item in
Task {
do {
if let data = try await item?.loadTransferable(
type: Data.self) {
if let transData = UIImage(data: data) {
photo = transData
}
}
} catch {
print("Error!!!", error)
}
}
sw = true
}
}
}
}


struct ContentView: View {
@State private var selectedPhoto = UIImage()
@State private var switcher = false
@State private var newCycles = 3
var body: some View {
PhotosPickerView(photo: $selectedPhoto, sw: $switcher)
.sheet(isPresented: $switcher) {
VStack {
Stepper("Cycle numbers: \(newCycles)",
value: $newCycles,
in: 1...6,
step: 1)
Image(uiImage: selectedPhoto)
.resizable()
.scaledToFit()
.mask(alignment: .center) {
HexagonShapeMask(cycles: newCycles, pitch: 2)
}
.onTapGesture {
switcher = false
}
.padding()
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(ContentView())


執行結果很完美:
恭喜並推薦一個讀者開發,剛上架的App:Journal&Note

可用來寫自己的學習筆記或旅遊日誌,裡面用到的程式設計,大多在本課程前4個單元介紹到,App以 iPhone, iPad 平台為主,但也能在 Mac (M1以上晶片)或 Vision Pro 上執行。

從零基礎到開發一個App上架,大約花了一年多時間,包括自學前4個單元課程,相當厲害。可見要學好App程式設計不難,最重要就是要下定決心,自然就能夠認真且專注。


ooya

感謝老師無私分享且至今仍不斷奉獻教學,我才有能力發表第一個作品,我也會牢記老師提醒「網路世界非常遼闊,每天都有新的技術及應用出現,千萬不要自滿,前方未探索的區域仍是無止盡。」並督促自己持續精進。

2024-05-21 15:32
  • 8
內文搜尋
X
評分
評分
複製連結
請輸入您要前往的頁數(1 ~ 8)
Mobile01提醒您
您目前瀏覽的是行動版網頁
是否切換到電腦版網頁呢?