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

抖音首页的头部滑动标签(通常称为"Segmented Control"或"Tab Bar")是一个常见的UI组件,可以通过以下几种方式实现:

1. 使用UISegmentedControl

最简单的实现方式是使用系统自带的UISegmentedControl

swift 复制代码
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. 自定义实现(更接近抖音效果)

抖音的效果通常是水平滚动的标签栏,可以这样实现:

swift 复制代码
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

swift 复制代码
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的滚动偏移量
  • 根据偏移量动态计算指示器位置和宽度
  • 实现标签颜色渐变效果

代码实现

swift 复制代码
// 在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方法已经包含了字体缩放逻辑,这里补充字体缩放的具体参数:

swift 复制代码
// 在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的缓存机制
  • 监听滑动方向提前准备内容

代码实现

swift 复制代码
// 在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. 性能优化与标签重用

实现思路

  • 对于大量标签,实现重用机制
  • 只保留可视区域附近的标签
  • 动态加载和卸载标签

代码实现

swift 复制代码
// 在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 弹性效果限制

swift 复制代码
// 在TikTokTabBar中 
scrollView.bounces = false 
scrollView.alwaysBounceHorizontal = false 

5.2 点击动画效果

swift 复制代码
@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 性能优化提示

swift 复制代码
// 在TikTokTabBar的初始化中 
layer.shouldRasterize = true 
layer.rasterizationScale = UIScreen.main.scale 

5.4 内存管理优化

swift 复制代码
// 在视图控制器中 
deinit {
    scrollView.delegate = nil 
}

总结

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

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

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

相关推荐
无知的前端6 小时前
Flutter开发,GetX框架路由相关详细示例
android·flutter·ios
大熊猫侯佩7 小时前
iOS 18 中全新 SwiftData 重装升级,其中一个功能保证你们“爱不释手”
数据库·ios·swift
大熊猫侯佩7 小时前
SwiftUI 6.0(iOS 18)新容器视图修改器漫谈
ios·swiftui·wwdc
Digitally8 小时前
如何将 iPhone 中的短信导出为 PDF
ios·pdf·iphone
帅次1 天前
Flutter Container 组件详解
android·flutter·ios·小程序·kotlin·iphone·xcode
SoaringHeart1 天前
SwiftUI组件封装:仿 Flutter 原生组件 Wrap实现
ios·swiftui
十月ooOO1 天前
uniapp 云打包 iOS 应用上传到 app store 商店的过程
ios·uni-app
帅次1 天前
Flutter setState() 状态管理详细使用指南
android·flutter·ios·小程序·kotlin·android studio·iphone
kymjs张涛1 天前
前沿技术周刊 2025-06-03
android·前端·ios