如何在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
相关产品推荐
相关产品推荐

