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

修改自定义UIPageControl:用UIBezierPath绘制未选中圆点

自定义PageControl实现圆形未选中指示器及滚动过渡效果

当前代码通过圆角矩形绘制指示器,导致过渡时无法保持圆形。要实现需求,核心是改用圆形绘制逻辑,同时调整过渡动画为两个圆形的缩放渐变,而非拉伸矩形。

以下是修改后的完整代码:

public class PageControl: UIPageControl {
    // MARK: - Private Properties
    private var selectedIndex: Int = 0
    private var remainingDecimal: CGFloat = 0
    private var selectedColor: UIColor = .clear {
        didSet {
            reset()
        }
    }
    private var unselectedColor: UIColor = .clear {
        didSet {
            reset()
        }
    }
    // MARK: - Public Properties
    public var dotRadius: CGFloat = 3 {
        didSet {
            reset()
        }
    }
    // 移除dotHeight,因为圆形只需要radius控制大小
    public var dotSpacings: CGFloat = 7 {
        didSet {
            reset()
        }
    }
    public override var numberOfPages: Int {
        didSet {
            reset()
        }
    }
    public override var currentPage: Int {
        didSet {
            reset()
        }
    }
    public override var pageIndicatorTintColor: UIColor? {
        get {
            .clear
        }
        set {
            unselectedColor = newValue ?? .clear
        }
    }
    public override var currentPageIndicatorTintColor: UIColor? {
        get {
            .clear
        }
        set {
            selectedColor = newValue ?? .clear
        }
    }
    // MARK: - Public Functions
    // swiftlint:disable all
    public override func draw(_ rect: CGRect) {
        guard numberOfPages > 0 else { return }
        
        let dotDiameter = dotRadius * 2
        let totalDotWidth = dotDiameter + dotSpacings
        
        // 绘制所有静态未选中圆点
        for index in 0..<numberOfPages {
            if index != selectedIndex && index != selectedIndex + 1 {
                let centerX = CGFloat(index) * totalDotWidth + dotRadius
                let centerY = rect.midY
                let circleRect = CGRect(x: centerX - dotRadius, y: centerY - dotRadius, width: dotDiameter, height: dotDiameter)
                unselectedColor.setFill()
                UIBezierPath(ovalIn: circleRect).fill()
            }
        }
        
        // 处理过渡动画的两个圆点
        let selectedCenterX = CGFloat(selectedIndex) * totalDotWidth + dotRadius
        let nextCenterX = CGFloat(selectedIndex + 1) * totalDotWidth + dotRadius
        let centerY = rect.midY
        
        // 当前选中点:随滚动逐渐缩小
        let selectedScale = 1 - remainingDecimal
        let selectedSize = dotDiameter * selectedScale
        let selectedRect = CGRect(x: selectedCenterX - selectedSize/2, y: centerY - selectedSize/2, width: selectedSize, height: selectedSize)
        let selectedMixColor = between(selectedColor, and: unselectedColor, percentage: remainingDecimal)
        selectedMixColor.setFill()
        UIBezierPath(ovalIn: selectedRect).fill()
        
        // 下一个点:随滚动逐渐放大
        if selectedIndex + 1 < numberOfPages {
            let nextScale = remainingDecimal
            let nextSize = dotDiameter * nextScale
            let nextRect = CGRect(x: nextCenterX - nextSize/2, y: centerY - nextSize/2, width: nextSize, height: nextSize)
            let nextMixColor = between(unselectedColor, and: selectedColor, percentage: remainingDecimal)
            nextMixColor.setFill()
            UIBezierPath(ovalIn: nextRect).fill()
        }
    }
    // swiftlint:enable all
    public override var intrinsicContentSize: CGSize {
        let dotDiameter = dotRadius * 2
        guard numberOfPages > 0 else { return .zero }
        // 修正尺寸计算:(n个点的宽度) + (n-1个间距的宽度)
        let totalWidth = CGFloat(numberOfPages - 1) * dotSpacings + CGFloat(numberOfPages) * dotDiameter
        return .init(width: totalWidth, height: dotDiameter)
    }
    public func setOffset(_ offset: CGFloat, width: CGFloat) {
        // 限制索引范围,防止越界
        selectedIndex = max(0, min(Int(offset / width), numberOfPages - 1))
        remainingDecimal = offset / width - CGFloat(selectedIndex)
        setNeedsDisplay()
    }
    public func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let width = scrollView.bounds.width
        let offset = scrollView.contentOffset.x
        let pageIndex = Int(offset / width) % numberOfPages
        let newOffset = offset.truncatingRemainder(dividingBy: width * CGFloat(numberOfPages))

        setOffset(newOffset, width: width)
        currentPage = pageIndex
        setNeedsDisplay()
    }
    // MARK: - Private Functions
    private func reset() {
        self.invalidateIntrinsicContentSize()
        self.setNeedsDisplay()
    }
    private func between(_ color1: UIColor, and color2: UIColor, percentage: CGFloat) -> UIColor {
        let percentage = max(min(percentage, 1), 0)
        switch percentage {
        case 0: return color1
        case 1: return color2
        default:
            var (red1, green1, blue1, alpha1): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0)
            var (red2, green2, blue2, alpha2): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0)
            guard color1.getRed(&red1, green: &green1, blue: &blue1, alpha: &alpha1) else { return color1 }
            guard color2.getRed(&red2, green: &green2, blue: &blue2, alpha: &alpha2) else { return color2 }
            return UIColor(red: red1 + (red2 - red1) * percentage,
                           green: green1 + (green2 - green1) * percentage,
                           blue: blue1 + (blue2 - blue1) * percentage,
                           alpha: alpha1 + (alpha2 - alpha1) * percentage)
        }
    }
}

修改说明

  1. 移除多余属性:删掉了dotHeight,因为圆形尺寸仅由dotRadius控制,避免参数冗余
  2. 静态圆点绘制:所有未参与过渡的指示器直接用ovalIn绘制,确保始终是完美圆形
  3. 过渡动画优化:滚动时拆分出两个独立的圆形动画:当前选中点逐渐缩小,下一个点逐渐放大,两者始终保持圆形形态,同时伴随颜色渐变
  4. 修正尺寸计算:调整intrinsicContentSize的计算逻辑,避免多余留白,保证布局紧凑
  5. 边界安全处理:在setOffset中限制selectedIndex的范围,防止索引越界导致的绘制异常

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.06 23:32:01