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,可參考以下連結的說明。