#19_亂數遊戲之二-轉輪盤

Howewu
彼得潘的 Swift iOS / Flutter App 開發教室
27 min readDec 19, 2023

願你能夠好轉起來

繼之前練習過畫圓之後這次要讓它轉起來,說真的每次做跟圓有關的程式碼都會好想睡覺 😪,還好之前有練習過這次在理解各種圓術語上有比較能進入狀況

雖說主題是亂數但真的使用到亂數的程式碼只有一小行,大部分都在計算弧度和角度且我的圓分割的數量可以藉由 slider 數值改變而改變,原以為會卡在如何知道亂數旋轉後停在指標上的數字,結果在洗澡時突然想到這樣搞不好可以喔,果然不知道該怎麼做時洗澡就對了?

跟猜骰子點數做在同一個 Project 裡,一樣是用全程式碼,相較之下排版佈局簡單許多也就不再重複一遍不用 storyboard 的注意事項,預計之後還有猜拳跟圈圈叉叉也會做在一起

作業的部分應該算是這個的前置?

參考

紀錄

佈局錯誤

使用 UIBezierPath()CAShapeLayer()viewDidLoad() 裡畫圓時有可能會跟 Autolayout 佈局同時發生導致之後設置位置不準確

需要將作畫此圓的方法擺在後面的步驟一點

可以用

Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(makeCircle), userInfo: nil, repeats: false)

或是

// 視圖控制器的視圖完全出現後執行的方法
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)

// 呼叫 makeCircle 方法來初始化轉盤
// 這個方法可能包含了創建轉盤的圖層、配置圖層的屬性等邏輯
makeCircle()
}

圓心單位

在畫圓時用的 circlePath.move(to: <#T##CGPoint#>)

circlePath.addArc(withCenter: <#T##CGPoint#>, radius: <#T##CGFloat#>, startAngle: <#T##CGFloat#>, endAngle: <#T##CGFloat#>, clockwise: <#T##Bool#>)

這兩個 CGPoint 參數盡量以清楚的 XY 參數為主比如
let wheelCenter = CGPoint(x: wheelContainerView.bounds.midX, y:wheelContainerView.bounds.midY)

而不要以

wheelContainerView.center

圓形的確沒有出現在 wheelContainerView 的中心,這可能是因為以下幾個原因:

  1. Auto Layout 尚未生效:在 viewDidAppear 中,雖然 Auto Layout 的約束已經設定,但它們可能還沒有完全生效。所以,當你計算 center 屬性時,可能得到的不是最終的中心點。
  2. 使用 center 屬性的不當:當你使用 wheelContainerView.center 來設置圓形路徑的中心時,你是基於 wheelContainerView 在其父視圖(也就是整個屏幕)的中心點。但由於 wheelContainerView 是有自己的坐標系統的,你應該使用相對於 wheelContainerView 的中心點來繪製。
  3. Label 位置計算:Label 的位置計算應該基於 wheelContainerView 的本地坐標系統,且需要在 Auto Layout 完成後。

考慮到這些問題,你可以進行以下修改:

  • 確保使用 wheelContainerViewbounds 而不是 center 來計算中心點。
  • 在 Auto Layout 約束生效後再進行圓形和 Label 的繪製。可以在 viewDidLayoutSubviews 方法中調用 makeCircle

動畫控制

CATransaction.begin()

這個有加或不加有什麼差別?

CATransaction.begin()CATransaction.commit() 在 Swift 中被用來明確地開始和結束一組 Core Animation 的變更。當你想要進行一系列的動畫變更,並且想要它們被統一管理時,這兩個方法是非常有用的。下面來詳細解釋它們的作用以及有無它們的差異:

使用 CATransaction.begin()CATransaction.commit()

當你使用 CATransaction.begin()CATransaction.commit() 包裹一組動畫代碼時,你正在告訴系統這些變更是一個單獨的事務:

  1. 原子性(Atomicity):這兩個方法之間的所有變更被視為一個單一的事務。這意味著這些變更要么全部發生,要么一個都不發生,這對於保持界面的一致性很有幫助。
  2. 自定義事務屬性:你可以設置事務層級的屬性,如動畫的持續時間和時間曲線,這些屬性將應用於事務中的所有動畫。
  3. 同步多個動畫:當有多個動畫需要同時開始和結束時,這種方法特別有用。

不使用 CATransaction.begin()CATransaction.commit()

如果你不使用這兩個方法:

  1. 自動事務:Core Animation 會自動創建和提交事務。這意味著每個動畫變更都被視為其自己的事務,這可能導致多個動畫之間缺乏同步。
  2. 少控制權:你將無法設定整體事務的屬性,如持續時間和時間函數。

結論

  • 在你提供的程式碼中,使用 CATransaction.begin()CATransaction.commit() 允許你對旋轉動畫進行更細致的控制,尤其是設定動畫完成後的動作(通過 CATransaction.setCompletionBlock)。
  • 如果這些調用被移除,每個動畫變更將會立即開始,並且你將失去對整體動畫事務的控制,這可能會影響動畫的表現和同步。

舉例

我將通過一個簡單的示例來展示使用和不使用 CATransaction.begin()CATransaction.commit() 的差異。這個例子將涉及到兩個簡單的動畫:改變視圖的背景色和執行一個縮放動畫。

使用 CATransaction.begin()CATransaction.commit()

在這個例子中,我們將同時開始兩個動畫並在它們完成後執行一個動作:

// 使用 CATransaction
func performAnimationsWithTransaction() {
CATransaction.begin()
// 設置動畫完成後的動作
CATransaction.setCompletionBlock {
print("兩個動畫都完成了!")
}
// 第一個動畫:改變背景色
let colorAnimation = CABasicAnimation(keyPath: "backgroundColor")
colorAnimation.fromValue = UIColor.blue.cgColor
colorAnimation.toValue = UIColor.red.cgColor
colorAnimation.duration = 2.0
self.view.layer.add(colorAnimation, forKey: "colorChange")
// 第二個動畫:縮放
let scaleAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleAnimation.fromValue = 1.0
scaleAnimation.toValue = 1.5
scaleAnimation.duration = 2.0
self.view.layer.add(scaleAnimation, forKey: "scale")
CATransaction.commit()
}

不使用 CATransaction.begin()CATransaction.commit()

在這個例子中,我們將單獨執行每個動畫,並且沒有一個統一的完成動作:

// 不使用 CATransaction
func performAnimationsWithoutTransaction() {
// 第一個動畫:改變背景色
let colorAnimation = CABasicAnimation(keyPath: "backgroundColor")
colorAnimation.fromValue = UIColor.blue.cgColor
colorAnimation.toValue = UIColor.red.cgColor
colorAnimation.duration = 2.0
self.view.layer.add(colorAnimation, forKey: "colorChange")
// 第二個動畫:縮放
let scaleAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleAnimation.fromValue = 1.0
scaleAnimation.toValue = 1.5
scaleAnimation.duration = 2.0
self.view.layer.add(scaleAnimation, forKey: "scale")
}

差異

  • 使用 CATransaction:允許你同步開始和結束多個動畫。在這個例子中,兩個動畫會同時開始,並且在它們都完成後執行一個共同的完成塊。
  • 不使用 CATransaction:每個動畫將獨立執行,沒有同步開始或結束的保證。在這個例子中,沒有一個統一的完成動作來告知兩個動畫都已經完成。

這樣的例子顯示了使用 CATransaction 來管理多個動畫時的優勢,尤其是當你想要這些動畫同步進行並在全部完成後執行某些操作時。

程式碼

import UIKit

class wheelViewController: UIViewController {

// 基本參數定義
let aDegree = Double.pi / 180 // 將一度轉換為弧度的常數
let lineWidth = 1 // 線條寬度
let radius = 170 // 轉盤半徑
var startDegree = 270 // 轉盤開始的角度

// 初始化界面元件
let arrowImageView = UIImageView() // 箭頭指示器的圖片視圖
let slider = UISlider() // 滑動條控件
var sliderValue = 2 // 滑動條的初始值
let wheelContainerView = UIView() // 轉盤容器視圖
var timer = Timer() // 計時器
let startRollButton = UIButton() // 開始按鈕
var circleLayers: [CAShapeLayer] = [] // 用於儲存轉盤上的圓形圖層
var numberLabels: [UILabel] = [] // 用於儲存轉盤上的數字標籤
var rotationCurrentValue: Double = 0 // 當前轉盤的旋轉值
var eachSectorDegree: Double = 0 // 每個扇形(圓形分段)的角度
var rotatedNumbelLabel = UILabel() // 顯示旋轉數字的標籤

override func viewDidLoad() {
super.viewDidLoad()

// 設置界面元件屬性
view.backgroundColor = .black // 設置背景顏色

// 設置箭頭指示器
arrowImageView.image = UIImage(systemName: "arrowtriangle.down.fill")
arrowImageView.tintColor = .systemRed
arrowImageView.backgroundColor = .clear
arrowImageView.translatesAutoresizingMaskIntoConstraints = false

// 設置滑動條
slider.value = 2
slider.minimumValue = 2
slider.maximumValue = 36
slider.minimumTrackTintColor = .white
slider.maximumTrackTintColor = .white
slider.thumbTintColor = .white
slider.translatesAutoresizingMaskIntoConstraints = false
slider.addTarget(self, action: #selector(sliderValueChange), for: .valueChanged)

// 設置開始按鈕
startRollButton.setTitle(" START ROLL ", for: .normal)
startRollButton.setTitleColor(.black, for: .normal)
startRollButton.setTitleColor(.lightGray, for: .highlighted)
startRollButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 10)
startRollButton.backgroundColor = .white
startRollButton.translatesAutoresizingMaskIntoConstraints = false
startRollButton.addTarget(self, action: #selector(startRouletteAnimation), for: .touchUpInside)

// 設置旋轉數字標籤
rotatedNumbelLabel.text = ""
rotatedNumbelLabel.font = .boldSystemFont(ofSize: 80)
rotatedNumbelLabel.textColor = .white
rotatedNumbelLabel.textAlignment = .center
rotatedNumbelLabel.translatesAutoresizingMaskIntoConstraints = false

// 設置轉盤容器視圖
wheelContainerView.backgroundColor = .clear
wheelContainerView.translatesAutoresizingMaskIntoConstraints = false

// 添加元件到視圖
view.addSubview(wheelContainerView)
view.addSubview(arrowImageView)
view.addSubview(startRollButton)
view.addSubview(slider)
view.addSubview(rotatedNumbelLabel)

// 使用Auto Layout設置元件的位置和大小
NSLayoutConstraint.activate([
// 設置轉盤容器視圖的約束
wheelContainerView.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
wheelContainerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
wheelContainerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
wheelContainerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
wheelContainerView.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor, multiplier: 0.5),

// 設置箭頭指示器的約束
arrowImageView.centerXAnchor.constraint(equalTo: wheelContainerView.centerXAnchor),
arrowImageView.topAnchor.constraint(equalTo: wheelContainerView.topAnchor),

// 設置開始按鈕的約束
startRollButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
startRollButton.topAnchor.constraint(equalTo: wheelContainerView.bottomAnchor, constant: 30),

// 設置滑動條的約束
slider.centerXAnchor.constraint(equalTo: view.centerXAnchor),
slider.topAnchor.constraint(equalTo: startRollButton.bottomAnchor, constant: 50),
slider.widthAnchor.constraint(equalTo: wheelContainerView.widthAnchor, multiplier: 0.8),

// 設置旋轉數字標籤的約束
rotatedNumbelLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
rotatedNumbelLabel.widthAnchor.constraint(equalTo: slider.widthAnchor, multiplier: 1),
rotatedNumbelLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -90)
])

// 創建轉盤的圓形圖層和數字標籤
// Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(makeCircle), userInfo: nil, repeats: false)
}



// 視圖控制器的視圖完全出現後執行的方法
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)

// 呼叫 makeCircle 方法來初始化轉盤
// 這個方法可能包含了創建轉盤的圖層、配置圖層的屬性等邏輯
makeCircle()
}



// MARK: - Function & @objc Function



// 這個方法用於生成轉盤的圓形分段及其對應的數字標籤
@objc func makeCircle() {

// 移除已有的圓形圖層和數字標籤
for layer in circleLayers {
layer.removeFromSuperlayer()
}
for label in numberLabels {
label.removeFromSuperview()
}
circleLayers.removeAll()
numberLabels.removeAll()


// 計算轉盤中心點
let wheelCenter = CGPoint(x: wheelContainerView.bounds.midX, y: wheelContainerView.bounds.midY)

// 根據滑動條的值,生成相應數量的圓形分段
for circlePart in 1...sliderValue {
// 計算每個分段的起始和結束角度
var endDegree = startDegree + 360 / sliderValue


// 檢查是否正在處理轉盤的最後一個分段
if circlePart == sliderValue {
// 如果是最後一個分段,則將該分段的結束角度設置為 270 度
// 這是為了確保轉盤的最後一個分段始終結束在相同的位置(270度)
// 不論滑動條設定的分段數量如何變化,這樣可以使轉盤的起始和結束位置保持一致
endDegree = 270
}


// 創建並配置 UIBezierPath 來繪製每個圓形分段
let circlePath = UIBezierPath()
circlePath.move(to: wheelCenter)
circlePath.addArc(withCenter: wheelCenter, radius: CGFloat(radius), startAngle: aDegree * Double(startDegree), endAngle: aDegree * Double(endDegree), clockwise: true)

// 創建並配置每個分段的 CAShapeLayer
let circleLayer = CAShapeLayer()
circleLayer.path = circlePath.cgPath

// 設置扇形圖層的填充顏色
// 使用三元運算符來決定顏色:如果圓形分段數(circlePart)是偶數,則填充顏色為黑色;否則為白色
circleLayer.fillColor = (circlePart % 2 == 0) ? UIColor.black.cgColor : UIColor.white.cgColor

circleLayer.strokeColor = UIColor.systemGray.cgColor
circleLayer.lineWidth = CGFloat(lineWidth)

// 將創建的 CAShapeLayer 添加到轉盤容器視圖的圖層中
wheelContainerView.layer.addSublayer(circleLayer)
circleLayers.append(circleLayer)

// 為每個分段添加數字標籤
addNumberLabel(circlePart: circlePart, startDegree: startDegree, endDegree: endDegree)

// 更新起始角度為當前分段的結束角度
startDegree = endDegree
}
}



// 此函數用於向轉盤的每個分段添加數字標籤
func addNumberLabel(circlePart: Int, startDegree: Int, endDegree: Int) {

// 計算標籤應該放置的角度,即分段的中間點
var labelDegree = (startDegree + endDegree) / 2

// 如果這是轉盤的最後一個分段,則調整標籤的角度,以確保標籤正確顯示
if endDegree == 270 {
labelDegree += 180
}

// 創建一個 UIBezierPath,用於確定數字標籤的準確位置
let labelPath = UIBezierPath(arcCenter: CGPoint(x: wheelContainerView.bounds.midX, y: wheelContainerView.bounds.midY), radius: CGFloat(radius) - 20, startAngle: aDegree * Double(labelDegree), endAngle: aDegree * Double(labelDegree), clockwise: true)

// 初始化 UILabel 用作數字標籤,設定其大小、背景色、字體和顏色
let numberLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 30, height: 30))
numberLabel.backgroundColor = .clear
numberLabel.font = UIFont.systemFont(ofSize: 10)
numberLabel.textColor = (circlePart % 2 == 0) ? .white : .black // 交替顏色以提高可讀性
numberLabel.text = "\(circlePart)" // 設定標籤文本為分段數
numberLabel.textAlignment = .center
// 轉動標籤以匹配其對應分段的角度
numberLabel.transform = CGAffineTransform(rotationAngle: CGFloat(labelDegree - 270) * aDegree)
// 設定標籤的中心點為 UIBezierPath 的當前點
numberLabel.center = labelPath.currentPoint

// 將標籤添加到轉盤容器視圖中
wheelContainerView.addSubview(numberLabel)

// 將創建的標籤添加到 numberLabels 數組中,以便未來管理
numberLabels.append(numberLabel)
}



// 這個方法用於啟動轉盤的旋轉動畫
@objc func startRouletteAnimation() {

// 定義局部變量
let randomPi = Double.random(in: 0..<2 * Double.pi) // 生成隨機角度

eachSectorDegree = Double(360 / sliderValue) // 計算每個標籤代表的角度
print("eachSectorDegree: \(eachSectorDegree)")

// 初始化旋轉動畫
let rotation = CABasicAnimation(keyPath: "transform.rotation")
CATransaction.begin()

rotation.fromValue = rotationCurrentValue // 設置動畫起始值
rotationCurrentValue = rotationCurrentValue + 10 * Double.pi + randomPi // 更新旋轉目標值

// 計算旋轉後的實際值
// 算出最後一圈時從 0 度開始這個圓轉了幾(弧)度
let rotatedValue = rotationCurrentValue.truncatingRemainder(dividingBy: Double.pi * 2)
print("rotatedValue: \(rotatedValue)")
let degree = rotatedValue * 180 / Double.pi // 轉換為角度
print("degree: \(degree)")

// 計算轉盤停止後指向的扇形索引
// 'degree' 是轉盤停止後的角度
// 'eachSectorDegree' 是每個扇形代表的角度
// 計算方法是將 360 度減去 'degree' 得到轉盤目前處於箭頭處它原本的角度,然後再用這個原本的 “角度數字” 除以每個扇形的角度
// 最後加 1 是因為扇形索引是從 1 開始計數的(而不是從 0 開始)
let answer = (360 - degree) / eachSectorDegree + 1 // 計算結果

// 設置動畫其它屬性
rotation.toValue = rotationCurrentValue // 設置動畫結束值
rotation.duration = 5 // 設置動畫時長
rotation.isCumulative = true
rotation.isRemovedOnCompletion = false
rotation.fillMode = .forwards
rotation.repeatCount = 1 // 設置重復次數

rotation.timingFunction = CAMediaTimingFunction(controlPoints: 0, 0.9, 0.4, 1.0) // 設置動畫時間函數

print("\(answer)")

// 將動畫添加到轉盤視圖的圖層
wheelContainerView.layer.add(rotation, forKey: "rotationAnimation")

// 設置動畫完成後的處理
CATransaction.setCompletionBlock {
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [self] in
rotatedNumbelLabel.text = "\(Int(answer))" // 更新顯示結果的標籤
}
}

CATransaction.commit() // 提交動畫事務
}



// 滑動條值改變時調用的方法
@objc func sliderValueChange() {
// 更新 sliderValue 為滑動條當前的值
// 這個值決定了轉盤將被分成多少個部分
sliderValue = Int(slider.value)

// 調用 makeCircle 方法來重新繪製轉盤
// 這個操作會根據新的 sliderValue 創建相應數量的分段和數字標籤
makeCircle()
}



}
  1. 轉動的物件是包住整個圓 layer 和 label 的 wheelContainerView,這樣大家才會一起轉
  2. layer ( label 我不確定 XD )不會自己消失,在改變 slider 的數值時,圓的 layer 會一直堆疊增加
  3. 在正確的時機點將 layer & label 丟進陣列裡,當數值再次改變時先將原本存在陣列裡的刪掉,這樣可以有效解決不斷堆疊的窘境減緩記憶體不斷被壓榨
  4. 不管轉了幾圈、起始點是哪裡,最終判斷的點都是以最後一圈轉了幾度
    let rotatedValue = rotationCurrentValue.truncatingRemainder(dividingBy: Double.pi * 2)
    pi * 2 就是一圈,餘數就是最後一圈從 0 度開始後它轉了幾度
  5. 360 度減去 “最後一圈轉了幾度” 可以得到轉盤目前處於箭頭處它原本屬於的角度,然後再用這個原本的 “角度” 除以目前的扇形 (圓分割) 角度即可以得到它原本所屬的數字
  6. 最後加 1 是因為扇形索引是從 1 開始計數的 (而不是從 0 開始)

--

--