38 實作客製化評分功能模組

大家好,我是僥倖轉職iOS工程師成功的海蒂。

進入公司之後首先碰到的是製作新畫面,剛好實作到評分功能。中間有問強者涵宇大大,終於完成一個版本。

後來公司前輩說可以這個做成共用模組讓公司同仁其他app共用,這麼榮幸的事情一定要來寫個文章~

這個模組你可以客製: 按鈕圖案、按鈕數量、按鈕大小、按鈕間隔。

模組功能包含:點選與拖曳滑動改變分數回傳評分分數、清空已選按鈕。

尬店,這麼方便的code哪裡找? 在這⬇

輸入:按鈕圖案、按鈕數量、按鈕大小、按鈕間隔

let input = MyScoreViewInput (numberOfButtons: 5,
imageOfNormalButtons: "tiredCat",
imageOfIsSelectedButtons: "orangeCat",
sizeOfButtons: CGSize(width: 50, height: 50),
spaceOfButtons: 16
)

示範改變按鈕數量從5個改成3個、並調整按鈕大小變大:

let input = MyScoreViewInput (numberOfButtons: 3, // 從5個改成3個
imageOfNormalButtons: "tiredCat",
imageOfIsSelectedButtons: "orangeCat",
sizeOfButtons: CGSize(width: 70, height: 70), // 改變大小
spaceOfButtons: 16
)

這個評分的模組MyScoreView主要是:

  1. 用迴圈產生按鈕塞入stackView
  2. 新增一個專門感應手勢的透明view(稱為gestureView)在最上方那層。
  3. 這個gestureView可以實現點擊(tap)和拖曳(pan)功能。
  4. 拖曳(pan)的過程還是要可以即時更新按鈕圖示,就算滑動範圍離開gestureView仍要可以繼續操作。
  5. 當gesture狀態結束(end)時回傳最終結果(程式計算當使用者手放開時,有幾個按鈕是isSelected的數值)。

一、建立MyScoreView

  1. 建立新 Swift File,取名為 MyScoreView 。
  2. 建立 class MyScoreView ,並遵從 UIView (此時 Xcode 會要求你 UIView要 import UIKit)。
  3. 建立可以讓使用者設定客製化數值的 input ,內容包含按鈕數量、按鈕圖示名稱(分 normal 和 selected 不同情境)。

在這邊我用struct形式製作input:

public struct MyScoreViewInput {
let numberOfButtons: Int
let imageOfNormalButtons: String
let imageOfIsSelectedButtons: String
let sizeOfButtons: CGSize
let spaceOfButtons: CGFloat
}

並在 MyScoreView 的 class 建立 MyScoreViewInput 的變數,以及 input 變數的初始值。

public class MyScoreView: UIView {
public var input: MyScoreViewInput?
public var numberOfButtons: Int = 0
public var sizeOfButtons: CGSize = .zero
public var sizeOfMyScoreView: CGSize = .zero

二、建立setupView func 設定評分功能

(1)建立 setupView func 且引用 使用者輸入的 input。

 public func setupView(input: MyScoreViewInput) {}

(2) 建立 stackView,用迴圈方式將按鈕放進去後,依input進行設定。

public class MyScoreView: UIView {
// 在class 建立 stackView
private let stackView = UIStackView()


override init(frame: CGRect) {
super.init(frame: frame)
addSubview(stackView) // 在init時將stackView加在 MyScoreView上
addSubview(gestureView)

}

public func setupView(input: MyScoreViewInput) {
// 設定stackView UI
stackView.axis = .horizontal
stackView.distribution = .fillEqually
stackView.spacing = input.spaceOfButtons

// 設定按鈕 UI
numberOfButtons = input.numberOfButtons
buttonsArray.removeAll()
for index in 0 ..< numberOfButtons {
let button = UIButton()
button.tag = index
// 將以上array裡面的按鈕做protocol的設定,包含未選中、選中
let normalImage = UIImage(named: input.imageOfNormalButtons)
let selectedImage = UIImage(named: input.imageOfIsSelectedButtons)

button.setImage(normalImage, for: .normal)
button.setImage(selectedImage, for: .selected)
button.imageView?.contentMode = .scaleAspectFit

// 設定buttons大小
sizeOfButtons = input.sizeOfButtons
button.frame.size = sizeOfButtons

// 依據加進stackView裡面
stackView.addArrangedSubview(button)
buttonsArray.append(button)
}

// 設定stackView依據按鈕大小進行排版
stackView.frame = CGRect(x: 0,
y: 0,
width: (sizeOfButtons.width + input.spaceOfButtons) * CGFloat(numberOfButtons) - input.spaceOfButtons,
height: sizeOfButtons.height)
// 設定myScoreView的大小為stackView大小
self.frame = stackView.frame
}

(3)建立 gestureView 加入手勢功能

public class MyScoreView: UIView {
private let stackView = UIStackView()

// 在class 建立 gestureView
private let gestureView = UIView()

override init(frame: CGRect) {
super.init(frame: frame)
addSubview(stackView)
addSubview(gestureView) // 在init時將 gestureView 加在 MyScoreView上

}

public func setupView(input: MyScoreViewInput) {

...中間省略...

// 設定手勢感應區gestureView的功能tap及pan
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
gestureView.addGestureRecognizer(panGestureRecognizer)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(gestureViewTapped(_:)))
gestureView.addGestureRecognizer(tapGestureRecognizer)

...中間省略...


gestureView.frame = stackView.frame
}

// 設定 gestureViewTapped 點擊功能
@objc func gestureViewTapped(_ gesture: UITapGestureRecognizer) {
let location = gesture.location(in: gestureView)
let buttonCount = calculateStarCount(for: location)
buttonCountHandler?(buttonCount)
updateStarsBasedOnLocation(location)
}

// 設定 handlePanGesture 拖曳功能
@objc func handlePanGesture(_ gesture: UIPanGestureRecognizer) {

let location = gesture.location(in: gestureView)

// 根據手勢狀態進行處理
switch gesture.state {
case .began, .changed, .ended:

// 計算最近的星級數
let buttonCount = calculateStarCount(for: location)

// 更新星級狀態
updateStarsBasedOnLocation(location)

// 在 ended 狀態時,啟用 tap 手勢並回傳值
if gesture.state == .ended {
gesture.view?.gestureRecognizers?.forEach { recognizer in
if recognizer is UITapGestureRecognizer {
recognizer.isEnabled = true
}
}
buttonCountHandler?(buttonCount)
} else if gesture.state == .began {
// 避免與 tap 手勢衝突,在 pan 執行時關閉 tap 手勢
gesture.view?.gestureRecognizers?.forEach { recognizer in
if recognizer is UITapGestureRecognizer {
recognizer.isEnabled = false
}
}
}

// Debug 輸出手勢狀態和位置
print("hydeeTest", gesture.state.rawValue, gesture.location(in: gestureView))
buttonCountHandler?(buttonCount)

default:
let buttonCount = calculateStarCount(for: location)
updateStarsBasedOnLocation(location)
buttonCountHandler?(buttonCount)
}
}

利用手指移動的x軸數值來更新按鈕

// 依據滑動的區間更新按鈕
func updateStarsBasedOnLocation(_ location: CGPoint) {
// 計算每個按鈕的寬度範圍
let buttonWidth = gestureView.bounds.width / CGFloat(buttonsArray.count)

// 計算觸控位置所在的按鈕索引
let index = Int(location.x / buttonWidth)

// 更新按鈕按鈕狀態
for (i, button) in buttonsArray.enumerated() {
button.isSelected = i <= index
}
}

補強點擊功能時,若使用者沒有精確點擊在按鈕上,則以最後的位置計算最接近的按鈕是哪個。

 func calculateStarCount(for location: CGPoint) -> Int {
// 確保在點擊星星按鈕之間的區域時,回傳最接近的星星數
var closestButton: UIButton?
var minimumDistance: CGFloat = CGFloat.greatestFiniteMagnitude

for button in buttonsArray {
let buttonFrame = button.frame
let buttonCenter = CGPoint(x: buttonFrame.midX, y: buttonFrame.midY)
let distance = hypot(buttonCenter.x - location.x, buttonCenter.y - location.y)
if distance < minimumDistance {
minimumDistance = distance
closestButton = button
}
}

if let closestButton = closestButton, let index = buttonsArray.firstIndex(of: closestButton) {
return index + 1
}

return 0
}

(4)為了能夠接上一頁傳來的值來更新按鈕,加了更新功能。

public func updateButtonCount(numberOfButtons: Int, selectedCount: Int) {
// 移除舊的按鈕
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
buttonsArray.removeAll()

// 設定新的按鈕數量
self.numberOfButtons = numberOfButtons

// 創建新的按鈕
for index in 0 ..< numberOfButtons {
let button = UIButton()
button.tag = index

let normalImage = UIImage(named: input?.imageOfNormalButtons ?? "")
let selectedImage = UIImage(named: input?.imageOfIsSelectedButtons ?? "")

button.setImage(normalImage, for: .normal)
button.setImage(selectedImage, for: .selected)
button.imageView?.contentMode = .scaleAspectFit

// 設置按鈕的選中狀態
button.isSelected = index < selectedCount
button.frame.size = sizeOfButtons

stackView.addArrangedSubview(button)
buttonsArray.append(button)
}

// 更新 stackView 的框架
stackView.frame = CGRect(x: 0, y: 0,
width: (sizeOfButtons.width + (input?.spaceOfButtons ?? 0)) * CGFloat(numberOfButtons) - (input?.spaceOfButtons ?? 0),
height: sizeOfButtons.height)
self.frame = stackView.frame
gestureView.frame = stackView.frame
}

(5)reset所有按鈕

 // 用於重新評分按鈕
func resetButtons() {
buttonsArray.forEach {
$0.isSelected = false
}
}

實作:在 viewController 的 viewDidLoad 使用

class DemoViewController: UIViewController {

@IBOutlet weak var score: UILabel!

var scoreView: MyScoreView?

override func viewDidLoad() {
super.viewDidLoad()
let input = MyScoreViewInput (numberOfButtons: 3,
imageOfNormalButtons: "tiredCat",
imageOfIsSelectedButtons: "orangeCat",
sizeOfButtons: CGSize(width: 70, height: 70),
spaceOfButtons: 16
)


scoreView = MyScoreView()
scoreView?.setupView(input: input)
scoreView?.frame = CGRect(x: 0, y: 200, width: (scoreView?.frame.width)!, height: (scoreView?.frame.height)!)
view.addSubview(scoreView!)
scoreView?.buttonCountHandler = { buttonCount in
self.score.text = "\(buttonCount)"
}

}

@IBAction func reset(_ sender: Any) {
scoreView?.resetButtons()
}

--

--