iOS 抖音首页头部滑动标签的实现

发布于:2025-06-12 ⋅ 阅读:(24) ⋅ 点赞:(0)

抖音首页的头部滑动标签(通常称为"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 
}

总结

通过以上高级优化实现,你可以获得一个接近抖音效果的滑动标签栏,具有以下特点:

  1. 平滑的滑动动画和指示器过渡效果
  2. 动态字体缩放和颜色渐变
  3. 高效的页面预加载机制
  4. 优化的性能与内存管理
  5. 标签重用机制支持大量标签

这些优化可以显著提升用户体验,使滑动更加流畅,响应更加迅速,同时保持良好的内存使用效率。