UIView transparent gradient - iphone

Given an arbitrary UIView on iOS, is there a way using Core Graphics (CAGradientLayer comes to mind) to apply a "foreground-transparent" gradient to it?
I can't use a standard CAGradientLayer because the background is more complex than a UIColor. I also can't overlay a PNG because the background will change as my subview is scrolled along its parent vertical scrollview (see image).
I have a non-elegant fallback: have my uiview clip its subviews and move a pre-rendered gradient png of the background as the parent scrollview is scrolled.

This was an embarrassingly easy fix: apply a CAGradientLayer as my subview's mask.
CAGradientLayer *gradientLayer = [CAGradientLayer layer];
gradientLayer.frame = _fileTypeScrollView.bounds;
gradientLayer.colors = [NSArray arrayWithObjects:(id)[UIColor whiteColor].CGColor, (id)[UIColor clearColor].CGColor, nil];
gradientLayer.startPoint = CGPointMake(0.8f, 1.0f);
gradientLayer.endPoint = CGPointMake(1.0f, 1.0f);
_fileTypeScrollView.layer.mask = gradientLayer;
Thanks to Cocoanetics for pointing me in the right direction!

This is how I'll do.
Step 1 Define a custom gradient view (Swift 4):
import UIKit
class GradientView: UIView {
override open class var layerClass: AnyClass {
return CAGradientLayer.classForCoder()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
let gradientLayer = self.layer as! CAGradientLayer
gradientLayer.colors = [
UIColor.white.cgColor,
UIColor.init(white: 1, alpha: 0).cgColor
]
backgroundColor = UIColor.clear
}
}
Step 2 - Drag and drop a UIView in your storyboard and set its custom class to GradientView
As an example, this is how the above gradient view looks like:
https://github.com/yzhong52/GradientViewDemo

I used the accepted (OP's) answer above and ran into the same issue noted in an upvoted comment - when the view scrolls, everything that started offscreen is now transparent, covered by the mask.
The solution was to add the gradient layer as the superview's mask, not the scroll view's mask. In my case, I'm using a text view, which is contained inside a view called contentView.
I added a third color and used locations instead of startPoint and endPoint, so that items below the text view are still visible.
let gradientLayer = CAGradientLayer()
gradientLayer.frame = self.contentView!.bounds
gradientLayer.colors = [UIColor.white.cgColor, UIColor.clear.cgColor, UIColor.white.cgColor]
// choose position for gradient, aligned to bottom of text view
let bottomOffset = (self.textView!.frame.size.height + self.textView!.frame.origin.y + 5)/self.contentView!.bounds.size.height
let topOffset = bottomOffset - 0.1
let bottomCoordinate = NSNumber(value: Double(bottomOffset))
let topCoordinate = NSNumber(value: Double(topOffset))
gradientLayer.locations = [topCoordinate, bottomCoordinate, bottomCoordinate]
self.contentView!.layer.mask = gradientLayer
Before, the text that started offscreen was permanently invisible. With my modifications, scrolling works as expected, and the "Close" button is not covered by the mask.

I just ran into the same issue and wound up writing my own class. It seems like serious overkill, but it was the only way I could find to do gradients with transparency. You can see my writeup and code example here
It basically comes down to a custom UIView that creates two images. One is a solid color, the other is a gradient that is used as an image mask. From there I applied the resulting image to the uiview.layer.content.
I hope it helps,
Joe

I hate to say it, but I think that you are into the CUSTOM UIView land. I think that I would try to implement this in a custom UIView overiding the drawRect routine.
With this, you could have that view, place on top of your actual scrollview, and have your gradient view (if you will) "pass-on" all touch events (i.e. relinquish first responder).

Related

Rotating UIControl with CAGradientLayer not updating correctly Swift

Rather than using a normal button, I subclassed a UIControl because I needed to add a gradient to it. I also have a way to add a shadow and an activity indicator (not visible in the image below) as a stateful button to stop users hammering the button if (for example) an API call is being made.
It was really tricky to try to get the UIControl to rotate, and to be able to do this I added the shadow as a separate view to a container view containing the UIControl so a shadow could be added.
Now the issue is the control does not behave quite like a view on rotation - let me show you a screen grab for context:
This is mid-rotation but is just about visible to the eye - the image shows that the Gradient is 75% of the length of a blue UIView in the image.
https://github.com/stevencurtis/statefulbutton
In order to perform this rotation I remove the shadowview and then change the frame of the gradient frame to its bounds, and this is the problem.
func viewRotated() {
CATransaction.setDisableActions(true)
shadowView!.removeFromSuperview()
shadowView!.frame = self.frame
shadowView!.layer.masksToBounds = false
shadowView!.layer.shadowOffset = CGSize(width: 0, height: 3)
shadowView!.layer.shadowRadius = 3
shadowView!.layer.shadowOpacity = 0.3
shadowView!.layer.shadowPath = UIBezierPath(roundedRect: self.bounds, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: 20, height: 20)).cgPath
shadowView!.layer.shouldRasterize = true
shadowView!.layer.rasterizationScale = UIScreen.main.scale
self.gradientViewLayer.frame = self.bounds
self.selectedViewLayer.frame = self.bounds
CATransaction.commit()
self.insertSubview(shadowView!, at: 0)
}
So this rotation method is called through the parent view controller:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { context in
context.viewController(forKey: UITransitionContextViewControllerKey.from)
//inform the loginButton that it is being rotated
self.loginButton.viewRotated()
}, completion: { context in
// can call here when completed the transition
})
}
I know this is the problem, and I guess it is not happening at quite the right time to act the same way as a UIView. Now the issue is that I have tried many things to get this to work, and my best solution (above) is not quite there.
It isn't helpful to suggest to use a UIButton, to use an image for the gradient (please don't suggest using a gradient image as a background for a UIButton, I've tried this) or a third party library. This is my work, it functions but does not work acceptably to me and I want to get it to work as well as a usual view (or at least know why not). I have tried the other solutions above as well, and have gone for my own UIControl. I know I can lock the view if there is an API call, or use other ways to stop the user pressing the button too many times. I'm trying to fix my solution, not invent ways of getting around this issue with CAGradientLayer.
The problem: I need to make a UIControlView with a CAGradientLayer as a background rotate in the same way as a UIView, and not exhibit the issue shown in the image above.
Full Example:
https://github.com/stevencurtis/statefulbutton
Here is working code:
https://gist.github.com/alldne/22d340b36613ae5870b3472fa1c64654
These are my recommendations to your code:
1. A proper place for setting size and the position of sublayers
The size of a view, namely your button, is determined after the layout is done. What you should do is just to set the proper size of sublayers after the layout. So I recommend you to set the size and position of the gradient sublayers in layoutSubviews.
override func layoutSubviews() {
super.layoutSubviews()
let center = CGPoint(x: self.bounds.width / 2, y: self.bounds.height / 2)
selectedViewLayer.bounds = self.bounds
selectedViewLayer.position = center
gradientViewLayer.bounds = self.bounds
gradientViewLayer.position = center
}
2. You don’t need to use an extra view to draw shadow
Remove shadowView and just set the layer properties:
layer.shadowOffset = CGSize(width: 0, height: 3)
layer.shadowRadius = 3
layer.shadowOpacity = 0.3
layer.shadowColor = UIColor.black.cgColor
clipsToBounds = false
If you have to use an extra view to draw shadow, then you can add the view once in init() and set the proper size and position in layoutSubviews or you can just programmatically set auto layout constraints to the superview.
3. Animation duration & timing function
After setting proper sizes, your animation of the gradient layers and the container view doesn’t sync well.
It seems that:
During the rotation transition, coordinator(UIViewControllerTransitionCoordinator) has its own transition duration and easing function.
And the duration and easing function are applied automatically to all the subviews (UIView).
However, those values are not applied to the CALayer without an associated UIView. Consequently, it uses the default timing function and duration of CoreAnimation.
To sync the animations, explicitly set the animation duration and the timing function like below:
class ViewController: UIViewController {
...
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
CATransaction.setAnimationDuration(coordinator.transitionDuration)
CATransaction.setAnimationTimingFunction(coordinator.completionCurve.timingFunction)
}
...
}
// Swift 4
extension UIView.AnimationCurve {
var timingFunction: CAMediaTimingFunction {
let functionName: CAMediaTimingFunctionName
switch self {
case .easeIn:
functionName = kCAMediaTimingFunctionEaseIn as CAMediaTimingFunctionName
case .easeInOut:
functionName = kCAMediaTimingFunctionEaseInEaseOut as CAMediaTimingFunctionName
case .easeOut:
functionName = kCAMediaTimingFunctionEaseOut as CAMediaTimingFunctionName
case .linear:
functionName = kCAMediaTimingFunctionLinear as CAMediaTimingFunctionName
}
return CAMediaTimingFunction(name: functionName as String)
}
}

Deleting layers with gradient properly

I have UICollectionViewCell with UIView called gradientView that is used as a background for labels on it.
This UIView needs to have gradient - it is light and almost transparent at the top of cell and becomes slightly darker in the bottom of the cell (simple linear gradient).
So I created func called addGradient that takes UIView and adds layer with gradient to it.
func addGradient(view : UIView){
view.backgroundColor = .clear
let color1 = somecolor1.cgColor
let color2 = somecolor2.cgColor
let gradient:CAGradientLayer = CAGradientLayer()
gradient.frame.size = view.frame.size
gradient.colors = [color1, color2]
view.layer.addSublayer(gradient)
}
Inside cellForItemAt I call addGradient(cell.gradientView) and the gradient is shown. But I have 2 problems:
1) each cell becomes darker and darker - it seems that layers are added one over another and I don't want it
2) sometimes this gradients are slighly misplaced - I also think that is because I don't delete this layers properly
So how and where I should clear sublayers and maybe my method of adding this gradient inside cellForItemAt is not right?
just a guess on number 1 without seeing more of your code: change frame to bounds
func addGradient(view : UIView){
view.backgroundColor = .clear
let color1 = somecolor1.cgColor
let color2 = somecolor2.cgColor
let gradient:CAGradientLayer = CAGradientLayer()
gradient.frame.size = view.bounds.size
gradient.colors = [color1, color2]
view.layer.addSublayer(gradient)
}

Borders not covering background

I've got a UILabel is using a border the same color as a background which it is half obscuring, to create a nice visual effect. However the problem is that there is still a tiny, yet noticeable, sliver of the label's background color on the OUTSIDE of the border.
The border is not covering the whole label!
Changing the border width doesn't change anything either, sadly.
Here's a picture of what's going on, enlarged so you can see it:
And my code follows:
iconLbl.frame = CGRectMake(theWidth/2-20, bottomView.frame.minY-20, 40, 40)
iconLbl.font = UIFont.fontAwesomeOfSize(23)
iconLbl.text = String.fontAwesomeIconWithName(.Info)
iconLbl.layer.masksToBounds = true
iconLbl.layer.cornerRadius = iconLbl.frame.size.width/2
iconLbl.layer.borderWidth = 5
iconLbl.layer.borderColor = topBackgroundColor.CGColor
iconLbl.backgroundColor = UIColor.cyanColor()
iconLbl.textColor = UIColor.whiteColor()
Is there something I'm missing?
Or am I going to have to figure out another to achieve this effect?
Thanks!
EDIT:
List of things I've tried so far!
Changing layer.borderWidth
Fussing around with clipsToBounds/MasksToBounds
Playing around the the layer.frame
Playing around with an integral frame
EDIT 2:
No fix was found! I used a workaround by extending this method on to my UIViewController
func makeFakeBorder(inputView:UIView,width:CGFloat,color:UIColor) -> UIView {
let fakeBorder = UIView()
fakeBorder.frame = CGRectMake(inputView.frame.origin.x-width, inputView.frame.origin.y-width, inputView.frame.size.width+width*2, inputView.frame.size.height+width*2)
fakeBorder.backgroundColor = color
fakeBorder.clipsToBounds = true
fakeBorder.layer.cornerRadius = fakeBorder.frame.size.width/2
fakeBorder.addSubview(inputView)
inputView.center = CGPointMake(fakeBorder.frame.size.width/2, fakeBorder.frame.size.height/2)
return fakeBorder
}
I believe this is the way a border is drawn to a layer in iOS. In the document it says:
When this value is greater than 0.0, the layer draws a border using the current borderColor value. The border is drawn inset from the receiver’s bounds by the value specified in this property. It is composited above the receiver’s contents and sublayers and includes the effects of the cornerRadius property.
One way to fix this is to apply a mask to a view's layer, but I found out that even if so we still can see a teeny tiny line around the view when doing snapshot tests. So to fix it more, I put this code to layoutSubviews
class MyView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
let maskInset: CGFloat = 1
// Extends the layer's frame.
layer.frame = layer.frame.inset(dx: -maskInset, dy: -maskInset)
// Increase the border width
layer.borderWidth = layer.borderWidth + maskInset
layer.cornerRadius = bounds.height / 2
layer.maskToBounds = true
// Create a circle shape layer with true bounds.
let mask = CAShapeLayer()
mask.path = UIBezierPath(ovalIn: bounds.inset(dx: maskInset, dy: maskInset)).cgPath
layer.mask = mask
}
}
CALayer's mask

Swift: Mask Alignment + Auto-Layout Constraints

I have this PNG file, which I'd like to use as a mask for a UIView.
The view must be:
20 pixels/points in from each side
A perfect square
Centered vertically
I set the following constraints to accomplish this:
However, it seems these constraints don't play well with masks. When these constraints and the mask property are set, I get the following:
but I'd like the view to look just like the mask above, except orange (The backgroundColor here is just for simplicity—I later add subviews that need to be masked.)
However, when no constraints are set, the mask seems to work properly and I get something like this (borderColor added for visual purposes only):
Here's my code (viewForLayer is a UIView I made in the storyboard):
viewForLayer.layer.borderColor = UIColor.redColor().CGColor
viewForLayer.layer.borderWidth = 10
var mask = CALayer()
mask.contents = UIImage(named: "TopBump")!.CGImage
mask.frame = CGRect(x: 0, y: 0, width: viewForLayer.bounds.width, height: viewForLayer.bounds.height)
mask.position = CGPoint(x: viewForLayer.bounds.width/2, y: viewForLayer.bounds.height/2)
viewForLayer.layer.mask = mask
viewForLayer.backgroundColor = UIColor.orangeColor()
The problem is though, that now the view isn't the right size or in the right position—it doesn't follow the rules above—"The view must be: ". How can I have the mask work properly, and the auto-layout constraints set at the same time?
I found a way around it. Not sure if this is the best way but here we go...
http://imgur.com/pUIZbNA
Just make sure you change the name of the UIView class in the storyboard inspector too. Apparently, the trick is to set the mask frame for each layoutSubviews call.
class MaskView : UIView {
override func layoutSubviews() {
super.layoutSubviews()
if let mask = self.layer.mask {
mask.frame = self.bounds
}
}
}
class ViewController: UIViewController {
#IBOutlet weak var viewForLayer: MaskView!
override func viewDidLoad() {
super.viewDidLoad()
let image = UIImage(named: "TopBump")!.CGImage!
let maskLayer = CALayer()
maskLayer.contents = image
maskLayer.frame = viewForLayer.bounds
viewForLayer.layer.mask = maskLayer
viewForLayer.backgroundColor = UIColor.orangeColor()
// Do any additional setup after loading the view, typically from a nib.
viewForLayer.layer.borderColor = UIColor.redColor().CGColor
viewForLayer.layer.borderWidth = 10
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
}
I tried it for myself. Minus the nitpicking on 'let mask = CALayer()' (it's immutable reference to an updatable object), changing the autolayout constraints of the embedded view shows the mask is aligned correctly.
NSLog("\(viewForLayer.bounds.width), \(viewForLayer.bounds.height)")
returns 375.0, 667.0 on an iPhone 6 screen. What are you getting?

Mask a View with the alpha from another view in Swift

I've been struggling for a view hours to figure out how to get my view masked by a shape that is in another view. Basically I have a circular countdown timer that I want to be masked out by an animating circle that scales up from the center of the timer when the timer is reset.
I tried setting timerMask.maskView = timerCircleGrahics where timerCircleGraphics is the name of my timer animation view. But this is giving me very strange results when I test the app. It seems to clip the view to the rectangle bounds of my mask view rather than the alpha of the bounds that are drawn within that view. The mask layer is centered and being drawn properly, but I've never attempted this before so am not sure if I am doing it right.
Here is the class for my mask shape:
class timerBackgroundMask: UIView {
override func drawRect(rect: CGRect) {
var ovalPath = UIBezierPath(ovalInRect: CGRectMake(0, 0, 238, 238))
colorGreen.setFill()
ovalPath.fill()
}
}
Then using IB, I assign this mask to a manually placed View in my Storyboard called timerMask. I am realizeing now that by assigning timerBackgroundMask class to timerMask I have programatically added a subview to my manually placed Storyboard view, but I feel like the alpha should come through just the same when set this view to mask out anotherview. Here is the code i use to set the mask
timerCircleGraphics.layer.mask = timerMask.layer
The result I am getting is pretty weird:
The red portion should be a circle that is partially clipped by my timerMask from the center outward. The light green circle that you see is simply the background view of the counter, however it happens to be the exact position and size as my timerBackgroundMask for reference.
Don't bother creating a class and using the Storyboard. Do it straight in code, the simple way:
var ovalPath = UIBezierPath(ovalInRect: CGRectMake(0, 0, 238, 238))
colorGreen.setFill()
ovalPath.fill()
var mask = CAShapeLayer()
mask.path = ovalPath.CGPath
timerCircleGraphics.layer.mask = mask