Why does CABasicAnimation try to initialize another instance of my custom CALayer? - swift

I get this error:
fatal error: use of unimplemented initializer 'init(layer:)' for class 'MyProject.AccordionLayer'
using the following code. In my view controller:
override func viewDidLoad() {
let view = self.view as! AccordionView!
view.launchInitializationAnimations()
}
In my view:
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
for var i = 0; i < self.accordions.count; ++i {
var layer = AccordionLayer(accordion: self.accordions[i])
layer.accordion = self.accordions[i]
layer.frame = CGRectMake(0, CGFloat(i) * self.getDefaultHeight(), self.frame.width, self.getDefaultHeight())
self.layer.addSublayer(layer)
layer.setNeedsDisplay()
layers.append(layer)
}
}
func launchInitializationAnimations() {
for layer in self.layer.sublayers {
var animation = CABasicAnimation(keyPath: "topX")
animation.duration = 2.0
animation.fromValue = CGFloat(0.0)
animation.toValue = CGFloat(200.0)
layer.addAnimation(animation, forKey: "animateTopX")
}
}
And in my subclassed CALayer
var topX : Int
init(accordion: (color: CGColorRef!, header: String, subtitle: String, image: UIImage?)!) {
// Some initializations
// ...
super.init()
}
I also implement needsDisplayForKey, drawInContext.
I have seen 2-3 other questions with the same error message but I can't really figure out how it is related to my specific case on my own.
Why is CABasicAnimation trying to instantiate a new (my custom) CALayer?

I just ran into the same issue, and I got it working after adding the init(layer:) to my custom CALayer:
override init(layer: Any) {
super.init(layer: layer)
}
I hope it helps whoever get here.
EDIT As Ben Morrow comments, in your override you also have to copy across the property values from your custom class. For an example, see his https://stackoverflow.com/a/38468678/341994.

Why is CABasicAnimation trying to instantiate a new (my custom) CALayer?
Because animation involves making a copy of your layer to act as the presentation layer.

Related

Drawing a CAShapeLayer using autolayout

I am trying to draw and animate a circular button using CAShapeLayer but just the drawing gives me a lot of headache - I can't seem to figure out how to pass data into my class.
This is my setup:
- a class of type UIView which will draw the CAShapeLayer
- the view is rendered in my view controller and built using auto layout constraints
I have tried using layoutIfNeeded but seem to be passing the data too late for the view to be drawn. I have also tried redrawing the view in vieWillLayoutSubviews() but nothing. Example code below. What am I doing wrong?
Am I passing the data too early/too late?
Am I drawing the bezierPath too late?
I'd highly appreciate pointers.
And maybe a second follow up question: is there a simpler way to draw a circular path that is bound to it's views size?
In my View Controller:
import UIKit
class ViewController: UIViewController {
let buttonView: CircleButton = {
let view = CircleButton()
view.backgroundColor = .black
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
override func viewWillLayoutSubviews() {
}
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(buttonView)
buttonView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
buttonView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
buttonView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.75).isActive = true
buttonView.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.25).isActive = true
buttonView.layoutIfNeeded()
buttonView.arcCenter = buttonView.center
buttonView.radius = buttonView.frame.width/2
}
override func viewDidAppear(_ animated: Bool) {
print(buttonView.arcCenter)
print(buttonView.radius)
}
}
And the class for the buttonView:
class CircleButton: UIView {
//Casting outer circular layers
let trackLayer = CAShapeLayer()
var arcCenter = CGPoint()
var radius = CGFloat()
//UIView Init
override init(frame: CGRect) {
super.init(frame: frame)
}
//UIView post init
override func layoutSubviews() {
super.layoutSubviews()
print("StudyButtonView arcCenter \(arcCenter)")
print("StudyButtonView radius \(radius)")
layer.addSublayer(trackLayer)
let outerCircularPath = UIBezierPath(arcCenter: arcCenter, radius: radius, startAngle: 0, endAngle: 2*CGFloat.pi, clockwise: true)
trackLayer.path = outerCircularPath.cgPath
trackLayer.strokeColor = UIColor.lightGray.cgColor
trackLayer.lineWidth = 5
trackLayer.strokeStart = 0
trackLayer.strokeEnd = 1
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.transform = CATransform3DMakeRotation(-CGFloat.pi/2, 0, 0, 1)
}
//Required for subclass
required init?(coder aDecoder: NSCoder) {
fatalError("has not been implemented")
}
}
There really isn't any correlation between auto-layout and the proper implementation of your CircleButton class. Your CircleButton class doesn't know or care whether it's being configured via auto-layout or whether it has some fixed size.
Your auto-layout code looks OK (other than points 5 and 6 below). Most of the issues in your code snippet rest in your CircleButton class. A couple of observations:
If you're going to rotate the shape layer, you have to set its frame, too, otherwise the size is .zero and it's going to end up rotating it about the origin of the view (and rotate outside of the bounds of the view, especially problematic if you're clipping subviews). Make sure to set the frame of the CAShapeLayer to be the bounds of the view before trying to rotate it. Frankly, I'd remove the transform, but given that you're playing around with strokeStart and strokeEnd, I'm guessing you may want to change these values later and have it start at 12 o'clock, in which case the transform makes sense.
Bottom line, if rotating, set the frame first. If not, setting the layer's frame is optional.
If you're going to change the properties of the view in order to update the shape layer, you'll want to make sure that the didSet observers do the appropriate updating of the shape layer (or call setNeedsLayout). You don't want your view controller from having to mess around with the internals of the shape layer, but you also want to make sure that these changes do get reflected in the shape layer.
It's a minor observation, but I'd suggest adding the shape layer during init and only configuring and adding it to the view hierarchy once. This is more efficient. So, have the various init methods call your own configure method. Then, do size-related stuff (like updating the path) in layoutSubviews. Finally, have properties observers that update the shape layer directly. This division of labor is more efficient.
If you want, you can make this #IBDesignable and put it in its own target in your project. Then you can add it right in IB and see what it will look like. You can also make all the various properties #IBInspectable, and you'll be able to set them right in IB, too. You then don't have to do anything in the code of your view controller if you don't want to. (But if you want to, feel free.)
A minor issue, but when you add your view programmatically, you don't need to call buttonView.layoutIfNeeded(). You only need to do that if you're animating constraints, which you're not doing here. Once you add the constraints (and fix the above issues), the button will be laid out correctly, with no explicit layoutIfNeeded required.
Your view controller has a line of code that says:
buttonView.arcCenter = buttonView.center
That is conflating arcCenter (which is a coordinate within the buttonView's coordinate space) and buttonView.center (which is the coordinate for the button's center within the view controller's root view's coordinate space). One has nothing to do with the other. Personally, I'd get rid of this manual setting of arcCenter, and instead have layoutSubviews in ButtonView take care of this dynamically, using bounds.midX and bounds.midY.
Pulling that all together, you get something like:
#IBDesignable
class CircleButton: UIView {
private let trackLayer = CAShapeLayer()
#IBInspectable var lineWidth: CGFloat = 5 { didSet { updatePath() } }
#IBInspectable var fillColor: UIColor = .clear { didSet { trackLayer.fillColor = fillColor.cgColor } }
#IBInspectable var strokeColor: UIColor = .lightGray { didSet { trackLayer.strokeColor = strokeColor.cgColor } }
#IBInspectable var strokeStart: CGFloat = 0 { didSet { trackLayer.strokeStart = strokeStart } }
#IBInspectable var strokeEnd: CGFloat = 1 { didSet { trackLayer.strokeEnd = strokeEnd } }
override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
configure()
}
private func configure() {
trackLayer.fillColor = fillColor.cgColor
trackLayer.strokeColor = strokeColor.cgColor
trackLayer.strokeStart = strokeStart
trackLayer.strokeEnd = strokeEnd
layer.addSublayer(trackLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
updatePath()
}
private func updatePath() {
let arcCenter = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = (min(bounds.width, bounds.height) - lineWidth) / 2
trackLayer.lineWidth = lineWidth
trackLayer.path = UIBezierPath(arcCenter: arcCenter, radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: true).cgPath
// There's no need to rotate it if you're drawing a complete circle.
// But if you're going to transform, set the `frame`, too.
trackLayer.transform = CATransform3DIdentity
trackLayer.frame = bounds
trackLayer.transform = CATransform3DMakeRotation(-.pi / 2, 0, 0, 1)
}
}
That yields:
Or you can tweak the settings right in IB, and you'll see it take effect:
And having made sure that all of the didSet observers for the properties of ButtonView either update the path or directly update some shape layer, the view controller can now update these properties and they'll automatically be rendered in the ButtonView.
The main issue that I see in your code is that you are adding the layer inside -layoutSubviews, this method is called multiple times during a view lifecycle.
If you don't want to make the view hosted layer a CAShapeLayer by using the layerClass property, you need to override the 2 init methods (frame and coder) and call a commonInit where you instantiate and add your CAShape layer as a sublayer.
In -layoutSubviews just set the frame property of it and the path according to the new view size.

Using a CALayer in an NSStatusBarButton

I'm interested in drawing custom content in a NSStatusBarButton similar to the built-in battery menu bar app:
I'd like to use a CALayer due to the flexibility of drawing custom content. I've managed to add a layer-backed NSView to the button using this code:
// StatusView
class StatusView: NSView {
required init?(coder: NSCoder) {
super.init(coder: coder)
self.wantsLayer = true
}
override var wantsUpdateLayer: Bool {
return true
}
override func updateLayer() {
if let layer = self.layer {
layer.backgroundColor = NSColor.clear.cgColor
// Code adding text layer...
}
}
}
// MenuController
class StatusMenuController: NSObject {
override func awakeFromNib() {
if let button = statusItem.button {
layerView.frame = NSRect(x: 0.0, y: 0.0, width: len / 2, height: button.bounds.height)
button.addSubview(layerView)
}
}
}
Basically I'm adding a CALayer to the button of the NSStatusBarButton, which looks fine normally, but seems to have an opaque background when you click the menu item:
I've set the background color of the CALayer to transparent and the isOpaque property of the layer's view is false. Is there a way to get the view to actually blend with the background, similar to the battery menu bar app?
Thanks!

drawRect throws "fatal error: unexpectedly found nil while unwrapping an Optional value" in Xcode playground

Why the below code throws fatal error: unexpectedly found nil while unwrapping an Optional value when run in Xcode playground? I am not sure what is wrong with the below code. Thanks for your help. I haven't tried running it outside playground.
import UIKit
import XCPlayground
class CircularProgressView: UIView {
var progressBackgroundLayer: CAShapeLayer!
var progressLayer: CAShapeLayer!
var iconLayer: CAShapeLayer!
override init(frame: CGRect) {
super.init(frame: frame)
self.setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.setup()
}
convenience init() {
self.init(frame: CGRectZero)
}
func setup() {
progressBackgroundLayer = CAShapeLayer(layer: layer)
progressLayer = CAShapeLayer(layer: layer)
iconLayer = CAShapeLayer(layer: layer)
}
override func drawRect(rect: CGRect) {
progressBackgroundLayer.frame = self.bounds
progressLayer.frame = self.bounds
iconLayer.frame = self.bounds
}
}
var progressView = CircularProgressView(frame: CGRectMake(0, 0, 80, 80))
XCPlaygroundPage.currentPage.needsIndefiniteExecution = true
XCPlaygroundPage.currentPage.liveView = progressView
You shouldn't use the init(layer:) initialiser in order to create your CAShapeLayers. From the documentation (emphasis mine):
This initializer is used to create shadow copies of layers, for example, for the presentationLayer method. Using this method in any other situation will produce undefined behavior. For example, do not use this method to initialize a new layer with an existing layer’s content.
Therefore, as the behaviour of calling this initialiser is undefined in this circumstance, it's returning nil in your case – which you're then force unwrapping upon accessing, as it's an implicitly unwrapped optional.
You should instead just create your layers with init(). I would also recommend that you define your layer instances inline, and get rid of the implicitly unwrapped optionals, as they're inherently unsafe. For example:
class CircularProgressView: UIView {
let progressBackgroundLayer = CAShapeLayer()
let progressLayer = CAShapeLayer()
let iconLayer = CAShapeLayer()
...
In order to add the layers to your view, you need to use the addSublayer: method on the view's layer. For example:
func setup() {
layer.addSublayer(progressBackgroundLayer)
layer.addSublayer(progressLayer)
layer.addSublayer(iconLayer)
}
Also, drawRect: is used to draw the contents of your view, and therefore is totally the wrong place to be defining the frames of your layers (which should only occur when the view's bounds changes). You should instead consider doing this in layoutSubviews.
override func layoutSubviews() {
progressBackgroundLayer.frame = bounds
progressLayer.frame = bounds
iconLayer.frame = bounds
}
And finally, this code is pointless:
convenience init() {
self.init(frame: CGRectZero)
}
This is exactly what the UIView implementation of init does already.

OS X app freeze when closing window during CALayer animation when Tweetbot is running

I made a custom NSControl to use it as a custom layer backed button.
When the MyButton instance receives mouseDown and mouseUp events it changes its backgroundLayer's backgroundColorand its textLayer's foregroundColor.
It's using layers so the changes are implicitly animated.
But on mouseUp if the mouse is inside the MyButton's instance frame I call the linked action through sendAction(_:to:) method.
I linked the action to a method closing the current window and opening another one but sometimes the app freeze after the second window shows up.
I tried several things and it seems related to the layer animations, maybe something to do before closing the window that I'm not aware of.
UPDATE: It's Tweetbot ! If Tweetbot is running it's freezing the app! As soon as I quit it the app becomes responsive again.
You can find an example project here :
https://dl.dropboxusercontent.com/u/378166/CALayerFreeze.zip
(note that you'll sometimes have to try several times before the bug occurs)
Here's the code for MyButton.
class MyButton: NSControl {
let title = "Click me!"
// Init
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
deinit {
trackingAreas.forEach { self.removeTrackingArea($0) }
}
// Layer + Tracking Area configuration
var backgroundLayer = CALayer()
var textLayer = CATextLayer()
func setup() {
wantsLayer = true
backgroundLayer.frame = NSRect(origin: .zero, size: frame.size)
backgroundLayer.backgroundColor = NSColor.whiteColor().CGColor
layer?.addSublayer(backgroundLayer)
textLayer.frame = NSRect(origin: .zero, size: frame.size)
textLayer.string = title
textLayer.foregroundColor = NSColor.blackColor().colorWithAlphaComponent(0.64).CGColor
layer?.addSublayer(textLayer)
addTrackingArea(
NSTrackingArea(
rect: bounds,
options: [.MouseEnteredAndExited, .EnabledDuringMouseDrag, .ActiveInKeyWindow],
owner: self,
userInfo: nil
)
)
}
// States
private func normal() {
// ——— COMMENTING THIS MAKES THE BEACHBALL GO AWAY
backgroundLayer.backgroundColor = NSColor.whiteColor().CGColor
textLayer.foregroundColor = NSColor.blackColor().colorWithAlphaComponent(0.64).CGColor
}
private func highlight() {
// ——— COMMENTING THIS MAKES THE BEACHBALL GO AWAY
backgroundLayer.backgroundColor = NSColor.grayColor().CGColor
textLayer.foregroundColor = NSColor.whiteColor().colorWithAlphaComponent(0.64).CGColor
}
// Tracking events
var isMouseDown = false
override func mouseDown(theEvent: NSEvent) {
super.mouseDown(theEvent)
isMouseDown = true
highlight()
}
override func mouseUp(theEvent: NSEvent) {
super.mouseUp(theEvent)
isMouseDown = false
normal()
if frame.contains(convertPoint(theEvent.locationInWindow, toView: self)) {
sendAction(action, to: target)
}
}
}

Custom Activity Indicator with Swift

I am trying to create a custom activity indicator like one of these. There seems to be many approaches to animating the image, so I'm trying to figure out the best approach.
I found this tutorial with Photoshop + Aftereffects animation loop, but as the comment points out, it seems overly complex (and I don't own After Effects).
tldr: how can I take my existing image and animate it as a activity indicator (using either rotating/spinning animation or looping through an animation)
It's been a while since you posted this question, so I'm not sure if you've found the answer you're looking for.
You are right there are many ways to do what you are asking, and I don't think there's a "right way". I would make a UIView object which have a start and a stop method as the only public methods. I would also make it a singleton, so it's a shared object and not possible to put multiple instances on a UIViewController (imagine the mess it could make).
You can add a dataSource protocol which returns a UIViewController so the custom activity indicator could add itself to it's parent.
In the start method I would do whatever animations is needed (transform, rotate, GIF, etc.).
Here's an example:
import UIKit
protocol CustomActivityIndicatorDataSource {
func activityIndicatorParent() -> UIViewController
}
class CustomActivityIndicator: UIView {
private var activityIndicatorImageView: UIImageView
var dataSource: CustomActivityIndicatorDataSource? {
didSet {
self.setupView()
}
}
// Singleton
statice let sharedInstance = CustomActivityIndicator()
// MARK:- Initialiser
private init() {
var frame = CGRectMake(0,0,200,200)
self.activityIndicatorImageView = UIImageView(frame: frame)
self.activityIndicatorImageView.image = UIImage(named: "myImage")
super.init(frame: frame)
self.addSubview(self.activityIndicatorImageView)
self.hidden = true
}
internal required init?(coder aDecoder: NSCoder) {
self.activityIndicatorImageView = UIImageView(frame: CGRectMake(0, 0, 200, 200))
self.activityIndicatorImageView = UIImage(named: "myImage")
super.init(coder: aDecoder)
}
// MARK:- Helper methods
private func setupView() {
if self.dataSource != nil {
self.removeFromSuperview()
self.dataSource!.activityIndicatorParent().addSubview(self)
self.center = self.dataSource!.activityIndicatorParent().center
}
}
// MARK:- Animation methods
/**
Set active to true, and starts the animation
*/
func startAnimation() {
// A custom class which does thread handling. But you can use the dispatch methods.
ThreadController.performBlockOnMainQueue{
self.active = true
self.myAnimation
self.hidden = false
}
}
/**
This will set the 'active' boolean to false.
Remeber to remove the view from the superview manually
*/
func stopAnimation() {
ThreadController.performBlockOnMainQueue{
self.active = false
self.hidden = true
}
}
}
Mind that this is not fully tested and may need some tweaks to work 100%, but this is how I basically would handle it.