如何在SwiftUI中为图片轮播的页码指示器实现鱼眼效果?
SwiftUI 图片轮播鱼眼页码指示器实现方案
核心实现思路
要实现鱼眼效果的页码指示器,关键是根据当前轮播的激活位置,动态计算每个指示器的缩放比例:
- 绑定轮播的当前位置(可以是整数索引,也可以是滚动偏移的浮点进度)
- 计算每个指示器与当前位置的距离差
- 通过非线性缩放公式,让距离当前位置越近的指示器缩放越大,越远的越小,形成鱼眼视觉效果
代码实现示例
方案1:基于TabView的离散索引鱼眼效果
适合分页式轮播,切换页面时指示器缩放动画过渡:
struct FishEyePageIndicator: View { // 模拟轮播图片数据 private let carouselItems = ["img1", "img2", "img3", "img4", "img5"] // 当前选中的页码索引 @State private var currentPage = 0 // 鱼眼效果参数,可按需调整 private let baseIndicatorSize: CGFloat = 10 private let maxScale: CGFloat = 1.5 private let minScale: CGFloat = 0.7 private let scaleDecayFactor: CGFloat = 0.6 var body: some View { VStack { // 图片轮播容器 TabView(selection: $currentPage) { ForEach(carouselItems.indices, id: \.self) { index in Image(carouselItems[index]) .resizable() .scaledToFill() .frame(height: 250) .clipped() .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never)) .animation(.easeInOut, value: currentPage) // 自定义鱼眼指示器 HStack(spacing: 8) { ForEach(carouselItems.indices, id: \.self) { index in Circle() .fill(index == currentPage ? .blue : .gray.opacity(0.5)) .frame(width: baseIndicatorSize, height: baseIndicatorSize) .scaleEffect(scaleForIndicator(at: index)) .animation(.easeInOut(duration: 0.3), value: currentPage) } } .padding(.top, 16) } } // 计算单个指示器的缩放比例 private func scaleForIndicator(at index: Int) -> CGFloat { let distance = abs(currentPage - index) // 非线性衰减公式:距离越近,缩放越大;距离超过2后固定最小缩放 if distance >= 2 { return minScale } return maxScale - (maxScale - minScale) * pow(scaleDecayFactor, CGFloat(distance)) } }
方案2:基于ScrollView的平滑实时鱼眼效果
支持拖拽过程中指示器实时缩放,过渡更流畅:
struct SmoothFishEyeIndicator: View { private let carouselItems = ["img1", "img2", "img3", "img4", "img5"] // 滚动偏移量,用于计算当前轮播进度 @State private var scrollOffset: CGFloat = 0 private let itemWidth = UIScreen.main.bounds.width // 鱼眼效果参数 private let baseIndicatorSize: CGFloat = 10 private let maxScale: CGFloat = 1.5 private let minScale: CGFloat = 0.7 var body: some View { VStack { // 横向滚动轮播 ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 0) { ForEach(carouselItems.indices, id: \.self) { index in Image(carouselItems[index]) .resizable() .scaledToFill() .frame(width: itemWidth, height: 250) .clipped() } } .background( GeometryReader { proxy in Color.clear .preference(key: ScrollOffsetKey.self, value: proxy.frame(in: .named("carouselScroll")).minX) } ) } .coordinateSpace(name: "carouselScroll") .onPreferenceChange(ScrollOffsetKey.self) { offset in scrollOffset = offset } // 实时缩放的鱼眼指示器 HStack(spacing: 8) { ForEach(carouselItems.indices, id: \.self) { index in Circle() .fill(indicatorColor(for: index)) .frame(width: baseIndicatorSize, height: baseIndicatorSize) .scaleEffect(scaleForIndicator(at: index)) } } .padding(.top, 16) } } // 实时计算缩放比例 private func scaleForIndicator(at index: Int) -> CGFloat { let currentProgress = -scrollOffset / itemWidth let distance = abs(currentProgress - CGFloat(index)) // 用余弦曲线实现平滑过渡:距离0时maxScale,距离2时minScale if distance > 2 { return minScale } return maxScale - (maxScale - minScale) * (1 - cos(distance * .pi / 2)) / 2 } // 指示器颜色随距离动态变化 private func indicatorColor(for index: Int) -> Color { let currentProgress = -scrollOffset / itemWidth let distance = abs(currentProgress - CGFloat(index)) return distance < 0.5 ? .blue : .gray.opacity(0.5 + (1 - distance) * 0.5) } } // 自定义PreferenceKey,用于获取滚动偏移量 private struct ScrollOffsetKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } }
优化建议
- 调整缩放公式:可以根据设计需求修改缩放逻辑,比如用
1 / (1 + 0.4 * distance)实现反比例衰减,或者二次函数让缩放变化更陡 - 动画优化:替换
.easeInOut为.spring(response: 0.3, dampingFraction: 0.7),让指示器缩放更有弹性 - 响应式适配:将指示器的基础大小、间距改为相对单位(比如
UIScreen.main.bounds.width * 0.025),适配不同屏幕 - 性能优化:当指示器数量超过8个时,改用
LazyHStack避免一次性渲染所有视图 - 视觉增强:给选中的指示器添加阴影(
.shadow(color: .blue.opacity(0.3), radius: 3)),提升层次感
内容的提问来源于stack exchange,提问作者Upendra Siripurapu
相关产品推荐
相关产品推荐

