You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在SwiftUI中用ScrollView实现动态分类选择功能?

实现带磁吸效果的动态下划线分类标签栏

以下是满足需求的完整代码,实现了点击/滚动选中分类、下划线宽度与标题匹配、滚动停止磁吸锁定的功能:

import SwiftUI

struct Category: Identifiable, Equatable {
    let id = UUID()
    let title: String
    let color: Color
}

struct ContentView: View {
    let categories: [Category] = [
        Category(title: "Red", color: .red),
        Category(title: "Green", color: .green),
        Category(title: "Blue", color: .blue),
        Category(title: "Yellow", color: .yellow),
        Category(title: "Orange", color: .orange),
        Category(title: "Purple", color: .purple)
    ]
    
    @State private var selectedCategory: Category
    @State private var buttonFrames: [UUID: CGRect] = [:]
    @State private var scrollOffset: CGFloat = 0
    
    init() {
        _selectedCategory = State(initialValue: categories.first!)
    }
    
    var body: some View {
        VStack {
            VStack(alignment: .leading) {
                ScrollViewReader { proxy in
                    ScrollView(.horizontal, showsIndicators: false) {
                        HStack(spacing: 20) {
                            ForEach(categories) { category in
                                CategoryButton(category: category, isSelected: category == selectedCategory)
                                    .background(
                                        GeometryReader { geo in
                                            Color.clear
                                                .onAppear {
                                                    buttonFrames[category.id] = geo.frame(in: .global)
                                                }
                                                .onChange(of: geo.frame(in: .global)) { newFrame in
                                                    buttonFrames[category.id] = newFrame
                                                }
                                        }
                                    )
                                    .id(category.id)
                                    .onTapGesture {
                                        withAnimation(.easeInOut) {
                                            selectedCategory = category
                                            proxy.scrollTo(category.id, anchor: .center)
                                        }
                                    }
                            }
                        }
                        .padding(.horizontal, 20)
                        .background(
                            GeometryReader { geo in
                                Color.clear
                                    .onChange(of: geo.frame(in: .global).origin.x) { offset in
                                        scrollOffset = offset
                                    }
                            }
                        )
                    }
                    .onEnded { _ in
                        findNearestCategory()
                        withAnimation(.easeInOut) {
                            proxy.scrollTo(selectedCategory.id, anchor: .center)
                        }
                    }
                }
                
                // 动态下划线
                Capsule()
                    .frame(
                        width: buttonFrames[selectedCategory.id]?.width ?? 0,
                        height: 5
                    )
                    .foregroundColor(.black)
                    .offset(
                        x: buttonFrames[selectedCategory.id]?.minX ?? 0 - 20, // 减去HStack的左侧padding
                        y: 0
                    )
                    .animation(.easeInOut, value: selectedCategory)
            }
            
            Circle()
                .frame(width: 70, height: 70)
                .foregroundColor(selectedCategory.color)
                .offset(y: 50)
            
            Spacer()
        }
    }
    
    // 计算滚动停止后最近的分类
    private func findNearestCategory() {
        guard let selectedFrame = buttonFrames[selectedCategory.id] else { return }
        let centerX = UIScreen.main.bounds.midX + scrollOffset
        
        var nearestCategory = selectedCategory
        var minDistance = abs(selectedFrame.midX - centerX)
        
        for category in categories {
            guard let frame = buttonFrames[category.id] else { continue }
            let distance = abs(frame.midX - centerX)
            if distance < minDistance {
                minDistance = distance
                nearestCategory = category
            }
        }
        
        selectedCategory = nearestCategory
    }
}

struct CategoryButton: View {
    let category: Category
    let isSelected: Bool
    
    var body: some View {
        Text(category.title)
            .font(.headline)
            .padding(10)
            .foregroundColor(isSelected ? .black : .gray) // 选中时高亮文字
            .cornerRadius(8)
    }
}

#Preview {
    ContentView()
}

关键功能实现说明

  • 动态下划线:通过GeometryReader获取每个分类按钮的尺寸和位置,将下划线的宽度和偏移量绑定到选中分类对应的按钮参数,实现完全匹配标题长度和位置的效果。
  • 点击选中与滚动定位:使用ScrollViewReader绑定滚动视图,点击按钮时自动滚动到对应分类的中心位置,并更新选中状态。
  • 滚动磁吸效果:监听滚动结束事件,计算屏幕中心点对应的最近分类,自动切换选中状态并滚动到该分类的中心位置。
  • 状态同步:通过@State变量保存每个按钮的位置信息,确保下划线和选中状态始终同步更新。

内容的提问来源于stack exchange,提问作者Schwifty

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.03 13:41:31