-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathSTTabBarItemView.swift
More file actions
452 lines (402 loc) · 20.6 KB
/
Copy pathSTTabBarItemView.swift
File metadata and controls
452 lines (402 loc) · 20.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//
// STTabBarItemView.swift
// STBaseProject
//
// Created by 寒江孤影 on 2024/01/01.
//
import UIKit
/// Selection state for `STTabBarItemView`.
public enum STTabBarItemSelection: Equatable {
case selected
case deselected
public var isSelected: Bool { self == .selected }
}
public class STTabBarItemView: UIView {
private var itemModel: STTabBarItemModel?
private var config: STTabBarConfig?
private var isSelected: Bool = false
private var tapAction: (() -> Void)?
private var iconImageViewConstraints: [NSLayoutConstraint] = []
private var titleLabelConstraints: [NSLayoutConstraint] = []
private var initialConstraints: [NSLayoutConstraint] = []
private var lastEffectiveBarHeightUsed: CGFloat = -1
override public init(frame: CGRect) {
super.init(frame: frame)
self.setupUI()
}
public required init?(coder: NSCoder) {
super.init(coder: coder)
self.setupUI()
}
override public func layoutSubviews() {
super.layoutSubviews()
guard let model = self.itemModel, model.displayMode == .imageAndText else { return }
let eff = self.effectiveBarHeightForImageTextLayout()
if abs(eff - self.lastEffectiveBarHeightUsed) > 0.5 {
self.lastEffectiveBarHeightUsed = eff
self.updateUI()
}
}
private func setupUI() {
self.addSubview(self.iconImageView)
self.addSubview(self.titleLabel)
self.addSubview(self.badgeLabel)
self.addSubview(self.customContainerView)
self.iconImageView.translatesAutoresizingMaskIntoConstraints = false
self.titleLabel.translatesAutoresizingMaskIntoConstraints = false
self.badgeLabel.translatesAutoresizingMaskIntoConstraints = false
self.customContainerView.translatesAutoresizingMaskIntoConstraints = false
self.setupInitialConstraints()
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
self.addGestureRecognizer(tapGesture)
}
private func setupInitialConstraints() {
NSLayoutConstraint.deactivate(self.initialConstraints)
self.initialConstraints.removeAll()
// 设置初始约束(默认图文模式)
self.initialConstraints = [
// iconImageView 初始约束
self.iconImageView.centerXAnchor.constraint(equalTo: centerXAnchor),
self.iconImageView.topAnchor.constraint(equalTo: topAnchor, constant: 6),
self.iconImageView.widthAnchor.constraint(equalToConstant: 24),
self.iconImageView.heightAnchor.constraint(equalToConstant: 24),
// titleLabel 初始约束
self.titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
self.titleLabel.topAnchor.constraint(equalTo: self.iconImageView.bottomAnchor, constant: self.titleGap),
self.titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 4),
self.titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -4),
self.titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: 0),
// badgeLabel 约束
self.badgeLabel.topAnchor.constraint(equalTo: self.iconImageView.topAnchor, constant: -4),
self.badgeLabel.trailingAnchor.constraint(equalTo: self.iconImageView.trailingAnchor, constant: 4),
self.badgeLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 16),
self.badgeLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: 16),
// customContainerView 约束
self.customContainerView.topAnchor.constraint(equalTo: topAnchor),
self.customContainerView.leadingAnchor.constraint(equalTo: leadingAnchor),
self.customContainerView.trailingAnchor.constraint(equalTo: trailingAnchor),
self.customContainerView.bottomAnchor.constraint(equalTo: bottomAnchor)
]
NSLayoutConstraint.activate(self.initialConstraints)
}
/// 配置 TabBar Item
/// - Parameters:
/// - model: Item 数据模型
/// - config: TabBar 配置
/// - selection: 选中状态
/// - tapAction: 点击回调
public func configure(with model: STTabBarItemModel, config: STTabBarConfig? = nil, selection: STTabBarItemSelection = .deselected, tapAction: (() -> Void)? = nil) {
self.itemModel = model
self.config = config
self.isSelected = selection.isSelected
self.tapAction = tapAction
self.lastEffectiveBarHeightUsed = -1
self.updateUI()
}
/// Tab 栏整体配置变更后由 `STCustomTabBar.updateConfig` 调用,使内部布局使用最新的 `config`(`STTabBarConfig` 为值类型,需主动同步)
public func reapplyTabBarConfig(_ config: STTabBarConfig) {
self.config = config
self.lastEffectiveBarHeightUsed = -1
guard self.itemModel != nil else { return }
self.updateUI()
}
/// 更新选中状态
/// - Parameter selection: 新的选中状态
public func updateSelection(_ selection: STTabBarItemSelection) {
let selected = selection.isSelected
guard self.isSelected != selected else { return }
self.isSelected = selected
if let config = config, config.enableAnimation {
self.animateSelection(selected)
} else {
self.updateUI()
}
}
/// 更新徽章数量
/// - Parameter count: 徽章数量
public func updateBadgeCount(_ count: Int) {
guard let model = self.itemModel else { return }
var updatedModel = model
updatedModel.badge.count = count
self.itemModel = updatedModel
self.updateBadgeUI()
}
private func updateUI() {
guard let model = self.itemModel else { return }
NSLayoutConstraint.deactivate(self.initialConstraints)
NSLayoutConstraint.deactivate(self.iconImageViewConstraints)
NSLayoutConstraint.deactivate(self.titleLabelConstraints)
self.initialConstraints.removeAll()
self.iconImageViewConstraints.removeAll()
self.titleLabelConstraints.removeAll()
switch model.displayMode {
case .imageOnly:
self.setupImageOnlyMode(model)
case .textOnly:
self.setupTextOnlyMode(model)
case .imageAndText:
self.setupImageAndTextMode(model)
case .custom:
self.setupCustomMode(model)
case .irregular:
self.setupIrregularMode(model)
}
self.updateBadgeUI()
self.updateCustomView()
}
/// 图标与标题间距(从 config 读取,fallback 2pt,下限 0)
private var titleGap: CGFloat { max(0, self.config?.imageTitleGap ?? 2) }
/// 单行标题占用高度(用于在固定 TabBar 高度内分配图标与「距顶」)
private func titleLineHeight(for model: STTabBarItemModel) -> CGFloat {
let font = UIFont(name: model.typography.fontName, size: model.typography.fontSize) ?? UIFont.st_systemFont(ofSize: model.typography.fontSize, weight: .medium)
return ceil(font.lineHeight)
}
/// 图文排版可用高度:`config.height` 与父视图(contentView)实际高度取较小,避免配置值与约束高度不一致时仍按大值排版
private func effectiveBarHeightForImageTextLayout() -> CGFloat {
let configured = max(1, self.config?.height ?? 49)
let measuredHeights = [self.bounds.height, self.superview?.bounds.height ?? 0]
.filter { $0 > 0.5 }
guard let measured = measuredHeights.min() else { return configured }
return max(1, min(configured, measured))
}
private func resolvedImageAndTextLayout(for model: STTabBarItemModel) -> (topInset: CGFloat, iconWidth: CGFloat, iconHeight: CGFloat) {
let barH = self.effectiveBarHeightForImageTextLayout()
let baseW = model.layout.imageSize?.width ?? 24
let baseH = model.layout.imageSize?.height ?? 24
let titleH = self.titleLineHeight(for: model)
let contentH = baseH + self.titleGap + titleH
let areaTop = self.config?.itemLayoutAreaTopInset ?? 0
let areaBottom = barH - (self.config?.itemLayoutAreaBottomInset ?? 0)
let usableH = max(contentH, areaBottom - areaTop)
let top = areaTop + max(0, floor((usableH - contentH) / 2))
var iconW = baseW
var iconH = baseH
if top + iconH + self.titleGap + titleH > barH {
let iconBudget = max(1, barH - top - self.titleGap - titleH)
let scale = min(1, iconBudget / baseH)
iconH = baseH * scale
iconW = baseW * scale
}
return (top, iconW, iconH)
}
private func setupImageOnlyMode(_ model: STTabBarItemModel) {
// 只显示图片
self.iconImageView.isHidden = false
self.titleLabel.isHidden = true
self.iconImageView.image = self.isSelected ? model.selectedImage : model.normalImage
self.backgroundColor = self.isSelected ? model.colors.selectedBackground : model.colors.normalBackground
self.alpha = self.isSelected ? 1.0 : (self.config?.unselectedAlpha ?? 0.7)
// 图片居中显示
self.iconImageViewConstraints = [
self.iconImageView.centerXAnchor.constraint(equalTo: centerXAnchor),
self.iconImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
self.iconImageView.widthAnchor.constraint(equalToConstant: model.layout.imageSize?.width ?? 24),
self.iconImageView.heightAnchor.constraint(equalToConstant: model.layout.imageSize?.height ?? 24)
]
NSLayoutConstraint.activate(self.iconImageViewConstraints)
}
private func setupTextOnlyMode(_ model: STTabBarItemModel) {
// 只显示文字
self.iconImageView.isHidden = true
self.titleLabel.isHidden = false
self.titleLabel.text = model.title
self.titleLabel.textColor = self.isSelected ? model.colors.selectedText : model.colors.normalText
let font = UIFont(name: model.typography.fontName, size: model.typography.fontSize) ?? UIFont.st_systemFont(ofSize: model.typography.fontSize, weight: .medium)
self.titleLabel.font = font
self.backgroundColor = self.isSelected ? model.colors.selectedBackground : model.colors.normalBackground
self.alpha = self.isSelected ? 1.0 : (self.config?.unselectedAlpha ?? 0.7)
// 文字居中显示
self.titleLabelConstraints = [
self.titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
self.titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
self.titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 4),
self.titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -4)
]
NSLayoutConstraint.activate(self.titleLabelConstraints)
}
private func setupImageAndTextMode(_ model: STTabBarItemModel) {
// 显示图片和文字(默认模式)
self.iconImageView.isHidden = false
self.titleLabel.isHidden = false
self.iconImageView.image = isSelected ? model.selectedImage : model.normalImage
self.titleLabel.text = model.title
self.titleLabel.textColor = self.isSelected ? model.colors.selectedText : model.colors.normalText
let font = UIFont(name: model.typography.fontName, size: model.typography.fontSize) ?? UIFont.st_systemFont(ofSize: model.typography.fontSize, weight: .medium)
self.titleLabel.font = font
self.backgroundColor = isSelected ? model.colors.selectedBackground : model.colors.normalBackground
self.alpha = self.isSelected ? 1.0 : (self.config?.unselectedAlpha ?? 0.7)
let layout = self.resolvedImageAndTextLayout(for: model)
// 图片在上,文字在下(距顶 + 图标 + 标题总高度不超过 TabBar,避免与固定高度冲突)
self.iconImageViewConstraints = [
self.iconImageView.centerXAnchor.constraint(equalTo: centerXAnchor),
self.iconImageView.topAnchor.constraint(equalTo: topAnchor, constant: layout.topInset),
self.iconImageView.widthAnchor.constraint(equalToConstant: layout.iconWidth),
self.iconImageView.heightAnchor.constraint(equalToConstant: layout.iconHeight)
]
NSLayoutConstraint.activate(self.iconImageViewConstraints)
self.titleLabelConstraints = [
self.titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
self.titleLabel.topAnchor.constraint(equalTo: self.iconImageView.bottomAnchor, constant: self.titleGap),
self.titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 4),
self.titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -4),
self.titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: 0)
]
NSLayoutConstraint.activate(self.titleLabelConstraints)
}
private func setupCustomMode(_ model: STTabBarItemModel) {
// 自定义视图模式
self.iconImageView.isHidden = true
self.titleLabel.isHidden = true
self.customContainerView.isHidden = false
self.backgroundColor = .clear
self.alpha = 1.0
}
private func setupIrregularMode(_ model: STTabBarItemModel) {
// 不规则(凸起)按钮模式
self.iconImageView.isHidden = false
self.titleLabel.isHidden = false
self.iconImageView.image = self.isSelected ? model.selectedImage : model.normalImage
self.titleLabel.text = model.title
self.titleLabel.textColor = model.colors.normalText
let font = UIFont(name: model.typography.fontName, size: model.typography.fontSize) ?? UIFont.st_systemFont(ofSize: model.typography.fontSize, weight: .medium)
self.titleLabel.font = font
// 设置不规则按钮样式(去掉背景颜色)
self.backgroundColor = .clear
self.layer.cornerRadius = 0
self.layer.shadowColor = UIColor.clear.cgColor
self.layer.shadowOffset = .zero
self.layer.shadowRadius = 0
self.layer.shadowOpacity = 0
self.alpha = 1.0
let protrusion = model.irregular?.protrusionHeight ?? STTabBarIrregularStyle.standard.protrusionHeight
// 图片在上方,向上超出 tabbar,向下移动一些
self.iconImageViewConstraints = [
self.iconImageView.centerXAnchor.constraint(equalTo: centerXAnchor),
self.iconImageView.topAnchor.constraint(equalTo: topAnchor, constant: -protrusion + 10 + model.layout.imageTopInset - 6),
self.iconImageView.widthAnchor.constraint(equalToConstant: model.layout.imageSize?.width ?? 24),
self.iconImageView.heightAnchor.constraint(equalToConstant: model.layout.imageSize?.height ?? 24)
]
NSLayoutConstraint.activate(iconImageViewConstraints)
// 文字在下方(在 tabbar 内部),向下移动一些
self.titleLabelConstraints = [
self.titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
self.titleLabel.topAnchor.constraint(equalTo: self.iconImageView.bottomAnchor, constant: self.titleGap),
self.titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 4),
self.titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -4),
self.titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -6)
]
NSLayoutConstraint.activate(self.titleLabelConstraints)
}
private func updateBadgeUI() {
guard let model = self.itemModel else { return }
let badgeCount = model.badge.count
if badgeCount > 0 {
self.badgeLabel.isHidden = false
self.badgeLabel.text = badgeCount > 99 ? "99+" : "\(badgeCount)"
self.badgeLabel.backgroundColor = model.badge.backgroundColor
self.badgeLabel.textColor = model.badge.textColor
} else {
self.badgeLabel.isHidden = true
}
}
private func updateCustomView() {
guard let model = self.itemModel else { return }
self.customContainerView.subviews.forEach { $0.removeFromSuperview() }
if let customView = model.customView {
self.customContainerView.isHidden = false
self.customContainerView.addSubview(customView)
customView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
customView.topAnchor.constraint(equalTo: self.customContainerView.topAnchor),
customView.leadingAnchor.constraint(equalTo: self.customContainerView.leadingAnchor),
customView.trailingAnchor.constraint(equalTo: self.customContainerView.trailingAnchor),
customView.bottomAnchor.constraint(equalTo: self.customContainerView.bottomAnchor)
])
} else {
self.customContainerView.isHidden = true
}
}
private func animateSelection(_ selected: Bool) {
guard let model = itemModel, let config = config else { return }
UIView.animate(withDuration: config.animationDuration, delay: 0, options: [.curveEaseInOut], animations: {
let scale = selected ? config.selectedScale : 1.0
self.transform = CGAffineTransform(scaleX: scale, y: scale)
self.alpha = selected ? 1.0 : config.unselectedAlpha
self.titleLabel.textColor = selected ? model.colors.selectedText : model.colors.normalText
self.backgroundColor = selected ? model.colors.selectedBackground : model.colors.normalBackground
}, completion: { _ in
UIView.transition(with: self.iconImageView, duration: 0.2, options: .transitionCrossDissolve) {
self.iconImageView.image = selected ? model.selectedImage : model.normalImage
}
})
}
@objc private func handleTap() {
guard let model = self.itemModel, model.isEnabled else { return }
self.tapAction?()
}
override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) else { return }
guard var model = self.itemModel else { return }
var updated = false
if let name = model.normalImageName,
let img = UIImage(named: name)?.withRenderingMode(.alwaysOriginal) {
model.normalImage = img
updated = true
}
if let name = model.selectedImageName,
let img = UIImage(named: name)?.withRenderingMode(.alwaysOriginal) {
model.selectedImage = img
updated = true
}
if updated {
self.itemModel = model
self.updateUI()
}
}
private lazy var iconImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.isUserInteractionEnabled = false
return imageView
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.textAlignment = .center
label.font = UIFont.st_systemFont(ofSize: 10, weight: .medium)
label.isUserInteractionEnabled = false
return label
}()
private lazy var badgeLabel: UILabel = {
let label = UILabel()
label.textAlignment = .center
label.font = UIFont.st_systemFont(ofSize: 10, weight: .bold)
label.textColor = .white
label.backgroundColor = .systemRed
label.layer.cornerRadius = 8
label.layer.masksToBounds = true
label.isHidden = true
label.isUserInteractionEnabled = false
return label
}()
private lazy var customContainerView: UIView = {
let view = UIView()
view.isHidden = true
view.isUserInteractionEnabled = false
return view
}()
}
extension STTabBarItemView {
/// 创建默认的 TabBar Item
/// - Parameters:
/// - title: 标题
/// - normalImage: 普通图标
/// - selectedImage: 选中图标
/// - Returns: 配置好的 Item 视图
public static func createDefault(title: String, normalImage: UIImage?, selectedImage: UIImage?) -> STTabBarItemView {
let itemView = STTabBarItemView()
let model = STTabBarItemModel(title: title, normalImage: normalImage, selectedImage: selectedImage)
itemView.configure(with: model)
return itemView
}
}