小红书使用红色圆点代表选中状态, 这里使用的是胶囊, 思路异曲同工, 直接上代码
import SwiftUI
struct AnimatedPageControl: View {
@Binding var currentPage: Int
@State var visibleRange = 0...4
var totalPage: Int
var body: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
AnimatedPageWindow(
currentPage: $currentPage,
totalCount: totalPage,
visibleRange: visibleRange
)
}.frame(width: 56, height: 5)
.onChange(of: visibleRange) { oldValue, newValue in
if totalPage <= 5 {
return
}
if newValue.first == 0 {
DispatchQueue.main.async {
withAnimation {
proxy.scrollTo(newValue.first, anchor: .leading)
}
}
return
}
if newValue.last == totalPage - 1 {
DispatchQueue.main.async {
withAnimation {
proxy.scrollTo(newValue.first, anchor: .leading)
}
}
return
}
if newValue.last ?? 0 > oldValue.last ?? 0 {
DispatchQueue.main.async {
withAnimation {
proxy.scrollTo(newValue.last, anchor: .trailing)
}
}
}
if newValue.last ?? 0 < oldValue.last ?? 0 {
DispatchQueue.main.async {
withAnimation {
proxy.scrollTo(newValue.first, anchor: .leading)
}
}
}
}
}
.onChange(of: currentPage) { oldValue, newValue in
if totalPage <= 5 {
return
}
// 因为是轮播图, 考虑从 0 左滑, 从 totalPage - 1 右滑
if oldValue == 0, newValue == totalPage - 1 {
// 左滑, 轮播, 滑动到最后一个 traling
//滑动后记得更新visibleRange
visibleRange = totalPage - 5...totalPage - 1
} else if oldValue == totalPage - 1, newValue == 0 {
// 右滑, 轮播, 滑动到第一个, leading
visibleRange = 0...4
//滑动后记得更新visibleRange
} else if newValue > oldValue {
// 向右滑动, 如果右边还有, 向右滑动一格, 否则正常赋值胶囊
if visibleRange.last == newValue && newValue < totalPage - 1 {
// 如果右边还有, 向右滑动一格
// 滑动后记得更新visibleRange
let start = (visibleRange.first ?? 0) + 1
let last = (visibleRange.last ?? 0) + 1
visibleRange = start...last
}
} else {
// 向左滑动, 如果左边还有
if visibleRange.first == newValue && newValue != 0 {
// 如果左边还有, 向左滑动一格
// 滑动后记得更新visibleRange
let start = (visibleRange.first ?? 0) - 1
let last = (visibleRange.last ?? 0) - 1
visibleRange = start...last
}
}
}
}
}
struct AnimatedPageWindow: View {
@Binding var currentPage: Int
let totalCount: Int
let visibleRange: ClosedRange<Int>
// MARK: 默认参数
private let dotHeight: CGFloat = 5
private let dotWidth: CGFloat = 5
private let smalldotHeight: CGFloat = 3
private let smalldotWidth: CGFloat = 3
private let selectedExtraWidth: CGFloat = 7
private let spacing: CGFloat = 6
var body: some View {
HStack(spacing: spacing) {
ForEach(0..<totalCount, id: \.self) { index in
Capsule()
.fill(index == currentPage
? Color.black.opacity(0.45)
: Color.black.opacity(0.15))
.frame(
width: index == currentPage
? dotWidth + selectedExtraWidth
: isSmallDot(index) ? smalldotWidth : dotWidth,
height: isSmallDot(index) ? smalldotHeight : dotHeight
)
// 左右小点:本质 5x5,通过 padding 压成 3x3
.padding(.all, isSmallDot(index) ? 1 : 0)
.animation(.spring(response: 0.25, dampingFraction: 0.55), value: currentPage)
.id(index)
}
}
}
private func isSmallDot(_ index: Int) -> Bool {
// 当前页永远不是小点
if index == currentPage {
return false
}
// ❗️不可见范围:永远是小点
if !visibleRange.contains(index) {
return true
}
// 左边界小点(左侧还有页)
if index == visibleRange.first && visibleRange.first ?? 0 > 0 {
return true
}
// 右边界小点(右侧还有页)
if index == visibleRange.last && visibleRange.last ?? 0 < totalCount - 1 {
return true
}
return false
}
}