Swift Control RollingCountdownView(动画倒计时)

一直觉得自己写的不是技术,而是情怀,一个个的教程是自己这一路走来的痕迹。靠专业技能的成功是最具可复制性的,希望我的这条路能让你们少走弯路,希望我能帮你们抹去知识的蒙尘,希望我能帮你们理清知识的脉络,希望未来技术之巅上有你们也有我。

Swift Control RollingCountdownView(动画倒计时) 下载链接

文章目录

效果

使用

bash 复制代码
import UIKit
import SnapKit

class ViewController: UIViewController {
    
    // MARK: - UI Components
    
    private let countdownView: RollingCountdownView = {
        let view = RollingCountdownView()
        view.unit = "分钟"
        view.numberFont = .systemFont(ofSize: 48, weight: .bold)
        view.textColor = .systemBlue
        view.animationDuration = 0.6
        return view
    }()
    
    private let valueLabel: UILabel = {
        let label = UILabel()
        label.text = "当前值: 0"
        label.font = .systemFont(ofSize: 16)
        label.textColor = .gray
        label.textAlignment = .center
        return label
    }()
    
    private let incrementButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("+1", for: .normal)
        button.titleLabel?.font = .systemFont(ofSize: 20, weight: .semibold)
        button.backgroundColor = .systemBlue
        button.setTitleColor(.white, for: .normal)
        button.layer.cornerRadius = 8
        return button
    }()
    
    private let decrementButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("-1", for: .normal)
        button.titleLabel?.font = .systemFont(ofSize: 20, weight: .semibold)
        button.backgroundColor = .systemRed
        button.setTitleColor(.white, for: .normal)
        button.layer.cornerRadius = 8
        return button
    }()
    
    private let setButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("设为 60", for: .normal)
        button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
        button.backgroundColor = .systemGreen
        button.setTitleColor(.white, for: .normal)
        button.layer.cornerRadius = 8
        return button
    }()
    
    private let resetButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("重置", for: .normal)
        button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
        button.backgroundColor = .systemOrange
        button.setTitleColor(.white, for: .normal)
        button.layer.cornerRadius = 8
        return button
    }()
    
    private let noAnimationSwitch: UISwitch = {
        let switchControl = UISwitch()
        return switchControl
    }()
    
    private let noAnimationLabel: UILabel = {
        let label = UILabel()
        label.text = "禁用动画"
        label.font = .systemFont(ofSize: 14)
        label.textColor = .darkGray
        return label
    }()
    
    private let buttonStackView: UIStackView = {
        let stack = UIStackView()
        stack.axis = .horizontal
        stack.spacing = 12
        stack.distribution = .fillEqually
        return stack
    }()
    
    private let controlStackView: UIStackView = {
        let stack = UIStackView()
        stack.axis = .vertical
        stack.spacing = 16
        stack.alignment = .center
        return stack
    }()
    
    // MARK: - Lifecycle
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupActions()
        
        // 初始值
        countdownView.setValue(0, animated: false)
    }
    
    // MARK: - Setup
    
    private func setupUI() {
        view.backgroundColor = .white
        title = "滚动倒计时"
        
        // 添加视图
        view.addSubview(countdownView)
        view.addSubview(valueLabel)
        view.addSubview(controlStackView)
        
        // 按钮容器
        [incrementButton, decrementButton, setButton, resetButton].forEach {
            buttonStackView.addArrangedSubview($0)
        }
        
        // 控制开关
        let switchStack = UIStackView(arrangedSubviews: [noAnimationLabel, noAnimationSwitch])
        switchStack.axis = .horizontal
        switchStack.spacing = 8
        switchStack.alignment = .center
        
        [buttonStackView, switchStack].forEach {
            controlStackView.addArrangedSubview($0)
        }
        
        // 布局
        countdownView.snp.makeConstraints { make in
            make.top.equalTo(view.safeAreaLayoutGuide).offset(80)
            make.centerX.equalToSuperview()
            make.height.equalTo(80)
            make.width.equalTo(200)
        }
        
        valueLabel.snp.makeConstraints { make in
            make.top.equalTo(countdownView.snp.bottom).offset(20)
            make.centerX.equalToSuperview()
        }
        
        controlStackView.snp.makeConstraints { make in
            make.top.equalTo(valueLabel.snp.bottom).offset(40)
            make.left.right.equalToSuperview().inset(40)
        }
        
        buttonStackView.snp.makeConstraints { make in
            make.width.equalToSuperview()
            make.height.equalTo(50)
        }
    }
    
    private func setupActions() {
        incrementButton.addTarget(self, action: #selector(incrementTapped), for: .touchUpInside)
        decrementButton.addTarget(self, action: #selector(decrementTapped), for: .touchUpInside)
        setButton.addTarget(self, action: #selector(setTapped), for: .touchUpInside)
        resetButton.addTarget(self, action: #selector(resetTapped), for: .touchUpInside)
    }
    
    // MARK: - Actions
    
    @objc private func incrementTapped() {
        let newValue = countdownView.value + 1
        let animated = !noAnimationSwitch.isOn
        countdownView.setValue(newValue, animated: animated)
        updateValueLabel()
    }
    
    @objc private func decrementTapped() {
        let newValue = max(0, countdownView.value - 1)
        let animated = !noAnimationSwitch.isOn
        countdownView.setValue(newValue, animated: animated)
        updateValueLabel()
    }
    
    @objc private func setTapped() {
        let newValue = 60
        let animated = !noAnimationSwitch.isOn
        countdownView.setValue(newValue, animated: animated)
        updateValueLabel()
    }
    
    @objc private func resetTapped() {
        let newValue = 0
        let animated = !noAnimationSwitch.isOn
        countdownView.setValue(newValue, animated: animated)
        updateValueLabel()
    }
    
    private func updateValueLabel() {
        valueLabel.text = "当前值: \(countdownView.value) \(countdownView.unit)"
    }
}

封装代码

bash 复制代码
import UIKit
import SnapKit

final class RollingCountdownView: UIView {

    // MARK: - Public

    /// 当前数字
    private(set) var value: Int = 0

    /// 是否已经设置过数字
    private var hasValue = false

    /// 单位
    var unit: String = "分钟" {
        didSet {
            unitLabel.text = unit
        }
    }

    /// 数字字体
    var numberFont: UIFont = .systemFont(ofSize: 15, weight: .medium) {
        didSet {
            currentNumberLabel.font = numberFont
            unitLabel.font = numberFont
        }
    }

    /// 文字颜色
    var textColor: UIColor = .systemBlue {
        didSet {
            currentNumberLabel.textColor = textColor
            unitLabel.textColor = textColor
        }
    }

    /// 动画时间
    var animationDuration: TimeInterval = 0.8

    // MARK: - UI

    /// 数字滚动容器
    private lazy var numberContainerView: UIView = {
        let view = UIView()
        view.clipsToBounds = true
        return view
    }()

    /// 当前数字
    private lazy var currentNumberLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .right
        label.font = numberFont
        label.textColor = textColor
        return label
    }()

    /// 单位
    private lazy var unitLabel: UILabel = {
        let label = UILabel()
        label.text = unit
        label.textAlignment = .left
        label.font = numberFont
        label.textColor = textColor
        return label
    }()

    // MARK: - Init

    override init(frame: CGRect) {
        super.init(frame: frame)
        buildUI()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        buildUI()
    }

    // MARK: - UI

    private func buildUI() {

        clipsToBounds = true

        addSubview(numberContainerView)
        addSubview(unitLabel)

        // 数字容器:占 50%
        numberContainerView.snp.makeConstraints { make in
            make.left.top.bottom.equalToSuperview()
            make.width.equalToSuperview().multipliedBy(0.4)
        }

        // 单位:占 50%
        unitLabel.snp.makeConstraints { make in
            make.left.equalTo(numberContainerView.snp.right)
            make.top.bottom.right.equalToSuperview()
            make.width.equalToSuperview().multipliedBy(0.6)
        }

        // 当前数字
        numberContainerView.addSubview(currentNumberLabel)

        currentNumberLabel.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
    }

    // MARK: - Public

    /// 设置数字
    /// - Parameters:
    ///   - value: 新数字
    ///   - animated: 是否播放滚动动画
    func setValue(_ value: Int, animated: Bool = true) {

        // 第一次设置
        if !hasValue {

            hasValue = true
            self.value = value

            currentNumberLabel.text = "\(value)"

            return
        }

        // 数字没有变化
        guard self.value != value else {
            return
        }

        let oldValue = self.value
        self.value = value

        // 不需要动画
        guard animated else {
            currentNumberLabel.text = "\(value)"
            return
        }

        animateChange(
            from: oldValue,
            to: value
        )
    }

    // MARK: - Animation

    private func animateChange(
        from oldValue: Int,
        to newValue: Int
    ) {

        layoutIfNeeded()
        numberContainerView.layoutIfNeeded()

        let oldLabel = currentNumberLabel

        // 创建新的数字 Label
        let newLabel = UILabel()
        newLabel.text = "\(newValue)"
        newLabel.textAlignment = .right
        newLabel.font = numberFont
        newLabel.textColor = textColor

        numberContainerView.addSubview(newLabel)

        // 和当前 Label 完全一样的位置
        newLabel.frame = numberContainerView.bounds

        // 新数字从下面开始
        newLabel.transform = CGAffineTransform(
            translationX: 0,
            y: numberContainerView.bounds.height
        )

        // 开始动画
        UIView.animate(
            withDuration: animationDuration,
            delay: 0,
            options: [
                .curveEaseInOut,
                .beginFromCurrentState,
                .allowUserInteraction
            ]
        ) {

            // 旧数字向上
            oldLabel.transform = CGAffineTransform(
                translationX: 0,
                y: -self.numberContainerView.bounds.height
            )

            // 新数字向上进入
            newLabel.transform = .identity

        } completion: { _ in

            // 恢复当前 Label
            oldLabel.transform = .identity
            oldLabel.text = "\(newValue)"

            // 删除临时 Label
            newLabel.removeFromSuperview()
        }
    }
}
相关推荐
昵称画1 小时前
交接班记录电子化:三班之间的信息不漏
开发语言·数据库·人工智能·低代码·php
“AI国潮设计-小江”1 小时前
【AIGC实战】Python+SDXL批量生成“潮汕英歌舞不倒翁”IP | 角色一致性与多场景融合方案,附完整代码
开发语言·人工智能·python·prompt·aigc
传奇开心果编程1 小时前
【Rust入门练中学】第6课:结构体与方法
开发语言·rust·结构体·编程语言理论·结构体方法·编程通用语言
code tsunami2 小时前
【无标题】
开发语言·python
CodeGL2 小时前
10、Python基础之进程和线程
开发语言·python
TechWayfarer3 小时前
PHP漏洞被利用,怎么揪出攻击IP?
开发语言·python·tcp/ip·网络安全·php
终端安全笔记4 小时前
一份配置管十项 AI 功能:它关的是入口,不是数据
网络安全·ios·移动设备管理
卡卡~~5 小时前
“101计划”核心课程实践教材《数据实验结构指导-C++语言版》题目集
开发语言·c++·学习·算法
qq_406981035 小时前
大白话rust系列09 函数调用两次有什么错?
开发语言·python·rust
学逆向的5 小时前
C 多文件开发
c语言·开发语言·网络安全