给 CAShapeLayer 一个 3d 效果

Giving a CAShapeLayer a 3d effect

我正在 swift 玩倒数计时器。我想给倒计时圈一个3D效果的阴影。 下面的代码将完美地操作倒计时时间,但我想它更多的是我试图用它编辑的视觉效果。有什么办法可以使倒计时圆变大,同时赋予它 3d 效果。如果您 运行 代码,您将看到它只是一种二维填充。我一直在玩弄不同颜色和 alpha 的重叠圆圈,比如深色,试图让它看起来更 3d,但它绝对不是最有效的,因为它涉及一次绘制多个圆圈。有没有办法获得类似下图的 3d 效果,而无需重新绘制多个重叠的圆圈。下面的代码用于计数器的基本二维平面版本。

//for countdown timer: ----------------
let timeLeftShapeLayer = CAShapeLayer()
let bgShapeLayer = CAShapeLayer()
var timeLeft: TimeInterval = 10
var endTime: Date?
var timeLabel =  UILabel()
var timer = Timer()
// here you create your basic animation object to animate the strokeEnd
let strokeIt = CABasicAnimation(keyPath: "strokeEnd")

func drawBgShape() {
    bgShapeLayer.path = UIBezierPath(arcCenter: CGPoint(x: view.frame.midX , y: view.frame.midY), radius:
        100, startAngle: -90.degreesToRadians, endAngle: 270.degreesToRadians, clockwise: true).cgPath
    bgShapeLayer.strokeColor = UIColor.white.cgColor
    bgShapeLayer.fillColor = UIColor.clear.cgColor
    bgShapeLayer.lineWidth = 15
    view.layer.addSublayer(bgShapeLayer)
}
func drawTimeLeftShape() {
    timeLeftShapeLayer.path = UIBezierPath(arcCenter: CGPoint(x: view.frame.midX , y: view.frame.midY), radius:
        100, startAngle: -90.degreesToRadians, endAngle: 270.degreesToRadians, clockwise: true).cgPath
    timeLeftShapeLayer.strokeColor = UIColor.red.cgColor
    timeLeftShapeLayer.fillColor = UIColor.clear.cgColor
    timeLeftShapeLayer.lineWidth = 15
    view.layer.addSublayer(timeLeftShapeLayer)
}

func addTimeLabel() {
    timeLabel = UILabel(frame: CGRect(x: view.frame.midX-50 ,y: view.frame.midY/3, width: 100, height: 50))
    timeLabel.textAlignment = .center
    timeLabel.text = timeLeft.time
    view.addSubview(timeLabel)
}

@objc func updateTime() {
    if timeLeft > 0 {
        timeLeft = endTime?.timeIntervalSinceNow ?? 0
        timeLabel.text = timeLeft.time
    } else {
        timeLabel.text = "00:00"
        timer.invalidate()
    }
}

//-------------timer finish------------(extension for timer at bottom of file------

扩展:

//extensions for timer
extension TimeInterval {
    var time: String {
        return String(format:"%02d:%02d", Int(self/60),  Int(ceil(truncatingRemainder(dividingBy: 60))) )
    }
}
extension Int {
    var degreesToRadians : CGFloat {
        return CGFloat(self) * .pi / 180
    }
}

ViewDidload:

//for countdown timer: -------------
    view.backgroundColor = UIColor(white: 0.94, alpha: 1.0)
        drawBgShape()
        drawTimeLeftShape()
        addTimeLabel()
        // here you define the fromValue, toValue and duration of your animation
        strokeIt.fromValue = 0
        strokeIt.toValue = 1
        strokeIt.duration = timeLeft
        // add the animation to your timeLeftShapeLayer
        timeLeftShapeLayer.add(strokeIt, forKey: nil)
        // define the future end time by adding the timeLeft to now Date()
        endTime = Date().addingTimeInterval(timeLeft)
        timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(updateTime), userInfo: nil, repeats: true)

要获得 3D 效果,您通常会使用颜色渐变。在您的用例中,您将使用径向 CAGradientLayer。您必须屏蔽此层以仅查看您希望可见的区域。要填充的路径由外圈和内圈的区域组成。

这个填充路径可以创建如下:

let path = UIBezierPath(arcCenter: centerPoint, radius: outerRadius, startAngle: 0, endAngle: .pi * 2, clockwise: true)
let inner = UIBezierPath(arcCenter: centerPoint, radius: outerRadius - thickness, startAngle: 0, endAngle: .pi * 2, clockwise: true)
path.append(inner.reversing())

对于渐变,您可以使用 locations 参数指定一个 NSNumber 对象数组,这些对象定义渐变停止点的位置。这些值必须在 [0,1] 范围内。 CGColor类型对应的关联颜色设置在colors 属性.

在简单的情况下,您可以定义如下内容:

gradient.locations = [0,                                          //0
                      NSNumber(value: innerRadius / outerRadius), //1
                      NSNumber(value: middle / outerRadius),      //2
                      1]                                          //3

let colors = [color,            //0
              color,            //1
              color.lighter(),  //2
              color,            //3
]
gradient.colors = colors.map { [=11=].cgColor }

但是,只有在应用相应路径的蒙版后,才能看到所需的 3D 外观,请参见右图:

动画

很容易看出,你可以用一张CAGradientLayer作为背景,一张作为前景。那么问题自然而然地出现了,我们如何用前景渐变来动画填充过程?

这可以通过将前景渐变置于背景渐变之上并使用 CAShapeLayer 作为前景渐变的遮罩来实现。这样做时,动画的完成方式类似于您问题中使用 strokeEnd 属性 的示例。由于是遮罩,前景渐变逐渐可见

渐变

渐变可以包含多个区域。 3D 效果通常通过组合相似颜色的稍浅或较深的渐变来实现。对于演示示例,我使用了这个经过最少修改的漂亮 answer 来获得更亮或更暗的颜色变体。

演示

使用以上几点,这可能如下所示:

颜色、距离、渐变当然要看需求了,这只是个例子,怎么做出来的。对于前景渐变,阴影区域(内部圆形区域)和外部圆形区域(在光线中)选择了两种相似但不同的颜色。

Self-Contained 完整示例

CircleProgressView.swift

import UIKit

class CircleProgressView: UIView {

    private let backgroundGradient = CAGradientLayer()
    private let foregroundGradient = CAGradientLayer()
    private let timeLeftShapeLayer = CAShapeLayer()
    private let backgroundMask = CAShapeLayer()
    private let thickness: CGFloat

    private let innerBackgroundColor: UIColor
    private let outerBackgroundColor: UIColor
    private let innerForegroundColor: UIColor
    private let outerForegroundColor: UIColor

    init(_ thickness: CGFloat,
         _ innerBackgroundColor: UIColor,
         _ outerBackgroundColor: UIColor,
         _ innerForegroundColor: UIColor,
         _ outerForegroundColor: UIColor) {
        
        self.thickness = thickness
        self.innerBackgroundColor = innerBackgroundColor
        self.outerBackgroundColor = outerBackgroundColor
        self.innerForegroundColor = innerForegroundColor
        self.outerForegroundColor = outerForegroundColor

        super.init(frame: .zero)
        
        backgroundGradient.type = .radial
        layer.addSublayer(backgroundGradient)

        foregroundGradient.type = .radial
        layer.addSublayer(foregroundGradient)

        timeLeftShapeLayer.strokeColor = UIColor.white.cgColor
        timeLeftShapeLayer.fillColor = UIColor.clear.cgColor
        timeLeftShapeLayer.lineWidth = thickness
        layer.addSublayer(timeLeftShapeLayer)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
        
    private func circle(_ gradient: CAGradientLayer,
                        _ path: UIBezierPath,
                        _ outerRadius: CGFloat,
                        _ innerColor: UIColor,
                        _ outerColor: UIColor) {
        let innerRadius = outerRadius - thickness
        let middle = outerRadius - thickness / 2
        let slice: CGFloat = thickness / 16
        gradient.frame = bounds
        gradient.locations = [0,                                                  //0
                              NSNumber(value: (innerRadius) / outerRadius),       //1
                              NSNumber(value: (middle - slice) / outerRadius),    //2
                              NSNumber(value: (middle) / outerRadius),            //3
                              NSNumber(value: (middle + slice) / outerRadius),    //4
                              1]                                                  //5
        
        let colors = [innerColor,               //0
                      innerColor,               //1
                      innerColor.darker(),      //2
                      outerColor,               //3
                      outerColor.lighter(),     //4
                      outerColor                //5
        ]
        gradient.colors = colors.map { [=12=].cgColor }
        gradient.bounds = path.bounds
        gradient.startPoint = CGPoint(x: 0.5, y: 0.5)
        gradient.endPoint = CGPoint(x: 1, y: 1)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()

        let outerRadius: CGFloat = min(bounds.width, bounds.height) / 2.0
        let centerPoint = CGPoint(x: bounds.midX, y: bounds.midY)

        let path = UIBezierPath(arcCenter: centerPoint, radius: outerRadius, startAngle: 0, endAngle: .pi * 2, clockwise: true)
        let inner = UIBezierPath(arcCenter: centerPoint, radius: outerRadius - thickness, startAngle: 0, endAngle: .pi * 2, clockwise: true)
        path.append(inner.reversing())


        circle(backgroundGradient, path, outerRadius, innerBackgroundColor, outerBackgroundColor)

        backgroundMask.frame = bounds
        backgroundMask.path = path.cgPath
        backgroundMask.lineWidth = 0
        backgroundGradient.mask = backgroundMask

        circle(foregroundGradient, path, outerRadius, innerForegroundColor, outerForegroundColor)

        let middlePath = UIBezierPath(arcCenter: centerPoint, radius: outerRadius - thickness / 2, startAngle: 0, endAngle: .pi * 2, clockwise: true)
        middlePath.lineWidth = thickness
        timeLeftShapeLayer.path = middlePath.cgPath
        foregroundGradient.mask = timeLeftShapeLayer
        timeLeftShapeLayer.strokeEnd = 0
    }
    
    func startAnimation() {
        timeLeftShapeLayer.removeAllAnimations()
        timeLeftShapeLayer.strokeEnd = 1
        DispatchQueue.main.async {
            let strokeIt = CABasicAnimation(keyPath: "strokeEnd")
            strokeIt.fromValue = 0
            strokeIt.toValue = 1
            strokeIt.duration = 5
            self.timeLeftShapeLayer.add(strokeIt, forKey: nil)
        }
    }
    
}

UIColor+Brightness.swift

仅出于完整性考虑,请注意,原文可在 找到。

import UIKit

extension UIColor {

   func lighter(amount : CGFloat = 0.15) -> UIColor {
       return hueColorWithBrightness(amount: 1 + amount)
   }

    func darker(amount : CGFloat = 0.15) -> UIColor {
       return hueColorWithBrightness(amount: 1 - amount)
   }

   private func hueColorWithBrightness(amount: CGFloat) -> UIColor {
       var hue: CGFloat = 0
       var saturation: CGFloat = 0
       var brightness: CGFloat = 0
       var alpha: CGFloat = 0
       
       if getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) {
           return UIColor( hue: hue,
                           saturation: saturation,
                           brightness: brightness * amount,
                           alpha: alpha )
       } else {
           return self
       }
   }

}

ViewController.swift

调用并不奇怪,应该看起来像这样:

import UIKit

class ViewController: UIViewController {
    
    private var circleProgressView: CircleProgressView?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = UIColor(red: 0xBB / 0xFF, green: 0xBB / 0xFF, blue: 0xBB / 0xFF, alpha: 1)
        
        let innerBackgroundColor = UIColor(red: 0x65 / 0xFF, green: 0x79 / 0xFF, blue: 0x85 / 0xFF, alpha: 1)
        let outerBackgroundColor = innerBackgroundColor
        let innerForegroundColor = UIColor(red: 0xCF / 0xFF, green: 0xC9 / 0xFF, blue: 0x22 / 0xFF, alpha: 1)
        let outerForegroundColor = UIColor(red: 0xF3 / 0xFF, green: 0xCA / 0xFF, blue: 0x46 / 0xFF, alpha: 1)
        
        let progressView = CircleProgressView(24, innerBackgroundColor, outerBackgroundColor, innerForegroundColor, outerForegroundColor)
        circleProgressView = progressView
        progressView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(progressView)
        
        let button = UIButton()
        button.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(button)
        button.setTitle("Start", for: .normal)
        button.setTitleColor(.blue, for: .normal)
        button.addTarget(self, action: #selector(onStart), for: .touchUpInside)
        
        let margin: CGFloat = 24
        
        NSLayoutConstraint.activate([
            progressView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: margin),
            progressView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: margin),
            progressView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -margin),
            button.topAnchor.constraint(equalTo: progressView.bottomAnchor, constant: margin),
            button.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: margin),
            button.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -margin),
            button.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -margin),
        ])
    }
    
    @objc func onStart() {
        circleProgressView?.startAnimation()
    }
    
}