iOS App 讓元件繞圓移動的方法

開發 iOS App 時,想讓元件直線移動很簡單,想要繞圓移動就需稍微想一想了。以下三種方法在 SwiftUI & UIKit 都能實現繞圓移動的效果。

  • 方法1: 調整元件的 y 座標和旋轉的角度。
  • 方法2: 利用畫圓弧的 path 找出元件的座標。
  • 方法3: 利用三角函數計算元件的座標。

接下來我們將只示範前兩種方法,麻煩的三角函數就留給對數學有興趣的朋友研究了。一開始我們先實現以 slider 控制繞圓移動,接著再實現自動繞圓的動畫效果。

方法1: 調整元件的 y 座標和旋轉的角度

SwiftUI

struct ContentView: View {
@State private var degrees: Double = 0

var body: some View {
ZStack {
Image(systemName: "heart.circle")
.resizable()
.scaledToFit()
.frame(width: 300, height: 300)
.overlay(
Text("only the young can run")
.font(.title)
.offset(y: -100)
, alignment: .top)
.overlay(
VStack {
Slider(value: $degrees, in: 0...360)
Text("\(Int(degrees))")
}
.offset(y: 100)
.font(.title),
alignment: .bottom
)

Image("peter")
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.offset(y: -150)
.rotationEffect(.degrees(degrees))

}


}
}

關鍵在以下兩行程式

.offset(y: -150)
.rotationEffect(.degrees(degrees))

原本 peter 在愛心圓的中心,offset(y: -150) 將讓它移動到愛心圓的上方。(150 是愛心圓的半徑)

接著 rotationEffect(.degrees(degrees)) 讓 Peter 繞著愛心圓旋轉,比方以下是 90 度的位置。

不過 modifier 的順序很重要,要先 offset 再 rotate,若是先 rotate 再 offset,Peter 將變成在原地旋轉。

.rotationEffect(.degrees(degrees))
.offset(y: -150)

UIKit & storyboard

假設 storyboard 的設計如下,Peter 一開始在圓心的位置。

class ViewController: UIViewController {
@IBOutlet weak var circleImageView: UIImageView!
@IBOutlet weak var peterImageView: UIImageView!
@IBOutlet weak var label: UILabel!

func updateLocation(degrees: CGFloat) {

peterImageView.transform = CGAffineTransform.identity.rotated(by: CGFloat.pi / 180 * degrees).translatedBy(x: 0, y: -150)
label.text = "\(Int(degrees))"
}

@IBAction func sliderChange(_ sender: UISlider) {
updateLocation(degrees: CGFloat(sender.value))
}

override func viewDidLoad() {
super.viewDidLoad()
updateLocation(degrees: 0)
}
}

說明

在 function updateLocation 調整 peterImageView 的 transform,先用 rotated 旋轉再用 translatedBy 讓 y 位移。原本 peter 在愛心圓的中心,translatedBy(x: 0, y: -150)將讓它移動到愛心圓的上方。(150 是愛心圓的半徑),rotated(by: CGFloat.pi / 180 * degrees) 則讓 Peter 繞著愛心圓旋轉,比方以下是 90 度的位置。

值得注意的,transform 調整的順序很重要,要先 rotated 再 translatedBy,若是先 translatedBy 再 rotated,Peter 將變成在原地旋轉。

peterImageView.transform = CGAffineTransform.identity.translatedBy(x: 0, y: -150).rotated(by: CGFloat.pi / 180 * degrees)

自動繞圓移動的動畫效果

設定每 0.05 秒觸發的 timer,每次觸發時將度數加 1。

SwiftUI

struct ContentView: View {
@State private var degrees: Double = 0
let timer = Timer.publish(every: 0.05, on: .main, in: .common).autoconnect()

var body: some View {
ZStack {
Image(systemName: "heart.circle")
.resizable()
.scaledToFit()
.frame(width: 300, height: 300)
.overlay(
Text("only the young can run")
.font(.title)
.offset(y: -100)
, alignment: .top)
.overlay(
VStack {
Slider(value: $degrees, in: 0...360)
Text("\(Int(degrees))")
}
.offset(y: 100)
.font(.title),
alignment: .bottom
)

Image("peter")
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.offset(y: -150)
.rotationEffect(.degrees(degrees))

}
.onReceive(timer, perform: { (_) in
degrees += 1
if degrees == 360 {
degrees = 0
}
})

}
}

UIKit

class ViewController: UIViewController {
@IBOutlet weak var circleImageView: UIImageView!
@IBOutlet weak var peterImageView: UIImageView!
@IBOutlet weak var label: UILabel!
var cancellable: AnyCancellable?
var degrees: Float = 0
@IBOutlet weak var slider: UISlider!

func updateLocation() {
slider.value = degrees
peterImageView.transform = CGAffineTransform.identity.rotated(by: CGFloat.pi / 180 * CGFloat(degrees)).translatedBy(x: 0, y: -150)
label.text = "\(Int(degrees))"
}


@IBAction func sliderChange(_ sender: UISlider) {
degrees = sender.value
updateLocation()
}

override func viewDidLoad() {
super.viewDidLoad()

cancellable = Timer.publish(every: 0.05, on: .main, in: .common)
.autoconnect()
.sink {[weak self] (_) in
guard let self = self else { return }
self.degrees += 1
if self.degrees == 360 {
self.degrees = 0
}
self.updateLocation()
}

}

}

方法2: 利用畫圓弧的 path 找出元件的中心點座標

SwiftUI

利用 Path 畫圓弧,它的 currentPoint 即為 Peter 的中心點座標。

struct ContentView: View {
@State private var peterDegres: Double = 0

func getPeterPosition(size: CGSize) -> CGPoint {
let center = CGPoint(x: size.width/2, y: size.height/2)
var path = Path()
path.addArc(center: center, radius: 150, startAngle: .degrees(0), endAngle: .degrees(peterDegres), clockwise: false)
return path.currentPoint ?? .zero
}

var body: some View {
GeometryReader { (geometry) in
ZStack {
Image(systemName: "heart.circle")
.resizable()
.frame(width: 300, height: 300)
.overlay(
Text("only the young can run")
.font(.title)
.offset(y: -50)
, alignment: .top)
.overlay(
VStack {
Slider(value: $peterDegres, in: 0...360)
Text("\(Int(peterDegres))")
}
.offset(y: 100)
.font(.title),
alignment: .bottom
)

Image("peter")
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.position(getPeterPosition(size: geometry.size))

}
}


}
}

此做法的繞圓效果跟前個做法有小小的差異。如下圖所示,Peter 將繞圓移動,但 Peter 本身不會旋轉,一直都像男子漢般筆直站著。

UIKit

`class ViewController: UIViewController {
@IBOutlet weak var circleImageView: UIImageView!
@IBOutlet weak var peterImageView: UIImageView!


func updateLocation(degrees: CGFloat) {
let path = UIBezierPath()
let oneDegree = CGFloat.pi / 180
let endAngle = oneDegree * degrees
path.addArc(withCenter: circleImageView.center, radius: 150, startAngle: 0, endAngle: endAngle, clockwise: true)
peterImageView.center = path.currentPoint
}

@IBAction func sliderChange(_ sender: UISlider) {
updateLocation(degrees: CGFloat(sender.value))
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
updateLocation(degrees: 0)
}
}

我們在 viewDidLayoutSubviews 裡呼叫 updateLocation(degrees: 0),讓 Peter 圖片一開始在角度 0 的位置。使用 viewDidLayoutSubviews 的原因是考慮 auto layout,可參考以下連結的說明。

--

--

彼得潘的 iOS App Neverland
彼得潘的 Swift iOS App 開發問題解答集

彼得潘的iOS App程式設計入門,文組生的iOS App程式設計入門講師,彼得潘的 Swift 程式設計入門,App程式設計入門作者,http://apppeterpan.strikingly.com