How to add a border to a circular image with mask - ios

This is my attempt:
func round() {
let width = bounds.width < bounds.height ? bounds.width : bounds.height
let mask = CAShapeLayer()
mask.path = UIBezierPath(ovalInRect: CGRectMake(bounds.midX - width / 2, bounds.midY - width / 2, width, width)).CGPath
self.layer.mask = mask
// add border
let frameLayer = CAShapeLayer()
frameLayer.path = mask.path
frameLayer.lineWidth = 4.0
frameLayer.strokeColor = UIColor.whiteColor().CGColor
frameLayer.fillColor = nil
self.layer.addSublayer(frameLayer)
}
It works on the iphone 6 simulator (storyboard has size of 4.7), but on the 5s and 6+ it looks weird:
Is this an auto layout issue? Without the border, auto layout works correct. This is my first time working with masks and so I am not sure if what I have done is correct.
round function is called in viewDidLayoutSubviews.
Any thoughts?

If you have subclassed UIImageView, for example, you can override layoutSubviews such that it (a) updates the mask; (b) removes any old border; and (c) adds a new border. In Swift 3:
import UIKit
#IBDesignable
class RoundedImageView: UIImageView {
/// saved rendition of border layer
private weak var borderLayer: CAShapeLayer?
override func layoutSubviews() {
super.layoutSubviews()
// create path
let width = min(bounds.width, bounds.height)
let path = UIBezierPath(arcCenter: CGPoint(x: bounds.midX, y: bounds.midY), radius: width / 2, startAngle: 0, endAngle: .pi * 2, clockwise: true)
// update mask and save for future reference
let mask = CAShapeLayer()
mask.path = path.cgPath
layer.mask = mask
// create border layer
let frameLayer = CAShapeLayer()
frameLayer.path = path.cgPath
frameLayer.lineWidth = 32.0
frameLayer.strokeColor = UIColor.white.cgColor
frameLayer.fillColor = nil
// if we had previous border remove it, add new one, and save reference to new one
borderLayer?.removeFromSuperlayer()
layer.addSublayer(frameLayer)
borderLayer = frameLayer
}
}
That way, it responds to changing of the layout, but it makes sure to clean up any old borders.
By the way, if you are not subclassing UIImageView, but rather are putting this logic inside the view controller, you would override viewWillLayoutSubviews instead of layoutSubviews of UIView. But the basic idea would be the same.
--
By the way, I use a mask in conjunction with this shape layer because if you merely apply rounded corners of a UIView, it can result in weird artifacts (look at very thin gray line at lower part of the circular border):
If you use the bezier path approach, no such artifacts result:
For Swift 2.3 example, see earlier revision of this answer.

The easiest way is to manipulate the layer of the image view itself.
imageView.layer.cornerRadius = imageView.bounds.size.width / 2.0
imageView.layer.borderWidth = 2.0
imageView.layer.borderColor = UIColor.whiteColor.CGColor
imageView.layer.masksToBounds = true
You can include this in viewDidLayoutSubviews or layoutSubviews to make sure the size is always correct.
NB: Maybe this technique makes your circle mask obsolete ;-). As a rule of thumb, always choose the simplest solution.

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 set UIView in semi circle in Swift 5

I want to crop my UIView to the semi-circle and that will support all iPhone devices. Here, we can't set height and width to fix. I am using a multiplier.
Here is some of the SO answer I checked.
How to crop the UIView as semi circle?
Draw a semi-circle button iOS
It's work but for the fix height and width not with multiplier and I am targeting universal devices.
Below is the image for semicircle UIView.
Thanks in advance.
I have just modified the circle code from this link code. The problem is in the original code it used the initial frame when again layoutSubviews method called at that time semiCirleLayer == nil this condition is prevented from the update bezier path.
class SemiCirleView: UIView {
var semiCirleLayer: CAShapeLayer = CAShapeLayer()
override func layoutSubviews() {
super.layoutSubviews()
let arcCenter = CGPoint(x: bounds.size.width / 2, y: bounds.size.height)
let circleRadius = bounds.size.width / 2
let circlePath = UIBezierPath(arcCenter: arcCenter, radius: circleRadius, startAngle: CGFloat.pi, endAngle: CGFloat.pi * 2, clockwise: true)
semiCirleLayer.path = circlePath.cgPath
semiCirleLayer.fillColor = UIColor.red.cgColor
semiCirleLayer.name = "RedCircleLayer"
if !(layer.sublayers?.contains(where: {$0.name == "RedCircleLayer"}) ?? false) {
layer.addSublayer(semiCirleLayer)
}
// Make the view color transparent
backgroundColor = UIColor.clear
}
}

Swift: How to set a different radii for each corner of a UIView or CALayer without explicitly using a CGRect?

I am hoping to have different radii for each corner of a UIView or CALayer, however, without explicitly referring to a CGRect.
For example, I've done something along the lines of:
layer.cornerRadius = 4
let maskPath = UIBezierPath(roundedRect: bounds, byRoundingCorners: [.topLeft, .topRight, .bottomLeft], cornerRadii: CGSize(width: 18.0, height: 0.0))
let maskLayer = CAShapeLayer()
maskLayer.path = maskPath.cgPath
layer.mask = maskLayer
However, this relies on the bounds CGRect, so, when my device rotates, everything is broken. I can update the CGRect in layoutSubviews(), however, this is delayed as shown in this article.
If anyone knows how to have different corner radii on the main layer of a UIView (so the bounds of the CALayer or UIView will update automatically) I would appreciate it incredibly!
This should help you. From the article:
In iOS 11, Apple introduced a new property named maskedCorners for the
Core Animation layer (CALayer). This property is of the type
CACornerMask, which has 4 possible values:
layerMaxXMaxYCorner – lower right corner
layerMaxXMinYCorner – top right corner
layerMinXMaxYCorner – lower left corner
layerMinXMinYCorner – top left corner
By default, the value of
maskedCorners is set to display all the four corners. Now, let’s say
you just want to round the top corners. You can set maskedCorners to
the following:
self.view.layer.maskedCorners = [.layerMinXMinYCorner,
.layerMaxXMinYCorner]
example:
lazy var viewWithTopCornersRounded: UIView = {
let view = UIView()
view.layer.masksToBounds = true
view.layer.cornerRadius = 4
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] // top left and top right will be rounded
return view
}()
All 4 corners:
yourView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
yourView.layer.masksToBounds = true
yourView.layer.cornerRadius = 4

How to apply multiple masks to UIView

I have a question about how to apply multiple masks to a UIView that already has a mask.
The situation:
I have a view with an active mask that creates a hole in its top left corner, this is a template UIView that is reused everywhere in the project. Later in the project I would like to be able to create a second hole but this time in the bottom right corner, this without the need to create a completely new UIView.
The problem:
When I apply the bottom mask, it of course replaces the first one thus removing the top hole ... Is there a way to combine them both? And for that matter to combine any existing mask with a new one?
Thank you in advance!
Based on #Sharad's answer, I realised that re-adding the view's rect would enable me to combine the original and new mask into one.
Here is my solution:
func cutCircle(inView view: UIView, withRect rect: CGRect) {
// Create new path and mask
let newMask = CAShapeLayer()
let newPath = UIBezierPath(ovalIn: rect)
// Create path to clip
let newClipPath = UIBezierPath(rect: view.bounds)
newClipPath.append(newPath)
// If view already has a mask
if let originalMask = view.layer.mask,
let originalShape = originalMask as? CAShapeLayer,
let originalPath = originalShape.path {
// Create bezierpath from original mask's path
let originalBezierPath = UIBezierPath(cgPath: originalPath)
// Append view's bounds to "reset" the mask path before we re-apply the original
newClipPath.append(UIBezierPath(rect: view.bounds))
// Combine new and original paths
newClipPath.append(originalBezierPath)
}
// Apply new mask
newMask.path = newClipPath.cgPath
newMask.fillRule = kCAFillRuleEvenOdd
view.layer.mask = newMask
}
This is code I have used in my project to create one circle and one rectangle mask in UIView, you can replace the UIBezierPath line with same arc code :
func createCircleMask(view: UIView, x: CGFloat, y: CGFloat, radius: CGFloat, downloadRect: CGRect){
self.layer.sublayers?.forEach { ($0 as? CAShapeLayer)?.removeFromSuperlayer() }
let mutablePath = CGMutablePath()
mutablePath.addArc(center: CGPoint(x: x, y: y + radius), radius: radius, startAngle: 0.0, endAngle: 2 * 3.14, clockwise: false)
mutablePath.addRect(view.bounds)
let path = UIBezierPath(roundedRect: downloadRect, byRoundingCorners: [.topLeft, .bottomRight], cornerRadii: CGSize(width: 5, height: 5))
mutablePath.addPath(path.cgPath)
let mask = CAShapeLayer()
mask.path = mutablePath
mask.fillRule = kCAFillRuleEvenOdd
mask.backgroundColor = UIColor.clear.cgColor
view.layer.mask = mask
}
Pass your same UIView, it removes previous layers and applies new masks on same UIView.
Here mask.fillRule = kCAFillRuleEvenOdd is important. If you notice there are 3 mutablePath.addPath() functions, what kCAFillRuleEvenOdd does is, it first creates a hole with the arc then adds Rect of that view's bound and then another mask to create 2nd hole.
You can do something like the following, if you don't only have "simple shapes" but actual layers from e.g. other views, like UILabel or UIImageView.
let maskLayer = CALayer()
maskLayer.frame = viewToBeMasked.bounds
maskLayer.addSublayer(self.imageView.layer)
maskLayer.addSublayer(self.label.layer)
viewToBeMasked.bounds.layer.mask = maskLayer
So basically I just create a maskLayer, that contains all the other view's layer as sublayer and then use this as a mask.

Don't want bezier path get clipped by view frame

I thought that Bezier paths don't get clipped by the containing view, unless explicitly specified. I've verified in storyboard and can confirm that "Clip subviews" is unchecked. Also I've added clipsToBounds = falsein drawRect(),
Is there a way to not clip the bezier path at edges of containing view?
I don't want to have a clipped circle like shown in the image:
Here's my code:
override func drawRect(rect: CGRect) {
let lineWidth: CGFloat = 6
let color: UIColor = UIColor.yellowColor()
clipsToBounds = false
let haloRectFrame = CGRectMake(0, 0, bounds.width, bounds.height)
let haloPath = UIBezierPath(ovalInRect: haloRectFrame)
haloPath.lineWidth = lineWidth
color.set()
haloPath.stroke()
Ok, I've implemented two solutions. First solution utilises Bezier path with stroke. Here the stroke gets' clipped, so to fix it I've reduced the frame size. The second solution utilises CAShapeLayer, a subclass of CALayer. Here I use Bezier path, but since CAShapeLayer doesn't get clipped I don't need to reduce the frame size.
Solution 1 (Bezier path with stroke):
override func drawRect(rect: CGRect) {
let haloRectFrame = CGRectMake(
lineWidth / 2,
lineWidth / 2,
rect.width - lineWidth,
rect.height - lineWidth)
let haloPath = UIBezierPath(ovalInRect: haloRectFrame)
haloPath.lineWidth = lineWidth
color.set()
haloPath.stroke()
}
Solution 2 (CAShapeLayer):
override func drawRect(rect: CGRect) {
let haloPath = UIBezierPath(ovalInRect: rect).CGPath
let haloLayer = CAShapeLayer(layer: layer)
haloLayer.path = haloPath
haloLayer.fillColor = UIColor.clearColor().CGColor
haloLayer.strokeColor = color.CGColor
haloLayer.lineWidth = lineWidth
layer.addSublayer(haloLayer)
}

Resources