How to position Layers in swift programmatically? - ios

I am trying to position a circular progress bar but I am failing constantly. I guess I am not understanding the concept of frames and views and bounds. I found this post on stack overflow that shows me exactly how to construct a circular progress bar.
However, in the post, a circleView was created as UIView using CGRect(x: 100, y: 100, width: 100, height: 100)
In my case, manually setting the x and y coordinates is obv a big no. So the circleView has to be in the centre of it's parent view.
Here is my view hierarchy:
view -> view2ForHomeController -> middleView -> circleView
So everything is positioned using auto layout. Here is the problem:The circleView is adding properly and it positions itself at the x and y value I specify but how can I specify the x and y values of the center of middleView. I tried the following but the center values are returned as 0.0, 0.0.
self.view.addSubview(self.view2ForHomeController)
self.view2ForHomeController.fillSuperview()
let xPosition = self.view2ForHomeController.middleView.frame.midX
let yPostion = self.view2ForHomeController.middleView.frame.midY
print(xPosition) // ------- PRINTS ZERO
print(yPostion) // ------- PRINTS ZERO
let circle = UIView(frame: CGRect(x: xPosition, y: yPostion, width: 100, height: 100))
circle.backgroundColor = UIColor.green
self.view2ForHomeController.middleView.addSubview(circle)
var progressCircle = CAShapeLayer()
progressCircle.frame = self.view.bounds
let lineWidth: CGFloat = 10
let rectFofOval = CGRect(x: lineWidth / 2, y: lineWidth / 2, width: circle.bounds.width - lineWidth, height: circle.bounds.height - lineWidth)
let circlePath = UIBezierPath(ovalIn: rectFofOval)
progressCircle = CAShapeLayer ()
progressCircle.path = circlePath.cgPath
progressCircle.strokeColor = UIColor.white.cgColor
progressCircle.fillColor = UIColor.clear.cgColor
progressCircle.lineWidth = 10.0
progressCircle.frame = self.view.bounds
progressCircle.lineCap = CAShapeLayerLineCap(rawValue: "round")
circle.layer.addSublayer(progressCircle)
circle.transform = circle.transform.rotated(by: CGFloat.pi/2)
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0
animation.toValue = 1.1
animation.duration = 1
animation.repeatCount = MAXFLOAT
animation.fillMode = CAMediaTimingFillMode.forwards
animation.isRemovedOnCompletion = false
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
progressCircle.add(animation, forKey: nil)

Frame is in terms of superview/superlayer coordinates. If you are going to say
circle.layer.addSublayer(progressCircle)
then you must give progressCircle a frame in terms of the bounds of circle.layer.
As for centering, in general, to center a sublayer in its superlayer you say:
theSublayer.position = CGPoint(
x:theSuperlayer.bounds.midX,
y:theSuperlayer.bounds.midY)
And to center a subview in its superview you say:
theSubview.center = CGPoint(
x:theSuperview.bounds.midX,
y:theSuperview.bounds.midY)

The bounds are the layer/view's coordinates in its local coordinate system. The frame is the layer/view's coordinates in its parents coordinate system. Since layers do not participate in auto layout, you should implement UIViewController.viewDidLayoutSubviews (or UIView.layoutSubLayers) and set the frame of the layer to the bounds of its super layer's view (the backing layer of a UIView essentially is the CoreGraphics version of that view).
This also means you need to recompute your path in they layout method. if that is expensive then draw your path in unit space and use the transform of the layer to scale it up to the view's dimensions instead.

The solution is to first add the subviews and then create the entire circular progress view at
override func viewDidLayoutSubviews(){
super.viewDidLayoutSubviews()
// Add and position the circular progress bar and its layers here
}

Related

How do I make everything outside of a CAShapeLayer black with an opacity of 50% with Swift?

I have the following code which draws a shape:
let screenSize: CGRect = UIScreen.main.bounds
let cardLayer = CAShapeLayer()
let cardWidth = 350.0
let cardHeight = 225.0
let cardXlocation = (Double(screenSize.width) - cardWidth) / 2
let cardYlocation = (Double(screenSize.height) / 2) - (cardHeight / 2) - (Double(screenSize.height) * 0.05)
cardLayer.path = UIBezierPath(roundedRect: CGRect(x: 0, y: 0, width: cardWidth, height: 225.0), cornerRadius: 10.0).cgPath
cardLayer.position = CGPoint(x: cardXlocation, y: cardYlocation)
cardLayer.strokeColor = UIColor.white.cgColor
cardLayer.fillColor = UIColor.clear.cgColor
cardLayer.lineWidth = 4.0
self.previewLayer.insertSublayer(cardLayer, above: self.previewLayer)
I want everything outside of the shape to be black with an opacity of 50%. That way you can see the camera view still behind it, but it's dimmed, except where then shape is.
I tried adding a mask to previewLayer.mask but that didn't give me the effect I was looking for.
Your impulse to use a mask is correct, but let's think about what needs to be masked. You are doing three things:
Dimming the whole thing. Let's call that the dimming layer. It needs a dark semi-transparent background.
Drawing the white rounded rect. That's the shape layer.
Making a hole in the entire thing. That's the mask.
Now, the first two layers can be the same layer. That leaves only the mask. This is not trivial to construct: a mask affects its owner in terms entirely of its transparency, so we need a mask that is opaque except for an area shaped like the shape of the shape layer, which needs to be clear. To get that, we start with the shape and clip to that shape as we fill the mask — or we can clip to that shape as we erase the mask, which is the approach I prefer.
In addition, your code has some major flaws, the most important of which is that your shape layer has no size. Without a size, there is nothing to mask.
So here, with corrections and additions, is your code; I made this the entirety of a view controller, for testing purposes, and what I'm covering is the entire view controller's view rather than a particular subview or sublayer:
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .red
}
private var didInitialLayout = false
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if didInitialLayout {
return
}
didInitialLayout = true
let screenSize = UIScreen.main.bounds
let cardLayer = CAShapeLayer()
cardLayer.frame = self.view.bounds
self.view.layer.addSublayer(cardLayer)
let cardWidth = 350.0 as CGFloat
let cardHeight = 225.0 as CGFloat
let cardXlocation = (screenSize.width - cardWidth) / 2
let cardYlocation = (screenSize.height / 2) - (cardHeight / 2) - (screenSize.height * 0.05)
let path = UIBezierPath(roundedRect: CGRect(
x: cardXlocation, y: cardYlocation, width: cardWidth, height: cardHeight),
cornerRadius: 10.0)
cardLayer.path = path.cgPath
cardLayer.strokeColor = UIColor.white.cgColor
cardLayer.lineWidth = 8.0
cardLayer.backgroundColor = UIColor.black.withAlphaComponent(0.5).cgColor
let mask = CALayer()
mask.frame = cardLayer.bounds
cardLayer.mask = mask
let r = UIGraphicsImageRenderer(size: mask.bounds.size)
let im = r.image { ctx in
UIColor.black.setFill()
ctx.fill(mask.bounds)
path.addClip()
ctx.cgContext.clear(mask.bounds)
}
mask.contents = im.cgImage
}
And here's what we get. I didn't have a preview layer but the background is red, and as you see, the red shows through inside the white shape, which is just the effect you are looking for.
The shape layer can only affect what it covers, not the space it doesn't cover. Make a path that covers the entire video and has a hole in it where the card should be.
let areaToDarken = previewLayer.bounds // assumes origin at 0, 0
let areaToLeaveClear = areaToDarken.insetBy(dx: 50, dy: 200)
let shapeLayer = CAShapeLayer()
let path = CGPathCreateMutable()
path.addRect(areaToDarken, ...)
path.addRoundedRect(areaToLeaveClear, ...)
cardLayer.frame = previewLayer.bounds // use frame if shapeLayer is sibling
cardLayer.path = path
cardLayer.fillRule = .evenOdd // allow holes
cardLayer.fillColor = black, 50% opacity

How to make UIBezierPath to fill from the center

How do I make a UIBezierPath that starts drawing from the center and expands to the left and right?
My current solution is not using UIBezierPath but rather a UIView inside another UIView. The children view is centered inside of it's parent and I adjust child's width when I need it to expand. However, this solution is far from perfect because the view is located inside of UIStackView and the stack view is in UICollectionView cell and it sometimes does not get the width correctly.
override func layoutSubviews() {
super.layoutSubviews()
progressView.cornerRadius = cornerRadius
clipsToBounds = true
addSubview(progressView)
NSLayoutConstraint.activate([
progressView.topAnchor.constraint(equalTo: topAnchor),
progressView.bottomAnchor.constraint(equalTo: bottomAnchor),
progressView.centerXAnchor.constraint(equalTo: centerXAnchor)
])
let width = frame.width
let finalProgress = CGFloat(progress) * width
let widthConstraint = progressView.widthAnchor.constraint(equalToConstant: finalProgress)
widthConstraint.priority = UILayoutPriority(rawValue: 999)
widthConstraint.isActive = true
}
the progress is a class property of type double and when it gets set I call layoutIfNeeded.
I also tried calling setNeedsLayout and layoutIfNeeded but it does not solve the issue. SetNeedsDisplay and invalidateIntrinzicSize do not work either.
Now, I would like to do it using bezier path, but the issue is that it starts from a given point and draws from left to right. The question is: how do I make a UIBezierPath that is centered and when I increase the width it expands (stays in center)
Here is the image of what I want to achieve.
There are quite a few options. But here are a few:
CABasicAnimation of the strokeStart and strokeEnd of CAShapeLayer, namely:
Create CAShapeLayer whose path is the final UIBezierPath (full width), but whose strokeStart and strokeEnd are both 0.5 (i.e. start half-way, and finish half-way, i.e. nothing visible yet).
func configure() {
shapeLayer.strokeColor = strokeColor.cgColor
shapeLayer.lineCap = .round
setProgress(0, animated: false)
}
func updatePath() {
let path = UIBezierPath()
path.move(to: CGPoint(x: bounds.minX + lineWidth, y: bounds.midY))
path.addLine(to: CGPoint(x: bounds.maxX - lineWidth, y: bounds.midY))
shapeLayer.path = path.cgPath
shapeLayer.lineWidth = lineWidth
}
Then, in CABasicAnimation animate the change of the strokeStart to 0 and the strokeEnd to 1 to achieve the animation growing both left and right from the middle. For example:
func setProgress(_ progress: CGFloat, animated: Bool = true) {
let percent = max(0, min(1, progress))
let strokeStart = (1 - percent) / 2
let strokeEnd = 1 - (1 - percent) / 2
if animated {
let strokeStartAnimation = CABasicAnimation(keyPath: "strokeStart")
strokeStartAnimation.fromValue = shapeLayer.strokeStart
strokeStartAnimation.toValue = strokeStart
let strokeEndAnimation = CABasicAnimation(keyPath: "strokeEnd")
strokeEndAnimation.fromValue = shapeLayer.strokeEnd
strokeEndAnimation.toValue = strokeEnd
let animation = CAAnimationGroup()
animation.animations = [strokeStartAnimation, strokeEndAnimation]
shapeLayer.strokeStart = strokeStart
shapeLayer.strokeEnd = strokeEnd
layer.add(animation, forKey: nil)
} else {
shapeLayer.strokeStart = strokeStart
shapeLayer.strokeEnd = strokeEnd
}
}
Animation of the path of a CAShapeLayer
Have method that creates the UIBezierPath:
func setProgress(_ progress: CGFloat, animated: Bool = true) {
self.progress = progress
let cgPath = path(for: progress).cgPath
if animated {
let animation = CABasicAnimation(keyPath: "path")
animation.fromValue = shapeLayer.path
animation.toValue = cgPath
shapeLayer.path = cgPath
layer.add(animation, forKey: nil)
} else {
shapeLayer.path = cgPath
}
}
Where
func path(for progress: CGFloat) -> UIBezierPath {
let path = UIBezierPath()
let percent = max(0, min(1, progress))
let width = bounds.width - lineWidth
path.move(to: CGPoint(x: bounds.midX - width * percent / 2 , y: bounds.midY))
path.addLine(to: CGPoint(x: bounds.midX + width * percent / 2, y: bounds.midY))
return path
}
When you create the CAShapeLayer call setProgress with value of 0 and no animation; and
When you want to animate it out, then call setProgress with value of 1 but with animation.
Abandon UIBezierPath/CAShapeLayer approaches and use UIView and UIView block-based animation:
Create short UIView with a backgroundColor and whose layer has a cornerRadius which is half the height of the view.
Define constraints such that the view has a zero width. E.g. you might define the centerXanchor and so that it’s placed where you want it, and with a widthAnchor is zero.
Animate the constraints by setting the existing widthAnchor’s constant to whatever width you want and place layoutIfNeeded inside a UIView animation block.

CALayer animation for width

I have one view in which need to add one layer. I successfully added the layer but now i want to do animation for it's width means layer start filling from "started" label and end at "awaiting..." label. I try to add animation with CABasicAnimation it start from beginning but it not properly fill. Please check video like for more understanding.
func startProgressAnimation()
{
self.ivStarted.isHighlighted = true
let layer = CALayer()
layer.backgroundColor = #colorLiteral(red: 0.4980392157, green: 0.8352941176, blue: 0.1921568627, alpha: 1)
layer.masksToBounds = true
layer.frame = CGRect.init(x: self.viewStartedAwaited.frame.origin.x, y: self.viewStartedAwaited.frame.origin.y, width: 0.0, height: self.viewStartedAwaited.frame.height)
self.ivStarted.layer.addSublayer(layer)
CATransaction.begin()
let boundsAnim:CABasicAnimation = CABasicAnimation(keyPath: "bounds.size.width")
boundsAnim.fromValue = NSNumber.init(value: 0.0)
boundsAnim.byValue = NSNumber.init(value: Float(self.viewStartedAwaited.frame.size.width / 2.0))
boundsAnim.toValue = NSNumber.init(value: Float(self.viewStartedAwaited.frame.size.width))
let anim = CAAnimationGroup()
anim.animations = [boundsAnim]
anim.isRemovedOnCompletion = false
anim.duration = 5
anim.fillMode = kCAFillModeBoth
CATransaction.setCompletionBlock{ [weak self] in
self?.ivAwaited.isHighlighted = true
}
layer.add(anim, forKey: "bounds.size.width")
CATransaction.commit()
}
Here is the video what i achieve with this code.
https://streamable.com/h6tpp
Not Proper
Proper image
Just set your
layer.anchorPoint = CGPoint(x: 0, y: 0)
After this it will work according to your requirement.
The default anchorpoint of (0.5,0.5) means that the growing or shrinking of width happens evenly on both sides. Anchorpoint of (0,0) means the layer is essentially pinned to the top left, so whatever new width it gets, the change happens on the right. That is, it matters not whether your animation key path is 'bounds' or 'bounds.size.width'. The anchor point is what determines where the change happens

scrolling between invisible rects

Take a look at this picture :
Imagine a scroll view, where the pink rects are images each on a scroller page.
Imagine that the blue rects are invisible rects different in size on each page.
Imagine that the yellow rect is an image that is static = not on the scroll view.
I would like to scroll between 2 and 3, where the yellow stay at the same position, BUT it disappear when the blue rects moving. (you see it only inside)
In rect 3 you can see what happen to the yellow while scrolling .
How should I put my layers on the scroller to create such an effect ?
So based on comments, it look like you need a scrollview with simple views.
This views can be with overlay that has a hole inside, take a look at code for making layer with hole:
func layerWith(img: UIImage) -> CALayer {
let overlay = UIView(frame: CGRect(x: 0, y: 0,
width: UIScreen.main.bounds.width,
height: UIScreen.main.bounds.height))
// Create the initial layer from the view bounds.
let maskLayer = CAShapeLayer()
maskLayer.frame = overlay.bounds
let myImage = UIImage(named: "star")?.cgImage
maskLayer.frame = myView.bounds
maskLayer.contents = myImage
// Create the frame.
let radius: CGFloat = 150.0
let rect = CGRect(x: overlay.frame.midX - radius,
y: overlay.frame.midY - radius,
width: 2 * radius,
height: 2 * radius)
// Create the path.
let path = UIBezierPath(rect: overlay.bounds)
maskLayer.fillRule = kCAFillRuleEvenOdd
// Append the rect to the path so that it is subtracted.
path.append(UIBezierPath(rect: rect))
maskLayer.path = path.cgPath
// Set the mask of the view.
overlay.layer.mask = maskLayer
// Add the view so it is visible.
return overlay
}
And some code to add subviews into scrollview:
var i: CGFloat = 0
//or width that you want
let width = UIApplication.shared.keyWindow!.frame.width
for item in items {
let frame = CGRect(x: width * i, y: 0, width: width, height: scrollView.frame.height)
let v = UIView(frame: frame)
//here you can add layer from code above to this view before adding it to scrollview
v.layer.addSublayer(layerWith(img:your image goes here))
self.scrollView.addSubview(v)
i += 1
}

Subclassed MaterialView not showing MaterialDepth

I have subclassed MaterialView and override drawRect to make a custom view which I then add as my tableViewFooter. As the title says I cannot get the depth to work. I've messed around with clipsToBounds and masksToBounds on varying layers/views without success. It is a tableView inside of a UIViewController just for clarity of my code.
let receiptEdge = ReceiptEdge(frame: CGRect(x: 0, y: 0, width: self.view.w, height: 30))
receiptEdge.backgroundColor = .whiteColor()
receiptEdge.depth = MaterialDepth.Depth5
//What I've tried messing with for an hour
receiptEdge.clipsToBounds = false
receiptEdge.layer.masksToBounds = true
self.tableView.clipsToBounds = false
self.view.clipsToBounds = false
self.view.layer.masksToBounds = true
self.tableView.layer.masksToBounds = true
self.tableView.tableFooterView = receiptEdge
My code for subclass ReceiptEdge
override func prepareView() {
super.prepareView()
let rect = self.frame
let path = UIBezierPath()
let receiptEdgeSize = CGFloat(rect.width / 75)
var x = CGFloat(-receiptEdgeSize / 2)
let y = rect.size.height / 2
path.moveToPoint(CGPoint(x: x, y: y))
while x < rect.width {
x += receiptEdgeSize
path.addLineToPoint(CGPoint(x: x, y: y))
x += receiptEdgeSize
path.addArcWithCenter(CGPoint(x: x, y: y), radius: receiptEdgeSize, startAngle: CGFloat(M_PI), endAngle: CGFloat(0), clockwise: true)
x += receiptEdgeSize
}
path.addLineToPoint(CGPoint(x: x, y: CGFloat(0)))
path.addLineToPoint(CGPoint(x: 0, y: 0))
path.addLineToPoint(CGPoint(x: 0, y: y))
let layer = CAShapeLayer()
layer.path = path.CGPath
self.layer.mask = layer
self.visualLayer.mask = layer
self.visualLayer.backgroundColor = UIColor.blueColor().CGColor
self.layer.shadowColor = UIColor.redColor().CGColor
}
Screenshot showing no depth
Thanks in advance for someone that knows about layers more than I!
Here is a an example of a view where I have gotten depth to work on a subclass of MaterialTableViewCell using MaterialDepth.Depth2
Here is with layer having same path of visualLayer
Here I set no layer or path to self.layer. There is shadow but view or shadow not clipping to visual layer. set shadowColor to red so you could see the difference.
So the way depth works.
MaterialView is a composite object, which uses two layers to achieve its ability to clip the bounds and still offer a shadowing. A CAShapLayer called visualLayer is added as a sublayer to the backing layer of a view. So when an image is added, it is actually added to the visualLayer, which has the masksToBounds property set to true, and the layer property has its masksToBounds property set to false. This allows the depth property to be displayed on the layer property and still give the perception of a clipping.
In this line
receiptEdge.layer.masksToBounds = true
You are actually clipping the depth. So what I would try and do, is set this
let layer = CAShapeLayer()
layer.path = path.CGPath
self.layer.mask = layer
to the visualLayer property.
That should help you out, if not get you on the right path.

Resources