I'm adding borders to views like this:
extension UIView {
func addTopBorderWithColor(color: UIColor, width: CGFloat) {
let border = CALayer()
border.backgroundColor = color.cgColor
border.frame = CGRect(x:0,y: 0, width:self.frame.size.width, height:width)
self.layer.addSublayer(border)
}
}
in viewDidLoad:
textLbl.addTopBorderWithColor(color: UIColor.white, width: 1)
in viewWillAppear:
textLbl.text = "placeholder text \(variableLengthText)"
How can I dismiss the border I've drawn using the above extension?
In other views (for instance a ToolBar) as a sloppy hack, I would just write over the border with one matching the background color of the object so it didn’t overlap (for instance on a screen orientation change)
Well, that was always wrong, so the first thing to do is to stop doing it.
This has nothing to do with viewDidDisappear or viewWillAppear. The signal that the view is changing size is that its layoutSubviews is called. So you could implement layoutSubviews to remove the border layer and add a new one at the new size.
But it would be even better to add a subview to the label, consisting of a clear background except for the border. That way, the subview could be configured using constraints to grow and shrink together with the label automatically with no need for any code. Here's a screencast demonstrating that implementation; the red line is the right border:
While #matt's storyboard solution looks elegant, I'm unable to implement it. I did however get it to work by setting the layer.name field in the extension and checking for it in ViewWillLayoutSubviews
extension UIView {
func addTopBorderWithColor(color: UIColor, width: CGFloat) {
let border = CALayer()
border.backgroundColor = color.cgColor
border.frame = CGRect(x:0,y: 0, width:self.frame.size.width, height:width)
border.name = "topBorder"
self.layer.addSublayer(border)
}
In ViewWillLayoutSubviews I call:
if self.textLbl.layer.sublayers != nil {
for layer in textLbl.layer.sublayers! {
if layer.name == "rightBorder" || layer.name == "leftBorder" || layer.name == "topBorder" {
layer.removeFromSuperlayer()
}
}
}
In ViewDidLayoutSubviews I call:
textLbl.addTopBorderWithColor(color: UIColor.white, width: 1.5)
Related
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)
}
}
My view controller possesses a stack view with 3 buttons. One fixed in the center with a fixed width and the stackview set to fill proportionally such that the other two buttons will be symmetrical.
However I am also customizing the corner radius of the buttons and as soon as the application loads the button resizes in an undesired fashion.
Ive attempted numerous stackview distribution and fill settings. Removing the buttons from the stackview and simply trying to contraint them to edges on a normal UIView to no avail as it seems, but uncertain if, the constraints get deleted.
Visually the button will be located at the bottom right hand corner of the screen with 0 space between the edge and the button. Currently it gets laid out in a manner where there is no constraint it seems on multiple devices causing it to have a space on larger displays, and exit the screen on small displays within the simulator.
Attempted coding efforts to round the desired corners:
#IBOutlet fileprivate weak var button: UIButton! { didSet {
button.round(corners: [.topRight, .bottomLeft], radius: 50 , borderColor: UIColor.blue, borderWidth: 5.0)
button.setNeedsUpdateConstraints()
button.setNeedsLayout()
} }
override func viewDidLayoutSubviews() {
button.layoutIfNeeded()
}
ovverride func updateViewContraints() {
button.updateConstraintsIfNeeded()
super.updateViewConstraints()
}
The UIView extension that is being used can be found here:
https://stackoverflow.com/a/35621736/5434541
What solution is available to properly adjust the buttons corner radius' and allow the constraints to update the button to as it should be.
Without seeing your full code, I suspect you're getting this because it's drawing multiple layers with the border, and never removing any! If the buttons get resized, the old layers are still there. Here's a snip that shows removing the old layer on redraw that you should be able to adapt. I tested this inside a stack view, and it also behaves correctly on rotation:
class RoundedCorner: UIButton {
var lastBorderLayer:CAShapeLayer?
override func layoutSubviews() { setup() }
func setup() {
let r = self.bounds.size.height / 2
let path = UIBezierPath(roundedRect: self.bounds,
byRoundingCorners: [.topLeft, .bottomRight],
cornerRadii: CGSize(width: r, height: r))
let mask = CAShapeLayer()
mask.path = path.cgPath
addBorder(mask: mask, borderColor: UIColor.blue, borderWidth: 5)
self.layer.mask = mask
}
func addBorder(mask: CAShapeLayer, borderColor: UIColor, borderWidth: CGFloat) {
let borderLayer = CAShapeLayer()
borderLayer.path = mask.path
borderLayer.fillColor = UIColor.clear.cgColor
borderLayer.strokeColor = borderColor.cgColor
borderLayer.lineWidth = borderWidth
borderLayer.frame = bounds
if let last = self.lastBorderLayer, let index = layer.sublayers?.index(of: last) {
layer.sublayers?.remove(at: index)
}
layer.addSublayer(borderLayer)
self.lastBorderLayer = borderLayer
}
}
You also noted in your comment about the app lag. I'm not surprised, since layout can get called many times per update, and you're creating a new layer every single time. You can avoid this by saving the last dimensions of the botton frame and comparing. If it's identical, don't recreate the layer.
I want to give an imageView a shadow at the same time with rounded corners,but I failed.
Here is my solution
Basic idea :
Use an Extra view (say AView) as super view of image view (to those views on which you are willing to have shado) and assign that view class to DGShadoView
Pin Image view to AView (that super view)from left, right, top and bottom with constant 5
Set back ground color of the AView to clear color from storybosrd's Property inspector this is important
Inside idea: Here we are using a Bezier path on the Aview nearly on border and setting all rounded corner properties and shadow properties to that path and we are placing our target image view lie with in that path bound
#IBDesignable
class DGShadoView:UIView {
override func draw(_ rect: CGRect) {
self.rect = rect
decorate(rect: self.rect)
}
func decorate(rect:CGRect) {
//self.backgroundColor = UIColor.clear
//IMPORTANT: dont forgot to set bg color of your view to clear color from story board's property inspector
let ref = UIGraphicsGetCurrentContext()
let contentRect = rect.insetBy(dx: 5, dy: 5);
/*create the rounded oath and fill it*/
let roundedPath = UIBezierPath(roundedRect: contentRect, cornerRadius: 5)
ref!.setFillColor("your color for background".cgColor)
ref!.setShadow(offset: CGSize(width:0,height:0), blur: 5, color: "your color for shado".cgColor)
roundedPath.fill()
/*draw a subtle white line at the top of view*/
roundedPath.addClip()
ref!.setStrokeColor(UIColor.red.cgColor)
ref!.setBlendMode(CGBlendMode.overlay)
ref!.move(to: CGPoint(x:contentRect.minX,y:contentRect.minY+0.5))
ref!.addLine(to: CGPoint(x:contentRect.maxX,y:contentRect.minY+0.5))
}
}
Update
Extension Approach
There is another Approach. Just Make a class with empty and paste Following UIImageView Extension code, Assign this subclass to that ImageView on which you shadow.
import UIKit
class DGShadowView: UIImageView {
#IBInspectable var intensity:Float = 0.2{
didSet{
setShadow()
}
}
override func layoutSubviews()
{
super.layoutSubviews()
setShadow()
}
func setShadow(){
let shadowPath = UIBezierPath(rect: bounds)
layer.masksToBounds = false
layer.shadowColor = UIColor.black.cgColor
layer.shadowOffset = CGSize(width: 0.0, height: 0.3)
layer.shadowOpacity = intensity
layer.shadowPath = shadowPath.cgPath
}
}
The solution is to create two separate views. One for the shadow and one for the image itself. On the imageView you clipToBounds the layer so that the corner radius is properly added.
Put the imageView on top of the shadowView and you've got your solution!
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
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?