CABasicAnimation delay between repeat - swift

The code below creates a "shimmer" effect and works great. (The look of reflected light moving across a glossy surface.) It's currently set to repeat forever which is fine, however, I'd like there to be a pause between each shimmer. I can delay the start of the effect, but I can't seem to figure out how to delay each run. Bonus points if the delay time is random.
func startShimmering() {
let light = UIColor.white.cgColor
let alpha = UIColor.white.withAlphaComponent(0.0).cgColor
let gradient = CAGradientLayer()
gradient.colors = [alpha, light, alpha]
gradient.frame = CGRect(x: -self.bounds.size.width, y: 0, width: 3 * self.bounds.size.width, height: self.bounds.size.height)
gradient.startPoint = CGPoint(x: 1.0, y: 0.525)
gradient.endPoint = CGPoint(x: 0.0, y: 0.5)
gradient.locations = [0.1, 0.5, 0.9]
self.layer.mask = gradient
let animation = CABasicAnimation(keyPath: "locations")
animation.fromValue = [0.0, 0.1, 0.2]
animation.toValue = [0.8, 0.9, 1.0]
animation.duration = 1.5
animation.repeatCount = HUGE
gradient.add(animation, forKey: "shimmer")
}

As mentioned in Kevin's answer, you can use a CAAnimationGroup to add a delay to the end of your shimmer animation.
The "shimmer" animation will have a duration longer than the group's animation. Any difference between these two durations will be the delay between animations.
Here is the code to do so, ready for a Swift Playground:
class ShimmerView: UIView {
func startShimmering() {
let light = UIColor.white.cgColor
let alpha = UIColor.white.withAlphaComponent(0.0).cgColor
let gradient = CAGradientLayer()
gradient.colors = [alpha, light, alpha]
gradient.frame = CGRect(x: -self.bounds.size.width, y: 0, width: 3 * self.bounds.size.width, height: self.bounds.size.height)
gradient.startPoint = CGPoint(x: 1.0, y: 0.525)
gradient.endPoint = CGPoint(x: 0.0, y: 0.5)
gradient.locations = [0.1, 0.5, 0.9]
self.layer.mask = gradient
let shimmer = CABasicAnimation(keyPath: "locations")
shimmer.fromValue = [0.0, 0.1, 0.2]
shimmer.toValue = [0.8, 0.9, 1.0]
shimmer.duration = 1.5
shimmer.fillMode = kCAFillModeForwards
shimmer.isRemovedOnCompletion = false
let group = CAAnimationGroup()
group.animations = [shimmer]
group.duration = 2
group.repeatCount = HUGE
gradient.add(group, forKey: "shimmer")
}
}
let view = ShimmerView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
PlaygroundPage.current.liveView = view
view.backgroundColor = .blue
view.startShimmering()
Here is the end result:
(The gif animates the shimmer three times, which is why every third "shimmer" the delay is shorter.)

I haven't tried this myself but I believe you can wrap your animation in an animation group, set the group's duration to longer than the real animation's duration, and then set the group to repeat.
If that doesn't work, you could use a keypath animation instead, where most of the keypath is a reproduction of the normal animation, and the rest of the keypath just keeps the animation value pinned to either the start or end value.
If you want a random delay, your only choice is to actually run a timer (or use a CAAnimationDelegate) to set up a new animation each time.

Related

Smooth animation CAGradientLayer with long duration

Created CAGradientLayer with a sharp color transition. Position of the color transition changes during the time with using CABasicAnimation.
Everything works, but if I use long duration, animation doesn't produce a smooth change.
Code of gradient creation
override func draw(_ rect: CGRect) {
let path = CGMutablePath()
path.addRect(CGRect(x: 0, y: 0, width: 50, height: 50))
path.move(to: CGPoint(x: 75, y: 75))
path.addRect(CGRect(x: 75, y: 75, width: 50, height: 50))
let mask = CAShapeLayer()
mask.path = path
gradient = CAGradientLayer()
gradient.mask = mask
gradient.colors = [UIColor.orange.cgColor, UIColor.blue.cgColor]
gradient.locations = [0, 0]
gradient.startPoint = CGPoint(x: 0, y: 0)
gradient.endPoint = CGPoint(x: 1, y: 0)
gradient.frame = bounds
layer.insertSublayer(gradient, at: 0)
}
Animation code
func play() {
let animation = CABasicAnimation(keyPath: "locations")
animation.fromValue = [0, 0]
animation.toValue = [1, 1]
animation.duration = 120
animation.fillMode = .backwards
animation.isRemovedOnCompletion = false
gradient.add(animation, forKey: nil)
}
Demonstration of how the code works
https://i.stack.imgur.com/eUvOo.gif
Are there any options to make the changes smooth with long animation duration?
This looks like a limitation of CAGradientLayer
You can check current progress of CABasicAnimation using layer presentation().
And using CADisplayLink you can check updated value which presentation layer has for each frame, something like this:
...
gradient.add(animation, forKey: nil)
let link = CADisplayLink(target: self, selector: #selector(linkHandler))
link.add(to: .main, forMode: .default)
link.isPaused = false
}
#objc func linkHandler() {
NSLog("\(gradient.presentation()?.locations)")
}
From this test you'll see that value gest updated totally fine, but CAGradientLayer rounds them to around 0.004. Imagine that inside there's code like
locations = locations.map { round($0 * 250) / 250 }
I doubt there's anything you can do about that.
What you can do is use Metal do draw gradient by hand. It's a big and kind of scary topic, but drawing gradient isn't that hard. Check out Rainbows, it's outdated you will have to update the syntax to the current one, but it should be possible
Your draw method is inserting a new gradient layer every time it is called. But this method will be called repeatedly, resulting in multiple layers being inserted. The only thing you should be doing in draw is stroking/filling paths, and in this case, you probably should not implement draw at all.
The insertion of layers does not belong in draw.

How to launch gradient animation on button tapped?

I'm creating an IOS application. I have 5 buttons in star shapes created with UIBezierPath. And I want fill them when I tapped specific star. For ex. if I tapped 3-rd star then gradient will fill 3 first stars etc.
I have already created view with 5 stars. In the draw view method I have added code for animating gradient and it works fine only when view is launched.
override func draw(_ rect: CGRect) {
let path = UIBezierPath()
UIColor.black.setStroke()
path.lineWidth = 2
let numberOfPoints = 5
var angle: CGFloat = CGFloat.pi / 2
let angleOfIncrement = CGFloat.pi*2/CGFloat(numberOfPoints)
let radius: CGFloat = rect.origin.x + rect.width/2
let offset = CGPoint(x: rect.origin.x + rect.width/2, y: rect.origin.y + rect.height/2)
let midleRadius = radius*0.45
var firstStar = true
for _ in 1...numberOfPoints {
let firstPoint = getPointLocation(angle: angle, radius: midleRadius, offset: offset)
let midlePoint = getPointLocation(angle: angle+angleOfIncrement/2, radius: radius, offset: offset)
let secondPoint = getPointLocation(angle: angle+angleOfIncrement, radius: midleRadius, offset: offset)
if firstStar {
firstStar = false
path.move(to: firstPoint)
path.addLine(to: midlePoint)
path.addLine(to: secondPoint)
}else {
path.addLine(to: firstPoint)
path.addLine(to: midlePoint)
path.addLine(to: secondPoint)
}
angle += angleOfIncrement
}
path.stroke()
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
let gradient = CAGradientLayer(layer: layer)
gradient.colors = [UIColor.black.cgColor, UIColor.white.cgColor]
gradient.locations = [1]
gradient.startPoint = CGPoint(x: 0, y: 1)
gradient.endPoint = CGPoint(x: 1, y: 1)
gradient.frame = path.bounds
self.layer.addSublayer(gradient)
let animation = CABasicAnimation(keyPath: "locations")
animation.fromValue = [0, 0]
animation.toValue = [0, 1.0]
animation.duration = 3
gradient.add(animation, forKey: nil)
self.layer.mask = shapeLayer
}
I expect that when I tapped 3-rd star then gradient will fill first 3 stars.
You have to separate your drawing code from your animation code, and (btw.) you should also (re-)move the layer creation out of draw().
For the separation move
let animation = CABasicAnimation(keyPath: "locations")
animation.fromValue = [0, 0]
animation.toValue = [0, 1.0]
animation.duration = 3
gradient.add(animation, forKey: nil)
into a separate method and call it when needed.
You must also remove the layer creation from draw()because this method is always called if the view needs to be (re-)drawn. This produce a serious memory leak, because for instance every rotation of your iPhone will create a new gradient layer.
You have another issue in your code. Your gradient layer lies over the layer of the view. That cannot work. You may make the gradient layer to the view's layer, and work with mask, to cut the stars out of the gradient.

Swift: Animate position of a path on a CAShapeLayer

I have a path and a shape Layer on that I want to draw a line
let path = UIBezierPath()
path.move(to: CGPoint(x: xValue,y: yValue))
path.addLine(to: CGPoint(x: xValue, y: yValue + 300))
// create shape layer for that path
let shapeLayer = CAShapeLayer()
shapeLayer.strokeColor = #colorLiteral(red: 1, green: 0, blue: 0, alpha: 1).cgColor
shapeLayer.lineWidth = 3
shapeLayer.path = path.cgPath
But I am also animating the drawing of the line
self.chartView.layer.addSublayer(shapeLayer)
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0
animation.duration = 0.6
shapeLayer.add(animation, forKey: "MyAnimation")
Now to my question:
I also want to animate the position of my path at the meanwhile. So at the moment my line is on the x-Positon of "xValue". I want to move it animated to "xValue - 40.0". I tried it on 2 ways which both didnt work.
use UIView.animate. Problem: The x Position is changing but without an animation:
UIView.animate(withDuration: 3.0) {
var highlightFrame = shapeLayer.frame
highlightFrame.origin.x = (highlightFrame.origin.x) - 40
shapeLayer.frame = highlightFrame
}
add a animation on the shape layer. Nothing happens here
let toPoint:CGPoint = CGPoint(x: -40.0, y: 0.0)
let fromPoint:CGPoint = CGPoint.zero
let movement = CABasicAnimation(keyPath: "movement")
movement.isAdditive = true
movement.fromValue = NSValue(cgPoint: fromPoint)
movement.toValue = NSValue(cgPoint: toPoint)
movement.duration = 0.3
shapeLayer.add(movement, forKey: "move")
Anyone has another solution? Thanks for your help.
let toPoint:CGPoint = CGPoint(x: -40.0, y: 0.0)
let fromPoint:CGPoint = CGPoint.zero
let movement = CABasicAnimation(keyPath: "position") // it should be "position" not "movement"
movement.isAdditive = true
movement.fromValue = NSValue(cgPoint: fromPoint)
movement.toValue = NSValue(cgPoint: toPoint)
movement.duration = 0.3
shapeLayer.add(movement, forKey: "move")

Circle scale animation works a bit weird

I am animating my GMSMarker so that it pulse once in couple seconds.
func addWave()
{
// circleView.layer.cornerRadius = size / 2
//Scale
let scaleAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleAnimation.fromValue = 1
scaleAnimation.toValue = zoom
//Opacity
let alphaAnimation = CABasicAnimation(keyPath: "opacity")
alphaAnimation.toValue = 0.0
//Corner radius
// let cornerRadiusAnimation = CABasicAnimation(keyPath: "cornerRadius")
// cornerRadiusAnimation.fromValue = size / 2
// cornerRadiusAnimation.toValue = (size * zoom)/2
//Animation Group
let animations: [CAAnimation] = [scaleAnimation, alphaAnimation]
let animationGroup = CAAnimationGroup()
animationGroup.duration = duration
animationGroup.animations = animations
animationGroup.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
animationGroup.repeatCount = 1
animationGroup.fillMode = kCAFillModeForwards
animationGroup.isRemovedOnCompletion = false
circleView.layer.add(animationGroup, forKey: "group")
}
The result looks like this:
And if I uncomment Corner radius section it looks like this:
So I need an advice.
Based on my observations, I think it must be a issue of wrong path of your CAShapeLayer hence the masking of the circle.
I just wrote a radar animation, I hope it might help you.
let scaleAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleAnimation.fromValue = 0
scaleAnimation.toValue = 1
let alphaAnimation = CABasicAnimation(keyPath: "opacity")
alphaAnimation.fromValue = 1
alphaAnimation.toValue = 0
let animations = CAAnimationGroup()
animations.duration = 0.8
animations.repeatCount = Float.infinity
animations.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
animations.animations = [scaleAnimation, alphaAnimation]
circleView.layer.add(animations, forKey: "animations")
What I did was, I used two CAShapeLayer (one being the orange marker and other is the rader layer at the back of marker). The animations were applied on the radar layer.
radarLayer = CAShapeLayer()
radarLayer.frame = CGRect(x: 0, y: 0, width: self.frame.size.width, height: self.frame.size.height);
radarLayer.path = UIBezierPath(rect: radarLayer.frame).cgPath
radarLayer.fillColor = UIColor.orange.cgColor
radarLayer.position = CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2)
radarLayer.cornerRadius = radarLayer.frame.size.width/2
radarLayer.masksToBounds = true
radarLayer.opacity = 0
self.layer.addSublayer(radarLayer)
circleLayer = CAShapeLayer()
circleLayer.frame = CGRect(x: 0, y: 0, width: 16, height: 16);
circleLayer.path = UIBezierPath(rect: circleLayer.frame).cgPath
circleLayer.fillColor = UIColor.orange.cgColor
circleLayer.position = CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2)
circleLayer.cornerRadius = circleLayer.frame.size.width/2
circleLayer.masksToBounds = true
self.layer.addSublayer(circleLayer)
PS. I'm new to Swift, so enlighten me if I'm wrong somewhere :)
Example : http://i.imgur.com/v2jFWgw.jpg
The following in an excerpt from Google documentation on Markers:
The view behaves as if clipsToBounds is set to YES, regardless of its actual value. You can apply transforms that work outside the bounds, but the object you draw must be within the bounds of the object. All transforms/shifts are monitored and applied. In short: subviews must be contained within the view.
The consequence of that, at least for my case, was exactly the behaviour mentioned in the question: a squared circle animation. The image I wanted to add inside the marker, at the end of the animation, was bigger than the container, so once added to the marker, it was clipped to the bounds of it.
The following is what I did:
public var pulseImageView: UIImageView = {
let imageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
imageView.image = PaletteElements.pulseLocation.value
imageView.contentMode = .center
let pulseAnimation = CABasicAnimation(keyPath: "transform.scale.xy")
pulseAnimation.repeatCount = Float.infinity
pulseAnimation.fromValue = 0
pulseAnimation.toValue = 2.0
pulseAnimation.isRemovedOnCompletion = false
let fadeOutAnimation = CABasicAnimation(keyPath: "opacity")
fadeOutAnimation.duration = 2.5
fadeOutAnimation.fromValue = 1.0
fadeOutAnimation.toValue = 0
fadeOutAnimation.repeatCount = Float.infinity
let animationGroup = CAAnimationGroup()
animationGroup.duration = 2.5
animationGroup.animations = [pulseAnimation, fadeOutAnimation]
animationGroup.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
animationGroup.repeatCount = .greatestFiniteMagnitude
animationGroup.fillMode = CAMediaTimingFillMode.forwards
animationGroup.isRemovedOnCompletion = false
imageView.layer.add(animationGroup, forKey: "pulse")
return imageView
}()
I defined a var inside my helper class so I can retrieve the image whenever I need it. My original image, the pulse image, was 116x116, so I created the imageView as 300x300 with a contentMode = .center, so the small image was in the center and not stretched. I chose 300x300 because my animation scaled up the pulse image until a 2x its initial value (116x2), so I made room for the entire animation to be performed.
Finally I added it to as a Marker to the map:
let pulseMarker = GMSMarker(position: userLocation.coordinate)
pulseMarker.iconView = pulseImageView
pulseMarker.groundAnchor = CGPoint(x: 0.5, y: 0.5)
pulseMarker.map = googleMapView
Hope it can help.

How to set two colors in UIButton background

I want to set two different colors in UIButton background. I know how to set one color or border and etc. But I don't know how to set two different colors in the background. I show the example. I use Swift 2
You can use a gradient with 2 pair of repeating colours:
let gradient: CAGradientLayer = CAGradientLayer()
gradient.frame = button.bounds
gradient.colors = [
UIColor.greenColor().CGColor,
UIColor.greenColor().CGColor,
UIColor.blackColor().CGColor,
UIColor.blackColor().CGColor
]
/* repeat the central location to have solid colors */
gradient.locations = [0, 0.5, 0.5, 1.0]
/* make it horizontal */
gradient.startPoint = CGPointMake(0, 0.5)
gradient.endPoint = CGPointMake(1, 0.5)
button.layer.insertSublayer(gradient, atIndex: 0)
You can change the orientation playing with the start/end points:
gradient.startPoint = CGPointMake(0, 0)
gradient.endPoint = CGPointMake(1, 1)
You can use CAGradientLayer to make it
let layer : CAGradientLayer = CAGradientLayer()
layer.frame.size = button.frame.size
layer.startPoint = CGPointZero
layer.endPoint = CGPointMake(1, 0)
let colorGreen = UIColor.greenColor().CGColor
let colorBlack = UIColor.blackColor().CGColor
layer.colors = [colorGreen, colorGreen, colorBlack, colorBlack]
layer.locations = [0.0, 0.5, 0.5, 1.0]
button.layer.insertSublayer(layer, atIndex: 0)