Choppy CATextLayer animation: fontSize + position concurrently - ios

I need to animate a CATextLayer's bounds.size.height, position, and fontSize. When I add them to a CAAnimationGroup, the text jitters during the animation, just like this:
https://youtu.be/HfC1ZX-pbyM
The jittering of the text's tracking values (spacing between characters) seems to occur while animating fontSize with bounds.size.height AND/OR position. I've isolated fontSize, and it performs well on its own.
How can I prevent the text from jittering in CATextLayer if I animate bounds and font size at the same time?
EDIT
I've moved on from animating bounds. Now, I only care about fontSize + position. Here are two videos showing the difference.
fontSize only (smooth): https://youtu.be/FDPPGF_FzLI
fontSize + position (jittery): https://youtu.be/3rFTsp7wBzk
Here is the code for that.
let startFontSize: CGFloat = 16
let endFontSize: CGFloat = 30
let startPosition: CGPoint = CGPoint(x: 40, y: 100)
let endPosition: CGPoint = CGPoint(x: 20, y: 175)
// Initialize the layer
textLayer = CATextLayer()
textLayer.string = "Hello how are you?"
textLayer.font = UIFont.systemFont(ofSize: startFontSize, weight: UIFont.Weight.semibold)
textLayer.fontSize = startFontSize
textLayer.alignmentMode = kCAAlignmentLeft
textLayer.foregroundColor = UIColor.black.cgColor
textLayer.contentsScale = UIScreen.main.scale
textLayer.isWrapped = true
textLayer.backgroundColor = UIColor.lightGray.cgColor
textLayer.anchorPoint = CGPoint(x: 0, y: 0)
textLayer.position = startPosition
textLayer.bounds.size = CGSize(width: 450, height: 50)
view.layer.addSublayer(textLayer)
// Animate
let damping: CGFloat = 20
let mass: CGFloat = 1.2
var animations = [CASpringAnimation]()
let fontSizeAnim = CASpringAnimation(keyPath: "fontSize")
fontSizeAnim.fromValue = startFontSize
fontSizeAnim.toValue = endFontSize
fontSizeAnim.damping = damping
fontSizeAnim.mass = mass
fontSizeAnim.duration = fontSizeAnim.settlingDuration
animations.append(fontSizeAnim)
let positionAnim = CASpringAnimation(keyPath: "position.y")
positionAnim.fromValue = textLayer.position.y
positionAnim.toValue = endPosition.y
positionAnim.damping = damping
positionAnim.mass = mass
positionAnim.duration = positionAnim.settlingDuration
animations.append(positionAnim)
let animGroup = CAAnimationGroup()
animGroup.animations = animations
animGroup.duration = fontSizeAnim.settlingDuration
animGroup.isRemovedOnCompletion = true
animGroup.autoreverses = true
textLayer.add(animGroup, forKey: nil)
My device is running iOS 11.0.
EDIT 2
I've broken down each animation (fontSize only, and fontSize + position) frame-by-frame. In each video, I'm progressing 1 frame at a time.
In the fontSize only video (https://youtu.be/DZw2pMjDcl8), each frame yields an increase in fontSize, so there's no choppiness.
In the fontSize + position video (https://youtu.be/_idWte92F38), position is updated in every frame, but not fontSize. There is only an increase in fontSize in 60% of frames, meaning that fontSize isn't animating in sync with position, causing the perceived chopping.
So maybe the right question is: why does fontSize animate in each frame when it's the only animation added to a layer, but not when added as part of CAAnimationGroup in conjunction with the position animation?

Apple DTS believes this issue is a bug. A report has been filed.
In the meantime, I'll be using CADisplayLink to synchronize the redrawing of CATextLayer.fontSize to the refresh rate of the device, which will redraw the layer with the appropriate fontSize in each frame.
Edit
After tinkering with CADisplayLink for a day or so, drawing to the correct fontSize proved difficult, especially when paired with a custom timing function. So, I'm giving up on CATextLayer altogether and going back to UILabel.
In WWDC '17's Advanced Animations with UIKit, Apple recommends "view morphing" to animate between two label states — that is, the translation, scaling, and opacity blending of two views. UIViewPropertyAnimator provides a lot of flexibility for this, like blending multiple timing functions and scrubbing. View morphing is also useful for transitioning between 2 text values without having to fade out the text representation, changing text, and fading back in.
I do hope Apple can beef up CATextLayer support for non-interactive animations, as I'd prefer using one view to animate the same text representation.

Related

Drawing checkmark with stroke animation in swift

I have a button in my iOS app and I want to apply a checkmark animation when the user taps it, but I don't have any idea how would I achieve this. If anybody has an idea please help me out.
Below is a sample gif image of the animation I want to create.
To create that animation you can either animate a mask revealing the check mark from left to right, as Matt says, or you can use a CAShapeLayer to draw the check-mark, and then animate the strokeEnd property of the shape layer.
(The sliding mask approach would always reveal the view's contents from left to right¹, whether the view contained a checkmark or a picture of a kitten. The shape layer stroke approach is specific to drawing shapes using UIBezierPath (or the Core Foundation equivalent, CGPath), but you can use it to draw complex shapes from beginning to end, even shapes that loop back on themselves. It's not limited to always revealing the view content from left to right.)
As it happens, the image you used in your question is the product of a demo app I created last weekend that uses the CAShapeLayer approach.
You can find the complete demo app here:
https://github.com/DuncanMC/AnimateCheckMark.git
In order to do animate the stroke, you'll need to create your check mark as a shape layer. Sample code to create a check mark looks like this:
private func checkmarkPath() -> CGPath {
let path = UIBezierPath()
path.move(to: CGPoint(x: 5, y: bounds.midY))
path.addLine(to: CGPoint(x: 25, y: bounds.midY + 20))
path.addLine(to: CGPoint(x: 45, y: bounds.midY - 20))
return path.cgPath
}
(That code isn't very general-purpose. It creates a fixed sized check mark. You might need to modify it to scale the check-mark to fit the view it belongs to.)
Once you have created a shape layer and installed your checkmark in it you need to create an animation that animates the shape layer's strokeEnd property. That code might look like this:
This function animates showing or hiding the checkmark view's layer by animating the layer's strokeEnd property.
- Parameter show: If true, show the checkmark. If false, hide it.
- Parameter animationDuration: the duration of the animation, in seconds.
- Parameter animationCurve: Indicates the animation timing curve to use, .linear or .easeInEaseOut
*/
public func animateCheckmarkStrokeEnd(_ show: Bool,
animationDuration: Double = 0.3,
animationCurve: AnimationCurve = .linear) {
guard let layer = layer as? CAShapeLayer else { return }
let newStrokeEnd: CGFloat = show ? 1.0 : 0.0
let oldStrokeEnd: CGFloat = show ? 0.0 : 1.0
let keyPath = "strokeEnd"
let animation = CABasicAnimation(keyPath: keyPath)
animation.fromValue = oldStrokeEnd
animation.toValue = newStrokeEnd
animation.duration = animationDuration
let timingFunction: CAMediaTimingFunction
if animationCurve == .linear {
timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
} else {
timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
}
animation.timingFunction = timingFunction
layer.add(animation, forKey: nil)
DispatchQueue.main.async {
layer.strokeEnd = newStrokeEnd
}
self.checked = show
}
The entire demo app lets you create the checkmark animation as either a stroke animation or a cross-fade, and allows you to use either linear timing or ease-in/ease-out timing. The window for the demo app looks like this:
¹: You could create other mask animations that reveal your view's contents from right to left, top to bottom, from the center out, etc. For the checkmark animation in your question, though, the mask animation you would need would be a left-to-right "wipe" animation.

UIKit: How to mix several CALayers into one view layer?

I would like to mix several sublayers into only one layer.
If I simply assign my modifications to the view layer, it works fine:
testView.layer.cornerRadius = 15.0
testView.layer.shadowColor = UIColor.yellow.withAlphaComponent(1.0).cgColor
testView.layer.shadowOpacity = 1.0
testView.layer.shadowRadius = 24.0
testView.layer.shadowOffset = .zero
testView.backgroundColor = .white
Then, I tried this:
testView.layer.cornerRadius = 15.0
// sl1
let layer1 = CALayer()
layer1.shadowColor = UIColor.yellow.withAlphaComponent(1.0).cgColor
layer1.shadowOpacity = 1.0
layer1.shadowRadius = 24.0
layer1.shadowOffset = .zero
// sl2
let layer2 = CALayer()
layer2.shadowColor = UIColor.red.withAlphaComponent(1.0).cgColor
layer2.shadowOpacity = 1.0
layer2.shadowRadius = 24.0
layer2.shadowOffset = .zero
// sublayers
let layer = CALayer()
testView.layer.sublayers = [layer1, layer2]
testView.backgroundColor = .white
But now here is the result I get
There is now no shadow surrounding the white view. Why is this happening?
Thank you for your help
The chief problem with your code is that none of your layers have any size. Thus for example you see no shadow, because the layer itself is of zero size in the top left corner so there is nothing there to cast any shadow.
It is your job to give a layer a frame immediately after creating it! Typically this will be the same as the bounds of the superlayer. Keep in mind, however, that unlike views, when a view or superlayer is resized, sublayers are not. Thus the sublayer can cease to "fit" its superlayer properly if you don't take measures to correct that.

Swift SKEmitterNode width

Good day, I am trying to use a SKEmitterNode in swift, but I can't seem to be able to change its' width, so the particles only cover half of the screen.
My code:
if let particles = SKEmitterNode(fileNamed: "Snow.sks") {
particles.position = CGPointMake(frame.size.width/2, frame.size.height)
particles.targetNode = self.scene
particles.zPosition = 999
addChild(particles)
}
How can I make the particles to cover the whole screen width?
After looking at the so called "emitter editor", as suggested by #Knight0fDragon, I was able to find the right parameter - particlePositionRange
if let particles = SKEmitterNode(fileNamed: "Snow.sks") {
particles.position = CGPointMake(frame.size.width/2, frame.size.height)
particles.targetNode = self.scene
// frame.size.width to cover the length of the screen.
particles.particlePositionRange = CGVector(dx: frame.size.width, dy: frame.size.height)
particles.zPosition = 999
addChild(particles)
}
Through the position and particlePositionRange you can get your goal
By the documentation:
particlePositionRange
Declaration
var particlePositionRange: CGVector { get set }
Discussion
The default value is (0.0,0.0). If a component is non-zero, the same component of a particle’s position is randomly determined and may vary by plus or minus half of the range value

How do I animate a horizontal card pivoting (yaw) from its base to vertical?

How do I programmatically via Quartz, animate a rectangle from lying face up (appear as line in 2D) to full height?
The following (pardon the crude drawing) is what I'm trying to get: a deck of cards (lines) with a card pivoting to full height. I don't have any means of adjusting for perspective.
Possible modus operandi: 1) start off with a UIImageView having zero height. 2) Upper (xl,yl)(xr,yr) coordinates widening apart (adjusting perspective) as the height increases.
Any reference, API suggestions welcomed.
This will be relatively close to your desired animation with examples for both UIView animations and CABasicAnimation.
To begin, let's set up the from/to 3D transformations:
let perspective: CGFloat = 1.0 / 1000.0
var fromTransform = CATransform3DMakeRotation(-CGFloat(M_PI_2), 1, 0, 0)
fromTransform.m34 = perspective
var toTransform = CATransform3DMakeRotation(0, 1, 0, 0)
toTransform.m34 = perspective
To animate with UIView animations:
view.layer.transform = fromTransform
UIView.animateWithDuration(1.0, animations: {
view.layer.transform = toTransform
})
If you want to use CABasicAnimation:
let flipAnimation = CABasicAnimation(keyPath: "transform")
flipAnimation.fromValue = NSValue(CATransform3D: fromTransform)
flipAnimation.toValue = NSValue(CATransform3D: toTransform)
flipAnimation.duration = 1.0
flipAnimation.fillMode = kCAFillModeForwards
view.layer.addAnimation(flipAnimation, forKey: "flip")
Edit:
OP desires the anchor point of the animation to be bottom-center, this can be achieved by:
view.layer.anchorPoint = CGPointMake(0.5, 1.0)

Using a UILabel Sublayer to Cut Off Corners Overlaying an Image

I've encountered a problem with code I'd written to cut off the corners of a UILabel (or, indeed, any UIView-derived object to which you can add sublayers) -- I do have to thank Kurt Revis for his answer to Use a CALayer to add a diagonal banner/badge to the corner of a UITableViewCell that pointed me in this direction.
I don't have a problem if the corner overlays a solid color -- it's simple enough to make the cut-off corner match that color. But if the corner overlays an image, how would you let the image show through?
I've searched SO for anything similar to this problem, but most of those answers have to do with cells in tables and all I'm doing here is putting a label on a screen's view.
Here's the code I use:
-(void)returnChoppedCorners:(UIView *)viewObject
{
NSLog(#"Object Width = %f", viewObject.layer.frame.size.width);
NSLog(#"Object Height = %f", viewObject.layer.frame.size.height);
CALayer* bannerLeftTop = [CALayer layer];
bannerLeftTop.backgroundColor = [UIColor blackColor].CGColor;
// or whatever color the background is
bannerLeftTop.bounds = CGRectMake(0, 0, 25, 25);
bannerLeftTop.anchorPoint = CGPointMake(0.5, 1.0);
bannerLeftTop.position = CGPointMake(10, 10);
bannerLeftTop.affineTransform = CGAffineTransformMakeRotation(-45.0 / 180.0 * M_PI);
[viewObject.layer addSublayer:bannerLeftTop];
CALayer* bannerRightTop = [CALayer layer];
bannerRightTop.backgroundColor = [UIColor blackColor].CGColor;
bannerRightTop.bounds = CGRectMake(0, 0, 25, 25);
bannerRightTop.anchorPoint = CGPointMake(0.5, 1.0);
bannerRightTop.position = CGPointMake(viewObject.layer.frame.size.width - 10.0, 10.0);
bannerRightTop.affineTransform = CGAffineTransformMakeRotation(45.0 / 180.0 * M_PI);
[viewObject.layer addSublayer:bannerRightTop];
}
I'll be adding similar code to do the BottomLeft and BottomRight corners, but, right now, those are corners that overlay an image. The bannerLeftTop and bannerRightTop are actually squares that are rotated over the corner against a black background. Making them clear only lets the underlying UILabel background color appear, not the image. Same for using the z property. Is masking the answer? Oo should I be working with the underlying image instead?
I'm also encountering a problem with the Height and Width being passed to this method -- they don't match the constrained Height and Width of the object. But we'll save that for another question.
What you need to do, instead of drawing an opaque corner triangle over the label, is mask the label so its corners aren't drawn onto the screen.
Since iOS 8.0, UIView has a maskView property, so we don't actually need to drop to the Core Animation level to do this. We can draw an image to use as a mask, with the appropriate corners clipped. Then we'll create an image view to hold the mask image, and set it as the maskView of the label (or whatever).
The only problem is that (in my testing) UIKit won't resize the mask view automatically, either with constraints or autoresizing. We have to update the mask view's frame “manually” if the masked view is resized.
I realize your question is tagged objective-c, but I developed my answer in a Swift playground for convenience. It shouldn't be hard to translate this to Objective-C. I didn't do anything particularly “Swifty”.
So... here's a function that takes an array of corners (specified as UIViewContentMode cases, because that enum includes cases for the corners), a view, and a “depth”, which is how many points each corner triangle should measure along its square sides:
func maskCorners(corners: [UIViewContentMode], ofView view: UIView, toDepth depth: CGFloat) {
In Objective-C, for the corners argument, you could use a bitmask (e.g. (1 << UIViewContentModeTopLeft) | (1 << UIViewContentModeBottomRight)), or you could use an NSArray of NSNumbers (e.g. #[ #(UIViewContentModeTopLeft), #(UIViewContentModeBottomRight) ]).
Anyway, I'm going to create a square, 9-slice resizable image. The image will need one point in the middle for stretching, and since each corner might need to be clipped, the corners need to be depth by depth points. Thus the image will have sides of length 1 + 2 * depth points:
let s = 1 + 2 * depth
Now I'm going to create a path that outlines the mask, with the corners clipped.
let path = UIBezierPath()
So, if the top left corner is clipped, I need the path to avoid the top left point of the square (which is at 0, 0). Otherwise, the path includes the top left point of the square.
if corners.contains(.TopLeft) {
path.moveToPoint(CGPoint(x: 0, y: 0 + depth))
path.addLineToPoint(CGPoint(x: 0 + depth, y: 0))
} else {
path.moveToPoint(CGPoint(x: 0, y: 0))
}
Do the same for each corner in turn, going around the square:
if corners.contains(.TopRight) {
path.addLineToPoint(CGPoint(x: s - depth, y: 0))
path.addLineToPoint(CGPoint(x: s, y: 0 + depth))
} else {
path.addLineToPoint(CGPoint(x: s, y: 0))
}
if corners.contains(.BottomRight) {
path.addLineToPoint(CGPoint(x: s, y: s - depth))
path.addLineToPoint(CGPoint(x: s - depth, y: s))
} else {
path.addLineToPoint(CGPoint(x: s, y: s))
}
if corners.contains(.BottomLeft) {
path.addLineToPoint(CGPoint(x: 0 + depth, y: s))
path.addLineToPoint(CGPoint(x: 0, y: s - depth))
} else {
path.addLineToPoint(CGPoint(x: 0, y: s))
}
Finally, close the path so I can fill it:
path.closePath()
Now I need to create the mask image. I'll do this using an alpha-only bitmap:
let colorSpace = CGColorSpaceCreateDeviceGray()
let scale = UIScreen.mainScreen().scale
let gc = CGBitmapContextCreate(nil, Int(s * scale), Int(s * scale), 8, 0, colorSpace, CGImageAlphaInfo.Only.rawValue)!
I need to adjust the coordinate system of the context to match UIKit:
CGContextScaleCTM(gc, scale, -scale)
CGContextTranslateCTM(gc, 0, -s)
Now I can fill the path in the context. The use of white here is arbitrary; any color with an alpha of 1.0 would work:
CGContextSetFillColorWithColor(gc, UIColor.whiteColor().CGColor)
CGContextAddPath(gc, path.CGPath)
CGContextFillPath(gc)
Next I create a UIImage from the bitmap:
let image = UIImage(CGImage: CGBitmapContextCreateImage(gc)!, scale: scale, orientation: .Up)
If this were in Objective-C, you'd want to release the bitmap context at this point, with CGContextRelease(gc), but Swift takes care of it for me.
Anyway, I convert the non-resizable image to a 9-slice resizable image:
let maskImage = image.resizableImageWithCapInsets(UIEdgeInsets(top: depth, left: depth, bottom: depth, right: depth))
Finally, I set up the mask view. I might already have a mask view, because you might have clipped the view with different settings already, so I'll reuse an existing mask view if it is an image view:
let maskView = view.maskView as? UIImageView ?? UIImageView()
maskView.image = maskImage
Finally, if I had to create the mask view, I need to set it as view.maskView and set its frame:
if view.maskView != maskView {
view.maskView = maskView
maskView.frame = view.bounds
}
}
OK, how do I use this function? To demonstrate, I'll make a purple background view, and put an image on top of it:
let view = UIImageView(image: UIImage(named: "Kaz-256.jpg"))
view.autoresizingMask = [ .FlexibleWidth, .FlexibleHeight ]
let backgroundView = UIView(frame: view.frame)
backgroundView.backgroundColor = UIColor.purpleColor()
backgroundView.addSubview(view)
XCPlaygroundPage.currentPage.liveView = backgroundView
Then I'll mask some corners of the image view. Presumably you would do this in, say, viewDidLoad:
maskCorners([.TopLeft, .BottomRight], ofView: view, toDepth: 50)
Here's the result:
You can see the purple background showing through the clipped corners.
If I were to resize the view, I'd need to update the mask view's frame. For example, I might do this in my view controller:
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
self.cornerClippedView.maskView?.frame = self.cornerClippedView.bounds
}
Here's a gist of all the code, so you can copy and paste it into a playground to try out. You'll have to supply your own adorable test image.
UPDATE
Here's code to create a label with a white background, and overlay it (inset by 20 points on each side) on the background image:
let backgroundView = UIImageView(image: UIImage(named: "Kaz-256.jpg"))
let label = UILabel(frame: backgroundView.bounds.insetBy(dx: 20, dy: 20))
label.backgroundColor = UIColor.whiteColor()
label.font = UIFont.systemFontOfSize(50)
label.text = "This is the label"
label.lineBreakMode = .ByWordWrapping
label.numberOfLines = 0
label.textAlignment = .Center
label.autoresizingMask = [ .FlexibleWidth, .FlexibleHeight ]
backgroundView.addSubview(label)
XCPlaygroundPage.currentPage.liveView = backgroundView
maskCorners([.TopLeft, .BottomRight], ofView: label, toDepth: 50)
Result:

Resources