Xcode — 客製化 UISegmentedControl

春麗 S.T.E.M.
18 min readNov 25, 2024

--

目錄

⦿ 前情提要
⦿ ValueChanged
⦿ items: [Any]?
⦿ 客製化 Segmented Control
⦿ Delegation

前情提要

之前的文章裡,透過 SegmentedControl 去切換圖片的設置,如下:

@IBAction func changeTag(_ sender: Any) {
switch imageTag.selectedSegmentIndex {
case 0:
myImageView.image = UIImage(named: "014")
classicalSlogan.text = "教練我想打球⋯⋯"
case 1:
myImageView.image = UIImage(named: "008")
classicalSlogan.text = "真相只有一個!"
case 2:
myImageView.image = UIImage(named: "011")
classicalSlogan.text = "來呀~我幫你找啊~"
default:
myImageView.image = UIImage(named: "014")
classicalSlogan.text = "教練我想打球⋯⋯"
}
}

實際上這種方式並不是那麼常用,selectedSegmentIndex 常被用來做兩個 View 的切換,比方說使用兩個 ContainerView 分裝不同的 VC,切換時分別顯現不同的 VC 如下:

設置方法初始化 selectedSegmentIndex 為其中一個 ContainerView 顯現(例如 0),那麼,Index 為 1 的 ContainerView 初始化隱藏。

    @IBOutlet weak var mySegmentedControl: UISegmentedControl!
@IBOutlet weak var firstContainer: UIView!
@IBOutlet weak var secondContainer: UIView!

override func viewDidLoad() {
super.viewDidLoad()

secondContainer.isHidden = true
}


@IBAction func segmentedControlTapped(_ sender: UISegmentedControl) {
switch sender.selectedSegmentIndex {
case 0:
firstContainer.isHidden = false
secondContainer.isHidden = true
case 1:
firstContainer.isHidden = true
secondContainer.isHidden = false
case 2: return
default: return
}
}

我們可透過設置 selectedSegmentIndex 來初始化 SegmentedControl 的預設位置,否則預設為 0。

繼續閱讀|回目錄

ValueChanged

UISegmentedControl 這類動作元件會繼承 UIControl,而 UIControl 的 Event 裡的 ValueChanged,即是值改變的事件,當在對 UISegmentedControl 拉 IBAction 時會直接採用這個事件,這個改變的值就是 selectedSegmentIndex

items: [Any]?

接著,如果要對 UISegmentedControl 的每一個 Index 設置 Text / Image 時,只會存在一個,即是說,我設置文字,就不會有圖像;反之亦然。

而在 UISegmentedControl 的 init 方法中,public init(items: [Any]?),雖然 items 是 [Any]?,設置時並不能放入 UILabel、UIButton 等,其實只能放入 string 或 image,還有 iOS14 後的 UIAction。

放入 string、image

let image = UIImage(systemName: "square.and.arrow.up")!

let seg = UISegmentedControl(items: ["窩是標籤", image])
seg.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(seg)

NSLayoutConstraint.activate([
seg.centerXAnchor.constraint(equalTo: view.centerXAnchor),
seg.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])

放入 UIAction

// First Segment UIAction
let action1 = UIAction(title: "First",
image: UIImage(systemName: "1.circle")) { action in
print("First segment selected")
}

// Second Segment UIAction
let action2 = UIAction(title: "Second",
image: UIImage(systemName: "2.circle")) { action in
print("Second segment selected")
}

let seg = UISegmentedControl.init(items: [action1, action2])
seg.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(seg)

雖然 Text/Image 只能擇一不那麼靈活彈性,不過 UISegmentedControl 還有個 setBackgroundImage,這個看似簡單的背景設置,實則也帶來許多麻煩,如果沒有美工概念,顯圖最後也是會被做得醜醜的。

繼續閱讀|回目錄

客製化 Segmented Control

不過,Segmented Control 不是那麼容易調整,因其已經有個固定形式,所以我們試著建構一個比較彈性化的 Segmented Control,比方如下:

如果我們希望 Segmented Control 下方有一個跟著跑帶著特定顏色的下底線,並且比較容易調整 View 的顏色、選取 / 未選的字型及大小,那麼可以簡單設置一個客製化的 View。

不過,我們先看看如何使用。

    private lazy var customSegmentedView: CustomSegmentedView = {
let segmentedView = CustomSegmentedView()
segmentedView.items = ["即時", "延遲"]
segmentedView.translatesAutoresizingMaskIntoConstraints = false
return segmentedView
}()


override func viewDidLoad() {
super.viewDidLoad()

customSegmentedView.selectedIndex = 0

NSLayoutConstraint.activate([
customSegmentedView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
customSegmentedView.topAnchor.constraint(equalTo: firstContainer.topAnchor, constant: -44),
customSegmentedView.widthAnchor.constraint(equalToConstant: 120),
customSegmentedView.heightAnchor.constraint(equalToConstant: 44)
])

customSegmentedView.selectionChanged = { index in
switch index {
case 0:
self.firstContainer.isHidden = false
self.secondContainer.isHidden = true
case 1:
self.firstContainer.isHidden = true
self.secondContainer.isHidden = false
default:
return
}
}
}

宣告一個 lazy var 的 CustomSegmentedView,這是一個客製化的 View,接著如同 Segmented Control 一樣,設置他的 items 名稱,然後是 selectedIndex(預設為 0),當約束條件定下來時,尤其是長寬,內部元件也定下來了。

最後是設置 selectionChanged,切換 Segment 後要顯示哪個 Container,隱藏哪個 Container,使用起來也是相當簡單,是吧?

最後是程式碼。

class CustomSegmentedView: UIView {

// MARK: - Properties
private let selfColor: UIColor = .systemGray5
private let buttonTintColor: UIColor = .clear
private let underlineColor: UIColor = .systemPink
private let titleNormalColor: UIColor = .systemGray
private let titleSelectedColor: UIColor = .black
private let titleFontSize: CGFloat = 16
private let titleNormalFontWeight: UIFont.Weight = .regular
private let titleSelectedFontWeight: UIFont.Weight = .bold
/// underline 在 view 裡面, 高度為正
/// 在 view 外面, 高度為負
private let underlineHeight: CGFloat = 4
/// underLine 的大小, 參照 Button Width, 最大為相等, 即是 1
/// Button Width 的大小, 參照 View 的 Width,
/// Butoon 塞入越多, Width 被切割越多塊
private let underlineRatio: CGFloat = 3

// UI Element
private var buttons: [UIButton] = []
private var underlineView: UIView!

/// 建構時, item 的數量同 SegmentedControl 的 Segment 數量
var items: [String] = [] {
didSet {
setupButtons()
}
}

/// 選擇 item 時, 改變 title 的顏色, 並更新 underline 的位置
var selectedIndex: Int = 0 {
didSet {
updateBtnSelectedUI()
}
}

var selectionChanged: ((Int) -> Void)?


// MARK: - Initializers
override init(frame: CGRect) {
super.init(frame: frame)
setupBasicViews()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
// setupView()
}

override func layoutSubviews() {
super.layoutSubviews()
layoutButtons()
updateUnderlinePosition()
}

// MARK: - Setup
private func setupBasicViews() {
setupSelf()
setupUnderLine()
}

private func setupSelf() {
backgroundColor = selfColor
}

private func setupUnderLine() {
underlineView = UIView()
underlineView.backgroundColor = underlineColor
underlineView.translatesAutoresizingMaskIntoConstraints = false
addSubview(underlineView)
}

private func setupButtons() {
// buttons.forEach { $0.removeFromSuperview() }
// buttons.removeAll()

for (index, title) in items.enumerated() {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(titleNormalColor, for: .normal)
button.setTitleColor(titleSelectedColor, for: .selected)
button.tintColor = buttonTintColor

button.titleLabel?.font = .systemFont(
ofSize: titleFontSize, weight: titleNormalFontWeight)

button.tag = index
button.addTarget(self, action: #selector(buttonTapped),
for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
addSubview(button)
buttons.append(button)
}

updateBtnSelectedUI()
}

private func layoutButtons() {
guard !buttons.isEmpty else { return }

let buttonWidth = frame.width / CGFloat(buttons.count)
for (index, button) in buttons.enumerated() {
button.frame = CGRect(x: CGFloat(index) * buttonWidth,
y: 0,
width: buttonWidth,
height: frame.height - underlineHeight)
}
}

private func updateBtnSelectedUI() {
for (index, button) in buttons.enumerated() {
button.isSelected = (index == selectedIndex)
button.titleLabel?.font = button.isSelected
? .systemFont(ofSize: titleFontSize,
weight: titleSelectedFontWeight)
: .systemFont(ofSize: titleFontSize,
weight: titleNormalFontWeight)
}
updateUnderlinePosition()
}

private func updateUnderlinePosition() {
guard selectedIndex < buttons.count else { return }
let selectedButton = buttons[selectedIndex]
let underlineWidth = selectedButton.frame.width / self.underlineRatio

UIView.animate(withDuration: 0.3) {
self.underlineView.frame =
CGRect(
x: selectedButton.frame.origin.x + (selectedButton.frame.width - underlineWidth) / 2,
y: self.frame.height - self.underlineHeight,
width: underlineWidth,
height: self.underlineHeight
)
}
}

// MARK: - Actions
@objc private func buttonTapped(_ sender: UIButton) {
selectedIndex = sender.tag
selectionChanged?(selectedIndex)
}
}

除了外部可存取的 items、selectedIndex 之外,透過 var selectionChanged: ((Int) -> Void)? 這個閉包屬性,來設置 button index changed 後會發生什麼事,第一個是改變 selectedIndex,與此同時 updateBtnSelectedUI(),接著將閉包傳遞出去,在外調用時可以知道 selectedIndex 變成多少。

Delegation

當然,我們也可以使用 Delegation 的方式去設計,在 CustomSegmentedView 上方加入一個 protocol。

protocol CustomSegmentedViewDelegate: AnyObject {
func segmentedView(_ segmentedView: CustomSegmentedView,
didSelectIndex index: Int)
}

接著,在 CustomSegmentedView 中宣告一個 weak var delegate: CustomSegmentedViewDelegate? ,然後在 buttonTapped 中這樣改動。

@objc private func buttonTapped(_ sender: UIButton) {
selectedIndex = sender.tag
// selectionChanged?(selectedIndex)
delegate?.segmentedView(self, didSelectIndex: selectedIndex)
}

原先的 selectionChanged 帶入 selectedIndex,改成 delegate?.segmentedView 帶入 selectedIndex。

回到 ViewController,在 viewDidLoad() 中放進 CustomSegmentedView.delegate = self,這時候在 conform CustomSegmentedViewDelegate,就可以在 func 中安排頁面隱藏與呈現,如下:

extension ViewController: CustomSegmentedViewDelegate {
func segmentedView(_ segmentedView: CustomSegmentedView,
didSelectIndex index: Int) {
switch index {
case 0:
self.firstContainer.isHidden = false
self.secondContainer.isHidden = true
case 1:
self.firstContainer.isHidden = true
self.secondContainer.isHidden = false
default:
return
}
}


}

這樣寫也是相當漂亮,不是嗎?

這次就分享到這,感謝您的閱讀。

繼續閱讀|回目錄

--

--

春麗 S.T.E.M.
春麗 S.T.E.M.

Written by 春麗 S.T.E.M.

Do not go gentle into that good night, Old age should burn and rave at close of day; Rage, rage, against the dying of the light.

No responses yet