抖音首页的头部滑动标签(通常称为"Segmented Control"或"Tab Bar")是一个常见的UI组件,可以通过以下几种方式实现:
1. 使用UISegmentedControl
最简单的实现方式是使用系统自带的UISegmentedControl
:
let segmentedControl = UISegmentedControl(items: ["推荐", "关注", "同城"])
segmentedControl.selectedSegmentIndex = 0
segmentedControl.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
navigationItem.titleView = segmentedControl
@objc func segmentChanged(_ sender: UISegmentedControl) {
// 处理标签切换
print("Selected segment: \(sender.selectedSegmentIndex)")
}
2. 自定义实现(更接近抖音效果)
抖音的效果通常是水平滚动的标签栏,可以这样实现:
import UIKit
class TikTokTabBar: UIView {
private let scrollView = UIScrollView()
private var buttons: [UIButton] = []
private let indicator = UIView()
private var currentIndex: Int = 0
var titles: [String] = [] {
didSet {
setupButtons()
}
}
var onTabSelected: ((Int) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupUI()
}
private func setupUI() {
scrollView.showsHorizontalScrollIndicator = false
addSubview(scrollView)
indicator.backgroundColor = .red
scrollView.addSubview(indicator)
}
override func layoutSubviews() {
super.layoutSubviews()
scrollView.frame = bounds
var x: CGFloat = 0
let buttonHeight = bounds.height - 4
let padding: CGFloat = 20
for (index, button) in buttons.enumerated() {
let width = button.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude,
height: buttonHeight)).width + padding * 2
button.frame = CGRect(x: x, y: 0, width: width, height: buttonHeight)
x += width
if index == currentIndex {
indicator.frame = CGRect(x: button.frame.minX + padding,
y: buttonHeight,
width: button.frame.width - padding * 2,
height: 3)
}
}
scrollView.contentSize = CGSize(width: x, height: bounds.height)
}
private func setupButtons() {
buttons.forEach { $0.removeFromSuperview() }
buttons.removeAll()
for (index, title) in titles.enumerated() {
let button = UIButton(type: .custom)
button.setTitle(title, for: .normal)
button.setTitleColor(index == 0 ? .white : .lightGray, for: .normal)
button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium)
button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
button.tag = index
scrollView.addSubview(button)
buttons.append(button)
}
}
@objc private func buttonTapped(_ sender: UIButton) {
selectTab(at: sender.tag, animated: true)
onTabSelected?(sender.tag)
}
func selectTab(at index: Int, animated: Bool) {
guard index >= 0 && index < buttons.count else { return }
let button = buttons[index]
currentIndex = index
UIView.animate(withDuration: animated ? 0.25 : 0) {
self.buttons.forEach {
$0.setTitleColor($0.tag == index ? .white : .lightGray, for: .normal)
}
self.indicator.frame = CGRect(x: button.frame.minX + 20,
y: button.frame.height,
width: button.frame.width - 40,
height: 3)
// 确保选中的标签可见
let visibleRect = CGRect(x: button.frame.minX - 30,
y: 0,
width: button.frame.width + 60,
height: self.scrollView.frame.height)
self.scrollView.scrollRectToVisible(visibleRect, animated: animated)
}
}
}
3. 结合PageViewController实现完整效果
要实现抖音首页的完整效果(滑动标签同时控制页面切换),可以结合UIPageViewController
:
class TikTokHomeViewController: UIViewController {
private let tabBar = TikTokTabBar()
private var pageViewController: UIPageViewController!
private var viewControllers: [UIViewController] = []
override func viewDidLoad() {
super.viewDidLoad()
// 设置标签栏
tabBar.titles = ["推荐", "关注", "同城"]
tabBar.onTabSelected = { [weak self] index in
self?.selectPage(at: index, animated: true)
}
navigationItem.titleView = tabBar
// 设置页面控制器
pageViewController = UIPageViewController(transitionStyle: .scroll,
navigationOrientation: .horizontal,
options: nil)
pageViewController.delegate = self
pageViewController.dataSource = self
// 添加子控制器
viewControllers = [
RecommendationViewController(),
FollowingViewController(),
NearbyViewController()
]
addChild(pageViewController)
view.addSubview(pageViewController.view)
pageViewController.didMove(toParent: self)
pageViewController.setViewControllers([viewControllers[0]], direction: .forward, animated: false)
}
private func selectPage(at index: Int, animated: Bool) {
guard index >= 0 && index < viewControllers.count else { return }
let direction: UIPageViewController.NavigationDirection = index > tabBar.currentIndex ? .forward : .reverse
pageViewController.setViewControllers([viewControllers[index]], direction: direction, animated: animated)
tabBar.selectTab(at: index, animated: animated)
}
}
extension TikTokHomeViewController: UIPageViewControllerDelegate, UIPageViewControllerDataSource {
func pageViewController(_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let index = viewControllers.firstIndex(of: viewController), index > 0 else { return nil }
return viewControllers[index - 1]
}
func pageViewController(_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let index = viewControllers.firstIndex(of: viewController), index < viewControllers.count - 1 else { return nil }
return viewControllers[index + 1]
}
func pageViewController(_ pageViewController: UIPageViewController,
didFinishAnimating finished: Bool,
previousViewControllers: [UIViewController],
transitionCompleted completed: Bool) {
if completed, let currentVC = pageViewController.viewControllers?.first,
let index = viewControllers.firstIndex(of: currentVC) {
tabBar.selectTab(at: index, animated: true)
}
}
}
高级优化
1. 动画效果:可以添加更流畅的滑动动画和指示器动画
2. 字体缩放:选中的标签可以放大字体,未选中的缩小
3. 预加载:预加载相邻的页面以提高响应速度
4. 性能优化:对于大量标签,实现重用机制
下面是优化的具体实现
1. 平滑滑动动画与指示器效果优化
实现思路
- 监听UIScrollView的滚动偏移量
- 根据偏移量动态计算指示器位置和宽度
- 实现标签颜色渐变效果
代码实现
// 在TikTokTabBar类中添加以下方法
private var lastContentOffset: CGFloat = 0
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.isTracking || scrollView.isDragging || scrollView.isDecelerating else { return }
let offsetX = scrollView.contentOffset.x
let scrollViewWidth = scrollView.bounds.width
let progress = (offsetX / scrollViewWidth) - CGFloat(currentIndex)
// 防止快速滑动时progress超出范围
let clampedProgress = max(-1, min(1, progress))
updateTabAppearance(progress: clampedProgress)
updateIndicatorPosition(progress: clampedProgress)
lastContentOffset = offsetX
}
private func updateTabAppearance(progress: CGFloat) {
let absProgress = abs(progress)
for (index, button) in buttons.enumerated() {
// 当前标签和下一个标签
if index == currentIndex || index == currentIndex + (progress > 0 ? 1 : -1) {
let isCurrent = index == currentIndex
let targetIndex = isCurrent ? (progress > 0 ? currentIndex + 1 : currentIndex - 1) : currentIndex
guard targetIndex >= 0 && targetIndex < buttons.count else { continue }
let targetButton = buttons[targetIndex]
// 颜色渐变
let currentColor = UIColor.white
let targetColor = UIColor.lightGray
let color = isCurrent ?
currentColor.interpolate(to: targetColor, progress: absProgress) :
targetColor.interpolate(to: currentColor, progress: absProgress)
button.setTitleColor(color, for: .normal)
// 字体缩放
let minScale: CGFloat = 0.9
let maxScale: CGFloat = 1.1
let scale = isCurrent ?
maxScale - (maxScale - minScale) * absProgress :
minScale + (maxScale - minScale) * absProgress
button.transform = CGAffineTransform(scaleX: scale, y: scale)
} else {
// 其他标签保持默认状态
button.setTitleColor(.lightGray, for: .normal)
button.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
}
}
}
private func updateIndicatorPosition(progress: CGFloat) {
guard currentIndex >= 0 && currentIndex < buttons.count else { return }
let currentButton = buttons[currentIndex]
var nextIndex = currentIndex + (progress > 0 ? 1 : -1)
nextIndex = max(0, min(buttons.count - 1, nextIndex))
let nextButton = buttons[nextIndex]
let absProgress = abs(progress)
// 计算指示器位置和宽度
let currentFrame = currentButton.frame
let nextFrame = nextButton.frame
let originX = currentFrame.minX + (nextFrame.minX - currentFrame.minX) * absProgress
let width = currentFrame.width + (nextFrame.width - currentFrame.width) * absProgress
indicator.frame = CGRect(
x: originX + 20,
y: currentFrame.height,
width: width - 40,
height: 3
)
}
// UIColor扩展,用于颜色插值
extension UIColor {
func interpolate(to color: UIColor, progress: CGFloat) -> UIColor {
var fromRed: CGFloat = 0, fromGreen: CGFloat = 0, fromBlue: CGFloat = 0, fromAlpha: CGFloat = 0
var toRed: CGFloat = 0, toGreen: CGFloat = 0, toBlue: CGFloat = 0, toAlpha: CGFloat = 0
self.getRed(&fromRed, green: &fromGreen, blue: &fromBlue, alpha: &fromAlpha)
color.getRed(&toRed, green: &toGreen, blue: &toBlue, alpha: &toAlpha)
let red = fromRed + (toRed - fromRed) * progress
let green = fromGreen + (toGreen - fromGreen) * progress
let blue = fromBlue + (toBlue - fromBlue) * progress
let alpha = fromAlpha + (toAlpha - fromAlpha) * progress
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
}
}
2. 字体缩放效果优化
实现思路
- 根据滑动进度动态调整标签字体大小
- 当前选中标签放大,相邻标签适当缩小
- 其他标签保持最小尺寸
代码实现
上面的updateTabAppearance
方法已经包含了字体缩放逻辑,这里补充字体缩放的具体参数:
// 在updateTabAppearance方法中添加以下参数
let minScale: CGFloat = 0.9 // 最小缩放比例
let maxScale: CGFloat = 1.1 // 最大缩放比例
let scale = isCurrent ?
maxScale - (maxScale - minScale) * absProgress :
minScale + (maxScale - minScale) * absProgress
button.transform = CGAffineTransform(scaleX: scale, y: scale)
3. 页面预加载机制
实现思路
- 预加载当前页面相邻的页面
- 使用UIPageViewController的缓存机制
- 监听滑动方向提前准备内容
代码实现
// 在TikTokHomeViewController中添加预加载逻辑
private var pendingIndex: Int?
private var direction: UIPageViewController.NavigationDirection = .forward
func pageViewController(_ pageViewController: UIPageViewController,
willTransitionTo pendingViewControllers: [UIViewController]) {
if let pendingVC = pendingViewControllers.first,
let index = viewControllers.firstIndex(of: pendingVC) {
pendingIndex = index
}
}
func pageViewController(_ pageViewController: UIPageViewController,
didFinishAnimating finished: Bool,
previousViewControllers: [UIViewController],
transitionCompleted completed: Bool) {
if completed, let pendingIndex = pendingIndex {
currentIndex = pendingIndex
tabBar.selectTab(at: currentIndex, animated: true)
// 预加载相邻页面
preloadAdjacentPages()
}
pendingIndex = nil
}
private func preloadAdjacentPages() {
// 预加载前一个页面
if currentIndex > 0 {
let previousIndex = currentIndex - 1
if let previousVC = pageViewController.dataSource?.pageViewController(
pageViewController,
viewControllerBefore: viewControllers[currentIndex]
) {
// 确保视图已加载
_ = previousVC.view
}
}
// 预加载后一个页面
if currentIndex < viewControllers.count - 1 {
let nextIndex = currentIndex + 1
if let nextVC = pageViewController.dataSource?.pageViewController(
pageViewController,
viewControllerAfter: viewControllers[currentIndex]
) {
// 确保视图已加载
_ = nextVC.view
}
}
}
// 修改selectPage方法以支持方向判断
private func selectPage(at index: Int, animated: Bool) {
guard index >= 0 && index < viewControllers.count else { return }
direction = index > currentIndex ? .forward : .reverse
pageViewController.setViewControllers([viewControllers[index]], direction: direction, animated: animated) { [weak self] _ in
self?.preloadAdjacentPages()
}
currentIndex = index
tabBar.selectTab(at: index, animated: animated)
}
4. 性能优化与标签重用
实现思路
- 对于大量标签,实现重用机制
- 只保留可视区域附近的标签
- 动态加载和卸载标签
代码实现
// 在TikTokTabBar中添加重用逻辑
private let reusableQueue = NSMutableSet()
private var visibleButtons =
private var allTitles =
func setTitles(_ titles: [String]) {
allTitles = titles
updateVisibleButtons()
}
private func updateVisibleButtons() {
// 计算当前可见范围
let visibleRange = calculateVisibleRange()
// 移除不再可见的按钮
for (index, button) in visibleButtons {
if !visibleRange.contains(index) {
button.removeFromSuperview()
reusableQueue.add(button)
visibleButtons.removeValue(forKey: index)
}
}
// 添加新可见的按钮
for index in visibleRange {
if visibleButtons[index] == nil {
let button = dequeueReusableButton()
configureButton(button, at: index)
scrollView.addSubview(button)
visibleButtons[index] = button
}
}
// 更新布局
setNeedsLayout()
}
private func calculateVisibleRange() -> ClosedRange<Int> {
let contentOffsetX = scrollView.contentOffset.x
let visibleWidth = scrollView.bounds.width
// 计算第一个和最后一个可见的索引
var startIndex = 0
var endIndex = allTitles.count - 1
// 这里可以添加更精确的计算逻辑
// 例如根据按钮宽度和偏移量计算
// 扩展可见范围,预加载左右各2个
startIndex = max(0, startIndex - 2)
endIndex = min(allTitles.count - 1, endIndex + 2)
return startIndex...endIndex
}
private func dequeueReusableButton() -> UIButton {
if let button = reusableQueue.anyObject() as? UIButton {
reusableQueue.remove(button)
return button
}
return UIButton(type: .custom)
}
private func configureButton(_ button: UIButton, at index: Int) {
button.setTitle(allTitles[index], for: .normal)
button.setTitleColor(index == currentIndex ? .white : .lightGray, for: .normal)
button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium)
button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
button.tag = index
}
// 在scrollViewDidScroll中调用updateVisibleButtons
func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateVisibleButtons()
// 其他滚动逻辑...
}
5. 综合优化与细节处理
5.1 弹性效果限制
// 在TikTokTabBar中
scrollView.bounces = false
scrollView.alwaysBounceHorizontal = false
5.2 点击动画效果
@objc private func buttonTapped(_ sender: UIButton) {
// 点击动画
UIView.animate(withDuration: 0.1, animations: {
sender.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
}) { _ in
UIView.animate(withDuration: 0.1) {
sender.transform = .identity
}
}
selectTab(at: sender.tag, animated: true)
onTabSelected?(sender.tag)
}
5.3 性能优化提示
// 在TikTokTabBar的初始化中
layer.shouldRasterize = true
layer.rasterizationScale = UIScreen.main.scale
5.4 内存管理优化
// 在视图控制器中
deinit {
scrollView.delegate = nil
}
总结
通过以上高级优化实现,你可以获得一个接近抖音效果的滑动标签栏,具有以下特点:
- 平滑的滑动动画和指示器过渡效果
- 动态字体缩放和颜色渐变
- 高效的页面预加载机制
- 优化的性能与内存管理
- 标签重用机制支持大量标签
这些优化可以显著提升用户体验,使滑动更加流畅,响应更加迅速,同时保持良好的内存使用效率。