CAShapeLayer disappears while moving - swift

CAShapeLayer disappears while moving to another monitor, but not always
Tried searching for code examples
override func draw(_ dirtyRect: NSRect)
{
//super.draw(dirtyRect)
image?.lockFocus()
print("SelectionRect::draw")
if (shapeLayerIsVisible) {
auxLayer.removeFromSuperlayer()
shapeLayer.removeFromSuperlayer()
}
//let origin = frame.origin
auxLayer = CAShapeLayer()
auxLayer.strokeColor = NSColor.white.cgColor
auxLayer.fillColor = nil
auxLayer.lineWidth = 1.0
//rectSize = CGSize(width: x2 - x1, height: y2 - y1)
let selectionRect = frame //CGRect(x: origin.x, y: origin.y, width: frame.size.width, height: frame.size.height)
let auxPath = CGMutablePath()
//print("x1: \(x1), y1: \(y1), x2: \(x2), y2: \(y2), width: \(rectSize.width), height: \(rectSize.height)")
auxPath.addRect(selectionRect)
auxLayer.path = auxPath
layer?.addSublayer(auxLayer)
shapeLayer = CAShapeLayer()
shapeLayer.strokeColor = NSColor.black.cgColor
shapeLayer.fillColor = nil
shapeLayer.lineWidth = 1.0
shapeLayer.lineDashPattern = [5, 5]
let path = CGMutablePath()
path.addRect(selectionRect)
shapeLayer.path = path
let lineDashAnimation = CABasicAnimation(keyPath: "lineDashPhase")
lineDashAnimation.fromValue = 0
lineDashAnimation.toValue = shapeLayer.lineDashPattern?.reduce(0) { $0 + $1.intValue }
lineDashAnimation.duration = 1
lineDashAnimation.repeatCount = Float.greatestFiniteMagnitude
shapeLayer.add(lineDashAnimation, forKey: nil)
shapeLayerIsVisible = true
layer?.addSublayer(shapeLayer)
I've got an 5K iMac with two external 4K monitors (all three scaled to look like 2560x1440 if that matters). With the given code, the NSView is initialized with x: 0, y:0, width: 100, height: 100. All works well, I can drag the Windows around on all three monitors. When I moved the Layer with the mouse to a different position and than drag the window to a different monitor the Shape disappears.
Edit: I fixed it by calling draw(9 at the end of each mouseDragged()-Event and chaninging selectionRect from frame to bounds. It works, but is that a clean solution?

Don't call draw() yourself, per Apple's documentation:
You should never call draw() directly yourself. To invalidate part of your view, and thus cause that portion to be redrawn, call the setNeedsDisplay() or setNeedsDisplay(_:) method instead.

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.

Can CAShapeLayer shapes center itself in a subView?

I created an arbitrary view
let middleView = UIView(
frame: CGRect(x: 0.0,
y: view.frame.height/4,
width: view.frame.width,
height: view.frame.height/4))
middleView.backgroundColor = UIColor.blue
view.addSubview(middleView)
Then I created a circle using UIBezierPath; however when I set the position to middleView.center, the circle is far off to the bottom of the view. Can you set the position in the center of a subview?
let shapeLayer = CAShapeLayer()
let circlePath = UIBezierPath(
arcCenter: .zero,
radius: 100,
startAngle: CGFloat(0).toRadians(),
endAngle: CGFloat(360).toRadians(),
clockwise: true)
shapeLayer.path = circlePath.cgPath
shapeLayer.strokeColor = UIColor.purple.cgColor
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.position = middleView.center
middleView.layer.addSublayer(shapeLayer)
How do I center this circle in that view?
You have two problems.
First, you are setting shapeLayer.position = middleView.center. The center of a view is is the superview's geometry. In other words, middleView.center is relative to view, not to middleView. But then you're adding shapeLayer as a sublayer of middleView.layer, which means shapeLayer needs a position that is in middleView's geometry, not in view's geometry. You need to set shapeLayer.position to the center of middleView.bounds:
shapeLayer.position = CGPoint(x: middleView.bounds.midX, y: middleView.bounds.midY)
Second, you didn't say where you're doing all this. My guess is you're doing it in viewDidLoad. But that is too early. In viewDidLoad, the views loaded from the storyboard still have the frames they were given in the storyboard, and haven't been laid out for the current device's screen size yet. So it's a bad idea to look at frame (or bounds or center) in viewDidLoad if you don't do something to make sure that things will be laid out correctly during the layout phase. Usually you do this by setting the autoresizingMask or creating constraints. Example:
let middleView = UIView(
frame: CGRect(x: 0.0,
y: view.frame.height/4,
width: view.frame.width,
height: view.frame.height/4))
middleView.backgroundColor = UIColor.blue
middleView.autoresizingMask = [.flexibleWidth, .flexibleHeight, .flexibleTopMargin, .flexibleBottomMargin]
view.addSubview(middleView)
However, shapeLayer doesn't belong to a view, so it doesn't have an autoresizingMask and can't be constrained. You have to lay it out in code. You could do that, but it's better to just use a view to manage the shape layer. That way, you can use autoresizingMask or constraints to control the layout of the shape, and you can set it up in viewDidLoad.
let circleView = CircleView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
circleView.center = CGPoint(x: middleView.bounds.midX, y: middleView.bounds.midY)
circleView.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin, .flexibleTopMargin, .flexibleBottomMargin]
circleView.shapeLayer.strokeColor = UIColor.purple.cgColor
circleView.shapeLayer.fillColor = nil
middleView.addSubview(circleView)
...
class CircleView: UIView {
override class var layerClass: AnyClass { return CAShapeLayer.self }
var shapeLayer: CAShapeLayer { return layer as! CAShapeLayer }
override func layoutSubviews() {
super.layoutSubviews()
shapeLayer.path = UIBezierPath(ovalIn: bounds).cgPath
}
}
Result:
And after rotating to landscape:

How do I flip over a UIBezierPath or cgPath thats animated onto the CAShapeLayer?

I have an array of type [UIBezierPath] that I transform into cgPaths and then animate onto a CAShapeLayer I called shapeLayer. Now for some reason all my paths are upside down, so all the paths get drawn upside down. How can I fix this, I tried a couple of methods but sadly none of them worked... I did however figure out how to scale the path. This is my code to draw the swiftPath, which is a path made up of UIBezierPaths found in the Forms class under the function swiftBirdForm(). Drawing the path is working fine, I just can't figure out how to flip it 180 degrees.
#objc func drawForm() {
var swiftPath = Forms.swiftBirdForm()
let shapeLayer = CAShapeLayer()
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = UIColor.black.cgColor
shapeLayer.lineWidth = 1
shapeLayer.frame = CGRect(x: -120, y: 120, width: 350, height: 350)
var paths: [UIBezierPath] = swiftPath
guard let path = paths.first else {
return
}
paths.dropFirst()
.forEach {
path.append($0)
}
shapeLayer.transform = CATransform3DMakeScale(0.6, 0.6, 0)
shapeLayer.path = path.cgPath
self.view.layer.addSublayer(shapeLayer)
let strokeEndAnimation = CABasicAnimation(keyPath: "strokeEnd")
strokeEndAnimation.duration = 1.0
strokeEndAnimation.fromValue = 0.0
strokeEndAnimation.toValue = 1.0
shapeLayer.add(strokeEndAnimation, forKey: nil)
}
Using CATransform3D
shapeLayer.transform = CATransform3DMakeScale(1, -1, 1)
Transforming path,
let shapeBounds = shapeLayer.bounds
let mirror = CGAffineTransform(scaleX: 1,
y: -1)
let translate = CGAffineTransform(translationX: 0,
y: shapeBounds.size.height)
let concatenated = mirror.concatenating(translate)
bezierPath.apply(concatenated)
shapeLayer.path = bezierPath.cgPath
Transforming layer,
let shapeFrame = CGRect(x: -120, y: 120, width: 350, height: 350)
let mirrorUpsideDown = CGAffineTransform(scaleX: 1,
y: -1)
shapeLayer.setAffineTransform(mirrorUpsideDown)
shapeLayer.frame = shapeFrame

Drawing a gradient color in an arc with a rounded edge

I want to replicate this in Swift.. reading further: How to draw a linear gradient arc with Qt QPainter?
I've figured out how to draw a gradient inside of a rectangular view:
override func drawRect(rect: CGRect) {
let currentContext = UIGraphicsGetCurrentContext()
CGContextSaveGState(currentContext)
let colorSpace = CGColorSpaceCreateDeviceRGB()
let startColor = MyDisplay.displayColor(myMetric.currentValue)
let startColorComponents = CGColorGetComponents(startColor.CGColor)
let endColor = MyDisplay.gradientEndDisplayColor(myMetric.currentValue)
let endColorComponents = CGColorGetComponents(endColor.CGColor)
var colorComponents
= [startColorComponents[0], startColorComponents[1], startColorComponents[2], startColorComponents[3], endColorComponents[0], endColorComponents[1], endColorComponents[2], endColorComponents[3]]
var locations: [CGFloat] = [0.0, 1.0]
let gradient = CGGradientCreateWithColorComponents(colorSpace, &colorComponents, &locations, 2)
let startPoint = CGPointMake(0, 0)
let endPoint = CGPointMake(1, 8)
CGContextDrawLinearGradient(currentContext, gradient, startPoint, endPoint, CGGradientDrawingOptions.DrawsAfterEndLocation)
CGContextRestoreGState(currentContext)
}
and how to draw an arc
func drawArc(color: UIColor, x: CGFloat, y: CGFloat, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat, lineWidth: CGFloat, clockwise: Int32) {
let context = UIGraphicsGetCurrentContext()
CGContextSetLineWidth(context, lineWidth)
CGContextSetStrokeColorWithColor(context,
color.CGColor)
CGContextAddArc(context, x, y, radius, startAngle, endAngle, clockwise)
CGContextStrokePath(context)
}
With the gradient, that took up the entire view. I was unsure how to limit the gradient to a particular area so I made a subclass of UIView, modified the frame and used the gradient code (first snippet).
For arcs, I've used the second snippet. I'm thinking I can draw an arc and then draw a circle at the end of the arc to smooth out the arc with a rounded finish. How can I do this and draw a gradient over the arc shape?
Please ignore the purple mark. The first picture is the beginning of the gradient arc from my specification, the second is the end of the gradient arc, below and to the left of the beginning arc.
How can I draw this in Swift with Core Graphics?
Exact code for 2019 with true circular gradient and totally flexible end positions:
These days basically you simply have a gradient layer and mask for that layer.
So step one, you need a "mask".
What the heck is a "mask" in iOS?
In iOS, you actually use a CAShapeLayer to make a mask.
This is confusing. It should be called something like 'CAShapeToUseAsAMaskLayer'
But that's how you do it, a CAShapeLayer.
Step two. In layout, you must resize the shape layer. And that's it.
So basically it's just this:
class SweetArc: UIView {
open override func layoutSubviews() {
super.layoutSubviews()
arcLayer.frame = bounds
gradientLayer.frame = bounds
}
private lazy var gradientLayer: CAGradientLayer = {
let l = CAGradientLayer()
...
l.mask = arcLayer
layer.addSublayer(l)
return l
}()
private lazy var arcLayer: CAShapeLayer = {
let l = CAShapeLayer()
...
return l
}()
}
That's the whole thing.
Note that the mask of the gradient layer is indeed set as the shape layer, which we have named "arcLayer".
A shape layer is used as a mask.
Here's the exact code for the two layers:
private lazy var gradientLayer: CAGradientLayer = {
// recall that .conic is finally available in 12 !
let l = CAGradientLayer()
l.type = .conic
l.colors = [
UIColor.yellow,
UIColor.red
].map{$0.cgColor}
l.locations = [0, 1]
l.startPoint = CGPoint(x: 0.5, y: 0.5)
l.endPoint = CGPoint(x: 0.5, y: 0)
l.mask = arcLayer
layer.addSublayer(l)
return l
}()
private lazy var arcLayer: CAShapeLayer = {
let l = CAShapeLayer()
l.path = arcPath
// to use a shape layer as a mask, you mask with white-on-clear:
l.fillColor = UIColor.clear.cgColor
l.strokeColor = UIColor.white.cgColor
l.lineWidth = lineThick
l.lineCap = CAShapeLayerLineCap.round
l.strokeStart = 0.4 // try values 0-1
l.strokeEnd = 0.5 // try values 0-1
return l
}()
Again, notice arcLayer is a shape layer you are using as a mask - you do NOT actually add it as a subview.
Remember that shape layers used as masks do not! get added as subviews.
That can be a source of confusion. It's not a "layer" at all.
Finally, notice arcLayer uses a path arcPath.
Construct the path perfectly so that strokeStart and strokeEnd work correctly.
You want to build the path perfectly.
So that it is very easy to set the stroke start and stroke end values.
And they make sense by perfectly and correctly running clockwise, from the top, from 0 to 1.
Here's the exact code for that:
private let lineThick: CGFloat = 10.0
private let outerGap: CGFloat = 10.0
private let beginFraction: CGFloat = 0 // 0 means from top; runs clockwise
private lazy var arcPath: CGPath = {
let b = beginFraction * .pi * 2.0
return UIBezierPath(
arcCenter: bounds.centerOfCGRect(),
radius: bounds.width / 2.0 - lineThick / 2.0 - outerGap,
startAngle: .pi * -0.5 + b,
endAngle: .pi * 1.5 + b,
clockwise: true
).cgPath
}()
You're done!
override func draw(_ rect: CGRect) {
//Add gradient layer
let gl = CAGradientLayer()
gl.frame = rect
gl.colors = [UIColor.red.cgColor, UIColor.blue.cgColor]
layer.addSublayer(gl)
//create mask in the shape of arc
let sl = CAShapeLayer()
sl.frame = rect
sl.lineWidth = 15.0
sl.strokeColor = UIColor.red.cgColor
let path = UIBezierPath()
path.addArc(withCenter: .zero, radius: 250.0, startAngle: 10.0.radians, endAngle: 60.0.radians, clockwise: true)
sl.fillColor = UIColor.clear.cgColor
sl.lineCap = kCALineCapRound
sl.path = path.cgPath
//Add mask to gradient layer
gl.mask = sl
}
Hope this helps!!