How to make a CAGradientLayer follow a CGPath - ios

Given a CAShapeLayer defining a path as in the picture below, I want to add a CAGradientLayer that follows the path of the shape layer.
For example, given a gradient from black->red:
the top right rounded piece would be black,
and if the slider was at 100, the top left would be red,
and if the slider was at 50, then half the slider would be black (as below), and the visible gradient would go from black (top right) to a redish-black at the bottom
Every previous post I've found does not actually answer this question.
For example, because I can only add axial CAGradientLayers, I can kind-of do this (pic below), but you can see it's not correct (the top left ends up becoming black again). How do I make the gradient actually follow the path/mask

For the simple circular shape path, the new conic gradient is great. I was able to get this immediately (this is a conic gradient layer masked by a circle shape layer):
I used red and green so as to show the gradient clearly. This doesn't seem to be identically what you're after, but I can't believe it will be very difficult to achieve your goals now that .conic exists.
class MyGradientView : UIView {
override class var layerClass : AnyClass { return CAGradientLayer.self }
required init?(coder aDecoder: NSCoder) {
super.init(coder:aDecoder)
let lay = self.layer as! CAGradientLayer
lay.type = .conic
lay.startPoint = CGPoint(x:0.5,y:0.5)
lay.endPoint = CGPoint(x:0.5,y:0)
lay.colors = [UIColor.green.cgColor, UIColor.red.cgColor]
}
override func layoutSubviews() {
super.layoutSubviews()
let shape = CAShapeLayer()
shape.frame = self.bounds
shape.strokeColor = UIColor.black.cgColor
shape.fillColor = UIColor.clear.cgColor
let b = UIBezierPath(ovalIn: shape.frame.inset(by: UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)))
shape.path = b.cgPath
shape.lineWidth = 10
shape.lineCap = .round
shape.strokeStart = 0.1
shape.strokeEnd = 0.9
shape.anchorPoint = CGPoint(x: 0.5, y: 0.5)
shape.transform = CATransform3DMakeRotation(-.pi/2, 0, 0, 1)
self.layer.mask = shape
}
}

Anybody on iOS 12 should go with Matt's suggestion or maybe use SFProgressCircle as DonMag suggested.
My solution:
I personally ended up adding two CAShapeLayers, each with a corresponding CAGradientLayer.
By programmatically splitting the slider in half as in the pic below, I was able to apply a top-to-bottom gradient on each side, which gives the effect I'm looking for. It's invisible to the user.

Related

Swift ios cut out rounded rect from view allowing colour changes

I'm using this approach to cut out a rounded rect "window" from a background view:
override func draw(_ rect: CGRect) {
guard let rectsArray = rectsArray else {
return
}
for holeRect in rectsArray {
let holeRectIntersection = rect.intersection(holeRect)
if let context = UIGraphicsGetCurrentContext() {
let roundedWindow = UIBezierPath(roundedRect: holeRect, cornerRadius: 15.0)
if holeRectIntersection.intersects(rect) {
context.addPath(roundedWindow.cgPath)
context.clip()
context.clear(holeRectIntersection)
context.setFillColor(UIColor.clear.cgColor)
context.fill(holeRectIntersection)
}
}
}
}
In layoutSubviews() I update the background colour add my "window frame" rect:
override func layoutSubviews() {
super.layoutSubviews()
backgroundColor = self.baseMoodColour
isOpaque = false
self.rectsArray?.removeAll()
self.rectsArray = [dragAreaView.frame]
}
I'm adding the rect here because layoutSubviews() updates the size of the "window frame" (i.e., the rect changes after layoutSubviews() runs).
The basic mechanism works as expected, however, if I change the background colour, the cutout window fills with black. So I'm wondering how I can animate a background colour change with this kind of setup? That is, I want to animate the colour of the area outside the cutout window (the window remains clear).
I've tried updating backgroundColor directly, and also using didSet in the accessor of a custom colour variable in my UIView subclass, but both cause the same filling-in of the "window".
var baseMoodColour: UIColor {
didSet {
self.backgroundColor = baseMoodColour
self.setNeedsDisplay()
}
}
Try to use UIView.animate, you can check it here
UIView.animate(withDuration: 1.0, delay: 0.0, options: [.curveEaseOut], animations: {
self.backgroundColor = someNewColour
//Generally
//myView.backgroundColor = someNewColor
}, nil)
The problem in the short run is that that is simply what clear does if the background color is opaque. Just give your background color some transparency — even a tiny bit of transparency, so tiny that the human eye cannot perceive it — and now clear will cut a hole in the view.
For example, your code works fine if you set the view's background color to UIColor.green.withAlphaComponent(0.99).
By the way, you should delete the lines about UIColor.clear; that's a red herring. You should also cut the lines about the backgroundColor; you should not be repainting the background color into your context. They are two different things.
The problem in the long run is that what you're doing is not how to punch a hole in a view. You should be using a mask instead. That's the only way you're going to get the animation while maintaining the hole.
Answering my own question, based on #matt's suggestion (and linked example), I did it with a CAShapeLayer. There was an extra "hitch" in my requirements, since I have a couple of views on top of the one I needed to mask out. So, I did the masking like this:
func cutOutWindow() {
// maskedBackgroundView is an additional view, inserted ONLY for the mask
let r = self.maskedBackgroundView.bounds
// Adjust frame for dragAreaView's border
var dragSize = self.dragAreaView.frame.size
var dragPosition = self.dragAreaView.frame.origin
dragSize.width -= 6.0
dragSize.height -= 6.0
dragPosition.x += 3.0
dragPosition.y += 3.0
let r2 = CGRect(x: dragPosition.x, y: dragPosition.y, width: dragSize.width, height: dragSize.height)
let roundedWindow = UIBezierPath(roundedRect: r2, cornerRadius: 15.0)
let mask = CAShapeLayer()
let path = CGMutablePath()
path.addPath(roundedWindow.cgPath)
path.addRect(r)
mask.path = path
mask.fillRule = kCAFillRuleEvenOdd
self.maskedBackgroundView.layer.mask = mask
}
Then I had to apply the colour change to maskedBackgroundView.layer.backgroundColor (i.e., to the layer, not the view). With that in place, I get the cutout I need, with animatable colour changes. Thanks #matt for pointing me in the right direction.

Add Gradient Layer to UIImageView in IOS thru CAGradientLayer

I am trying to add a Gradient in IOS like I did in Android. So I can see my label on top of the UIImageView and its not hidden.
In android I did this in a drawable `
<gradient
android:angle="90"
android:endColor="#00ffffff"
android:startColor="#aa000000"
android:centerColor="#00ffffff" />
<corners android:radius="0dp" />
`
I am trying to do this in IOS Swift 4.2 and I get the following :
let gradient: CAGradientLayer = CAGradientLayer()
gradient.colors = [UIColor.blue.cgColor, UIColor.red.cgColor]
gradient.locations = [0.0 , 1.0]
gradient.startPoint = CGPoint(x: 0.0, y: 1.0)
gradient.endPoint = CGPoint(x: 0.5, y: 0.5)
gradient.frame = CGRect(x: 0.0, y: 0.0, width: showImageView.frame.size.width, height: showImageView.frame.size.height)
showImageView.layer.addSublayer(gradient)
How do I get the Gradient to start black from the bottom and go up in 90 degrees?How do I change the opacity?Any idea?
A few thoughts:
Choose colors with alpha less than 1. Perhaps:
gradient.colors = [
UIColor.black.withAlphaComponent(0.8).cgColor,
UIColor.black.withAlphaComponent(0).cgColor
]
To have this gradient go vertically, choose start and end points that have the same x value. E.g. to cover bottom half, perhaps:
gradient.startPoint = CGPoint(x: 0.5, y: 1)
gradient.endPoint = CGPoint(x: 0.5, y: 0.5)
Be very careful about using frame. You want the layer to reference the bounds of the image view (using the image view’s coordinate system), not the frame (which specifies where the image view is in its superview’s coordinate system). If your image view happens to be at 0, 0, you might not see a difference, but if you move the image view around at all, these will start to deviate. So, assuming you’re adding this gradient to the image view, itself, you’d use the image view’s bounds:
gradient.frame = showImageView.bounds
Be aware that the frame/bounds of the image view can sometimes change. So, we will implement layoutSubviews in our UIImageView or UITableViewCell subclass, and update the gradient’s frame there, e.g.
override func layoutSubviews() {
super.layoutSubviews()
gradient.frame = bounds
}
That way it will update the gradient’s frame when the view’s layout changes.
The other solution is to define a UIView subclass, say GradientView, that renders the CAGradientLayer and define the base layerClass of that view to be a CAGradientLayer. Then add this view in between the image view and the label, define its constraints, and then you’ll have a gradient that changes dynamically as the GradientView size changes. (This layerClass approach has the advantage that it yields better animation/transitions than you’d get by just updating the frame programmatically.)
Thus, something like:
class GradientView: UIView {
override class var layerClass: AnyClass { return CAGradientLayer.self }
var gradientLayer: CAGradientLayer { return layer as! CAGradientLayer }
var firstColor: UIColor = UIColor.black.withAlphaComponent(0.8) {
didSet { updateColors() }
}
var secondColor: UIColor = UIColor.black.withAlphaComponent(0) {
didSet { updateColors() }
}
override init(frame: CGRect = .zero) {
super.init(frame: frame)
configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configure()
}
}
private extension GradientView {
func configure() {
updateColors()
gradientLayer.startPoint = CGPoint(x: 0.5, y: 1)
gradientLayer.endPoint = CGPoint(x: 0.5, y: 0.5)
}
func updateColors() {
gradientLayer.colors = [firstColor, secondColor].map { $0.cgColor }
}
}
If you really want your white text to pop, in addition to adding gradient over the image, you might also add a black glow/shadow to the text. It’s subtle, but really makes the text pop. Just make its shadow color the same color as the gradient.
So you can see the effect, here are four renditions of a cell, with (a) no gradient; (b) with gradient; (c) with gradient and nice gaussian blur around text; and (d) with simple shadow around text:
The nice, gaussian blur around the text is rendered with:
customLabel.layer.shadowColor = UIColor.black.cgColor
customLabel.layer.shadowRadius = 3
customLabel.layer.shadowOpacity = 1
customLabel.layer.masksToBounds = false
customLabel.layer.shouldRasterize = true
I think that third rendition (with gradient over the image, with glow around the text) is best. It’s subtle, but the text really pops. But gaussian blurs are computationally expensive, so if you find this adversely affects your performance too much, you can use the fourth option, with the simple, non-blurred shadow:
customLabel.shadowColor = .black
// perhaps also
// customLabel.shadowOffset = CGSize(width: -1, height: -1)

Adding CAShapeLayer to UIButton but it appears under the border line

I want to add a red dot to the border of a UIButton. My current code for adding a dot is like so:
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setUp()
}
override init(frame: CGRect) {
super.init(frame: frame)
setUp()
}
override func setUp() {
super.setUp()
layer.borderWidth = borderWidth
layer.borderColor = normalBorderColor.cgColor
let redDotLayer = CAShapeLayer()
redDotLayer.path = CGPath(ellipseIn: CGRect(x: 30, y: -3.5, width: 8, height: 8), transform: nil)
redDotLayer.fillColor = UIColor.red.cgColor
layer.addSublayer(redDotLayer)
}
However when I add the red dot it appears under the border line. I need it to be on top of the border line.
What am I doing wrong here?
Nothing wrong with your approach.
For example just consider adding subview to parent UIView, subview always stays inside the parent it can't be added above the parent.
Same scenario applies to CALayer . You can't addSublayer above parent layer.
As an exception,
Unlike views, a superlayer does not automatically clip the contents of sublayers that lie outside its bounds rectangle. Instead, the superlayer allows its sublayers to be displayed in their entirety by default. However, you can reenable clipping by setting the masksToBounds property of the layer to YES.
as per apple documentation sublayer can go beyond parent visible region, but not above the parent.
Even below methods won't help us.
- insertSublayer:atIndex:
- insertSublayer:above:
- insertSublayer:below:
addSublayer:
Appends the layer to the layer’s list of sublayers.
Solution
Draw another CAShapeLayer around the button.
CALayers may just draw the border around itself last. I think the easiest solution for you is just be to draw the border in a separate layer so that you can control the ordering.
override func setUp() {
super.setUp()
let borderLayer = CALayer()
borderLayer.borderWidth = borderWidth
borderLayer.borderColor = normalBorderColor.cgColor
layer.addSublayer(borderLayer)
let redDotLayer = CAShapeLayer()
redDotLayer.path = CGPath(ellipseIn: CGRect(x: 30, y: -3.5, width: 8, height: 8), transform: nil)
redDotLayer.fillColor = UIColor.red.cgColor
layer.addSublayer(redDotLayer)
}
In my code, I was unable to use another layer as a border so I've used another UIView as a border.
An important part is to set the border UIView isUserInteractionEnabled to false so the responder chain would pass it to the UIButton:
let borderView = UIView(frame: .zero)
borderView.backgroundColor = .clear
borderView.layer.borderWidth = 0.5
borderView.layer.borderColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.18).cgColor
borderView.layer.cornerRadius = 19
// Dont't forget to set it to false or else the button won't work
borderView.isUserInteractionEnabled = false

How to hide the CALayer's borders without causing rendering issue in iOS

I am having three UIView's one after the other horizontally, with each UiView.Layer.BorderColor set to Black and UiView.Layer.BorderWidth set to 0.25f like shown below. So this gives a border look for each of the view.
Now i have a requirement to only display the horizontal borders of the UiViews. Hence i create a mask layer and set path and frame to that mask layer to apply clip. Please refer the code snippet below to give you an idea of what am doing.
foreach (UIView UiView in myViews)
{
UiView.Layer.BorderColor = Color.Black.ToCGColor();
UiView.Layer.BorderWidth = 0.25f;
UIBezierPath maskPath = null;
CAShapeLayer maskLayer = new CAShapeLayer();
maskLayer.Frame = UiView.Bounds;
// Applying Clip to my layer
maskPath = UIBezierPath.FromRect(new CGRect(UiView.Bounds.Left + 0.5, UiView.Bounds.Top, UiView.Bounds.Width - 1, UiView.Bounds.Height));
maskLayer.Path = maskPath.CGPath;
view.Layer.Mask = maskLayer;
}
Yes i am aware that whatever the bounds i set for the maskPath is the Visible region, so given that each UiView is 60 in width , my visible region according to what i have written in code is , from 0.5 pixel upto 59.5 pixel of each UIView . Thus eliminating the borders present in the pixels from 0 to 0.5 and from 59.5 to 60. Hope this part is clear for you. Please refer the below image for a visual representation of my mask area. The Yellow borders denotes the mask bounds.
Now all is fine , but this 0.5 pixels border which is hidden causes a white space in my top and bottom borders of the UiView's put together continuously. Please refer the below image with the circled spots highlighting the void spaces in the top and bottom.
This can be easily reproduced in code too. Please suggest me on how to overcome this problem. What am i doing wrong. I know this is a very small blank space of half a pixel which is barely visible to the naked eye most of the times, but it will not be visually pleasing for my grid view application.
PS : Am developing in Xamarin.iOS , however native iOS solutions will also be helpful.
Thanks in advance
Try this for Swift.
class ViewController: UIViewController {
#IBOutlet weak var view1: UIView!
#IBOutlet weak var view2: UIView!
#IBOutlet weak var view3: UIView!
override func viewDidLoad() {
super.viewDidLoad()
var borderwidth = CGFloat()
borderwidth = 5
let topBorder = CALayer()
topBorder.backgroundColor = UIColor.black.cgColor
topBorder.frame = CGRect(x: self.view1.frame.origin.x, y: self.view1.frame.origin.y, width: self.view3.frame.origin.x + self.view3.frame.size.width - self.view1.frame.origin.x, height: borderwidth)
self.view.layer.addSublayer(topBorder)
let bottomBorder = CALayer()
bottomBorder.backgroundColor = UIColor.black.cgColor
bottomBorder.frame = CGRect(x: self.view1.frame.origin.x, y: self.view1.frame.origin.y + self.view1.frame.size.height - borderwidth, width: self.view3.frame.origin.x + self.view3.frame.size.width - self.view1.frame.origin.x, height: borderwidth)
self.view.layer.addSublayer(bottomBorder)
}
}
See this screenshot : 1
Masking is the problem. Try like this.
view.Layer.MasksToBounds = false;
view.Layer.AllowsEdgeAntialiasing = true;
layer.BorderColor = color.ToCGColor();
layer.BorderWidth = 0.5f;
if (layer.SuperLayer == null && layer.SuperLayer != view.Layer)
view.Layer.AddSublayer(layer);
view.Layer.BorderColor = Color.Transparent.ToCGColor();
layer.Frame = new CGRect(view.Bounds.Left, view.Bounds.Bottom - 0.5f, view.Bounds.Right, view.Bounds.Bottom);

Masking CAGradientLayer over CALayers

In my scene I have 2 views: first holds CALayer instances (bars), another hold CAGradientLayer and placed over first one. Picture below describes current state.
But I need this gradient to be applied only to bars (CALayer) of the first view.
I haven't found any relevant information to my problem. Any help appreciated.
You have to apply a mask to the gradient. There are various ways you could approach this problem.
You could create a CAShapeLayer, set the shape layer's path to the shape of the bars, and set the gradient layer's mask to that shape layer.
Or you could get rid of the bar layer and instead use two gradient layers, one for the orange bars and the other for the gray bars. Put both gradient layers in a subview, side-by-side, and set the superview's layer mask to the shape layer. Here's how to do that.
You'll need two gradient layers and a shape layer:
#IBDesignable
class BarGraphView : UIView {
private let orangeGradientLayer = CAGradientLayer()
private let grayGradientLayer = CAGradientLayer()
private let maskLayer = CAShapeLayer()
You'll also need the bar width:
private let barWidth = CGFloat(9)
At initialization time, set up the gradients and add all the sublayers:
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
private func commonInit() {
backgroundColor = .black
initGradientLayer(orangeGradientLayer, with: .orange)
initGradientLayer(grayGradientLayer, with: .gray)
maskLayer.strokeColor = nil
maskLayer.fillColor = UIColor.white.cgColor
layer.mask = maskLayer
}
private func initGradientLayer(_ gradientLayer: CAGradientLayer, with color: UIColor) {
gradientLayer.colors = [ color, color, color.withAlphaComponent(0.6), color ].map({ $0.cgColor })
gradientLayer.locations = [ 0.0, 0.5, 0.5, 1.0 ]
layer.addSublayer(gradientLayer)
}
At layout time, set the frames of the gradient layers and set the mask layer's path. This requires a little work because you don't want a bar to be half orange and half gray.
override func layoutSubviews() {
super.layoutSubviews()
let barCount = ceil(bounds.size.width / barWidth)
let orangeBarCount = floor(barCount / 2)
let grayBarCount = barCount - orangeBarCount
var grayFrame = bounds
grayFrame.size.width = grayBarCount * barWidth
grayFrame.origin.x = frame.maxX - grayFrame.size.width
grayGradientLayer.frame = grayFrame
var orangeFrame = bounds
orangeFrame.size.width -= grayFrame.size.width
orangeGradientLayer.frame = orangeFrame
maskLayer.frame = bounds
maskLayer.path = barPath()
}
private func barPath() -> CGPath {
var columnBounds = self.bounds
columnBounds.origin.x = columnBounds.maxX
columnBounds.size.width = barWidth
let path = CGMutablePath()
for datum in barData.reversed() {
columnBounds.origin.x -= barWidth
let barHeight = CGFloat(datum) * columnBounds.size.height
let barRect = columnBounds.insetBy(dx: 1, dy: (columnBounds.size.height - barHeight) / 2)
path.addRoundedRect(in: barRect, cornerWidth: 2, cornerHeight: 2)
}
return path
}
let barData: [Double] = {
let count = 100
return (0 ..< count).map({ 0.5 + (1 + sin(8.0 * .pi * Double($0) / Double(count))) / 4 })
}()
}
Result:
The BarGraphView is transparent wherever there are no bars. If you want it on a dark background, put a dark view behind it, or make it a subview of a dark view:

Resources