I am trying to make a progress view which consists of two moving waves with gradient fill. The waves should only be inside (bound by) a bezier path (here a triangle). This is my code:
class WaveView: UIView {
private let firstLayer = CAShapeLayer()
private let secondLayer = CAShapeLayer()
private let gradientLayer = CAGradientLayer()
private let shapeLayer = CAShapeLayer()
private var trianglePath: UIBezierPath {
let w = bounds.width
let h = bounds.height
let path = UIBezierPath()
path.move(to: CGPoint(x: w / 2, y: 0))
path.addLine(to: CGPoint(x: w, y: h))
path.addLine(to: CGPoint(x: 0, y: h))
path.close()
return path
}
private var firstColor: UIColor = .clear
private var secondColor: UIColor = .clear
private var offset: CGFloat = 0.0
private let twoPie: CGFloat = 2.0 * .pi
var showSingleWave: Bool = false
private var start: Bool = false
private(set) var progress: CGFloat = 0.0
private var waveHeight: CGFloat = 0.0
override init(frame: CGRect) {
super.init(frame: frame)
setupViews()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupViews() {
waveHeight = 20.0
firstColor = .cyan
secondColor = .cyan.withAlphaComponent(0.4)
createStarLayer()
createFirstLayer()
if !showSingleWave {
createSecondLayer()
}
createGradientLayer()
}
private func createStarLayer() {
shapeLayer.path = trianglePath.cgPath
//shapeLayer.masksToBounds = true
}
private func createGradientLayer() {
gradientLayer.frame = bounds
gradientLayer.colors = [UIColor.blue.cgColor, UIColor.red.cgColor]
gradientLayer.startPoint = CGPoint(x: 0.5, y: 1.0)
gradientLayer.endPoint = CGPoint(x: 0.5, y: 0.0)
gradientLayer.mask = shapeLayer
layer.insertSublayer(gradientLayer, at: 0)
}
private func createFirstLayer() {
firstLayer.frame = bounds
firstLayer.anchorPoint = .zero
firstLayer.fillColor = firstColor.cgColor
shapeLayer.addSublayer(firstLayer)
}
private func createSecondLayer() {
secondLayer.frame = bounds
secondLayer.anchorPoint = .zero
secondLayer.fillColor = secondColor.cgColor
shapeLayer.addSublayer(secondLayer)
}
func setProgress(_ pr: CGFloat) {
progress = min(pr, 1.0)
let top: CGFloat = progress * bounds.height
firstLayer.setValue(bounds.width - top, forKeyPath: "position.y")
secondLayer.setValue(bounds.width - top, forKeyPath: "position.y")
if !start {
DispatchQueue.main.async {
self.startAnim()
}
}
}
private func startAnim() {
start = true
waterWaveAnim()
}
private func waterWaveAnim() {
let w = bounds.width
let h = bounds.height
let W = w * 10
let bezier = UIBezierPath()
let startOffsetY = waveHeight * CGFloat(sinf(Float(offset * twoPie / w)))
var originOffsetY: CGFloat = 0.0
bezier.move(to: CGPoint(x: 0.0, y: startOffsetY))
for i in stride(from: 0.0, to: W, by: 20.0) {
originOffsetY = waveHeight * CGFloat(sinf(Float(twoPie / w * i offset * twoPie / w)))
bezier.addLine(to: CGPoint(x: i, y: originOffsetY))
}
bezier.addLine(to: CGPoint(x: W, y: originOffsetY))
bezier.addLine(to: CGPoint(x: W, y: h))
bezier.addLine(to: CGPoint(x: 0.0, y: h))
bezier.addLine(to: CGPoint(x: 0.0, y: startOffsetY))
bezier.close()
let anim = CABasicAnimation(keyPath: "transform.translation.x")
anim.duration = 1.0
anim.fromValue = -w * 0.5
anim.toValue = -w - w * 0.5
anim.repeatCount = .infinity
anim.isRemovedOnCompletion = false
firstLayer.fillColor = firstColor.cgColor
firstLayer.path = bezier.cgPath
firstLayer.add(anim, forKey: nil)
if !showSingleWave {
let bezier = UIBezierPath()
let startOffsetY = waveHeight * CGFloat(sinf(Float(offset * twoPie / w)))
var originOffsetY: CGFloat = 0.0
bezier.move(to: CGPoint(x: 0.0, y: startOffsetY))
for i in stride(from: 0.0, to: W, by: 20.0) {
originOffsetY = waveHeight * CGFloat(-sinf(Float(twoPie / w * i offset * twoPie / w)))
bezier.addLine(to: CGPoint(x: i, y: originOffsetY))
}
bezier.addLine(to: CGPoint(x: W, y: originOffsetY))
bezier.addLine(to: CGPoint(x: W, y: h))
bezier.addLine(to: CGPoint(x: 0.0, y: h))
bezier.addLine(to: CGPoint(x: 0.0, y: startOffsetY))
bezier.close()
secondLayer.fillColor = secondColor.cgColor
secondLayer.path = bezier.cgPath
secondLayer.add(anim, forKey: nil)
}
}
}
Usage:
let wv = WaveView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
wv.showSingleWave = false
view.addSubview(wv)
wv.snp.makeConstraints { make in
make.size.equalTo(wv.frame.size)
make.center.equalToSuperview()
}
wv.setProgress(0.5)
The result is:
which is not what I am looking for. The waves fill the frame according to the progress value but the triangle shape is totally filled with the gradient colors. How can I make the waves be only inside the path (triangle) and such that the path is filled with the wave based on the progress value.
CodePudding user response:
A lot of your setup code should be re-worked, so the view can adjust to size changes... but, for now...
You want to apply the mask to the view's layer -- not to the shapeLayer
.
Change your setupViews()
to this:
private func setupViews() {
// set the "base" background color
self.layer.backgroundColor = UIColor.red.cgColor
waveHeight = 20.0
firstColor = .cyan
secondColor = .cyan.withAlphaComponent(0.4)
// don't call this
//createStarLayer()
createFirstLayer()
if !showSingleWave {
createSecondLayer()
}
createGradientLayer()
// apply the triangle shape mask to self.layer
let maskLayer = CAShapeLayer()
maskLayer.path = trianglePath.cgPath
self.layer.mask = maskLayer
}
an example controller - "progress" starts at 0.1
each tap will increment it by 0.1
until it reaches 1.0
:
class WaveTestVC: UIViewController {
var wView: WaveView!
var pct: CGFloat = 0.1
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
let wv = WaveView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
wv.showSingleWave = false
view.addSubview(wv)
wView = wv
wv.snp.makeConstraints { make in
make.size.equalTo(wv.frame.size)
make.center.equalToSuperview()
}
wv.setProgress(pct)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
pct = 0.1
pct = min(pct, 1.0)
wView.setProgress(pct)
}
}
This will be the result: