I have a blue view with a white dashed border. As you can see from the image below, once the app is rotated the view changes its dimensions and the border doesn't adjust to the view's new width and height.
I need to find a way to
Know when the view controller changes its size - perhaps using viewWillTransitionToSize.
Delete the previously drawn border, if any.
Add a new border to the view - in the view's drawRect method.
How can I delete the border previously drawn on the view?
#IBOutlet weak var myView: UIView!
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
coordinator.animateAlongsideTransition(nil, completion: {
_ in
self.myView.setNeedsDisplay()
})
}
class RenderView: UIView {
override func drawRect(rect: CGRect) {
self.addDashedBorder()
}
}
extension UIView {
func addDashedBorder() {
let color = UIColor.whiteColor().CGColor
let shapeLayer:CAShapeLayer = CAShapeLayer()
let frameSize = self.frame.size
let shapeRect = CGRect(x: 0, y: 0, width: frameSize.width, height: frameSize.height)
shapeLayer.bounds = shapeRect
shapeLayer.position = CGPoint(x: frameSize.width/2, y: frameSize.height/2)
shapeLayer.fillColor = UIColor.clearColor().CGColor
shapeLayer.strokeColor = color
shapeLayer.lineWidth = 6
shapeLayer.lineJoin = kCALineJoinRound
shapeLayer.lineDashPattern = [6,3]
shapeLayer.path = UIBezierPath(roundedRect: shapeRect, cornerRadius: 5).CGPath
self.layer.addSublayer(shapeLayer)
}
}
Download sample project here.
This is not the right way to do things. Each time your RenderView is redrawn, its drawRect method will be called, and addDashedBorder will add a new layer to your view.
drawRect is for drawing inside of your view using CoreGraphics or UIKit, not CoreAnimation. Inside that method, you should just draw, nothing else. If you want to use it a layer, layoutSubviews is a better place to add it, and to update it to match the view.
Here are two ways to solve your problem. Both update the border correctly, and animate the border smoothly when the device is rotated.
Alternative 1: Just draw the border in drawRect, rather than using a separate shape layer. Also, set your view's contentMode so it automatically redraws when its size changes.
class ViewController: UIViewController {
#IBOutlet weak var myView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
self.myView.contentMode = .Redraw
}
}
class RenderView: UIView {
override func drawRect(rect: CGRect) {
let path = UIBezierPath(roundedRect: self.bounds, cornerRadius: 5)
path.lineWidth = 6
let pattern: [CGFloat] = [6.0, 3.0]
path.setLineDash(pattern, count: 2, phase: 0)
UIColor.whiteColor().setStroke()
path.stroke()
}
}
Alternative 2: Continue to use CAShapeLayer, but only create a single one, by using a lazy stored property. Update the layer in an override of layoutSubviews, and if necessary, animate it alongside any changes in the view's bounds.
class ViewController: UIViewController {
#IBOutlet weak var myView: UIView!
}
class RenderView: UIView {
// Create the borderLayer, and add it to our view's layer, on demand, only once.
lazy var borderLayer: CAShapeLayer = {
let shapeLayer = CAShapeLayer()
shapeLayer.fillColor = nil
shapeLayer.strokeColor = UIColor.whiteColor().CGColor
shapeLayer.lineWidth = 6
shapeLayer.lineDashPattern = [6,3]
self.layer.addSublayer(shapeLayer)
return shapeLayer
}()
override func layoutSubviews() {
super.layoutSubviews()
// We will update the borderLayer's path to match the view's current bounds.
let newPath = UIBezierPath(roundedRect: self.bounds, cornerRadius: 5).CGPath
// We may be animating from the old bounds to the new bounds.
// If so, we want the borderLayer to animate alongside that.
// (UIView does not do this automatically, since it does not know
// anything about our borderLayer; it's at the the CoreAnimation level,
// below UIKit.)
//
// We want an animation that uses the same properties as the existing
// animation, but applies to a different value: the borderLayer's path.
// We'll find the existing animation on the view's bounds.size,
// and if it exists, add our own animation based on it that will
// apply the path change.
if let viewBoundsAnimation = self.layer.animationForKey("bounds.size") {
let pathAnimation = CABasicAnimation()
pathAnimation.beginTime = viewBoundsAnimation.beginTime
pathAnimation.duration = viewBoundsAnimation.duration
pathAnimation.speed = viewBoundsAnimation.speed
pathAnimation.timeOffset = viewBoundsAnimation.timeOffset
pathAnimation.timingFunction = viewBoundsAnimation.timingFunction
pathAnimation.keyPath = "path"
pathAnimation.fromValue = borderLayer.path
pathAnimation.toValue = newPath
borderLayer.addAnimation(pathAnimation, forKey: "path")
}
// Finally, whether we are animating or not, make the border layer show the new path.
// If we are animating, this will appear when the animation is finished.
// If we are not animating, this will appear immediately.
self.borderLayer.path = newPath
}
}
Note that neither of these alternatives require overriding viewWillTransitionToSize or traitCollectionDidChange. Those are higher-level UIViewController concepts, that may get called during device rotation, but won't happen if some other code changes your view's size. It's better to use the simple UIView-level drawRect or layoutSubviews methods, because they will always work.
Call set needs display when the view transitions.You can detect it by using this function.It works perfectly to detect orientation change
override func traitCollectionDidChange(previousTraitCollection: UITraitCollection?) {
self.myView.setNeedsDisplay()
}
Related
What I am trying to do is to get the position of my label (timerLabel) in order to pass those coordinates to UIBezierPath (so that the center of the shape and the center of the label coincide).
Here's my code so far, inside the viewDidLoad method, using Xcode 13.2.1:
// getting the center of the label
let center = CGPoint.init(x: timerLabel.frame.midX , y: timerLabel.frame.midY)
// drawing the shape
let trackLayer = CAShapeLayer()
let circularPath = UIBezierPath(arcCenter: center, radius: 100, startAngle: -CGFloat.pi / 2, endAngle: 2 * CGFloat.pi, clockwise: true)
trackLayer.path = circularPath.cgPath
trackLayer.strokeColor = UIColor.lightGray.cgColor
trackLayer.lineWidth = 10
trackLayer.fillColor = UIColor.clear.cgColor
and this is what I have when I run my app:
link
What I don't understand is why I get (0,0) as coordinates even though I access the label's property (timerLabel.frame.midX).
The coordinates of your label may vary depending on current layout. You need to track all changes and reposition your circle when changes occur. In view controller that uses constraints you would override
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// recreate your circle here
}
this alone does not explain why your circle is so far out. First of all, looking at your image you do not get (0, 0) but some other value which may be relative position of your label within the blue bubble. The frame is always relative to its superview so you need to convert that into your own coordinate system:
let targetView = self.view!
let sourceView = timerLabel!
let centerOfSourceViewInTargetView: CGPoint = targetView.convert(CGPoint(x: sourceView.bounds.midX, y: sourceView.bounds.midY), to: targetView)
// Use centerOfSourceViewInTargetView as center
but I suggest using neither of the two. If you are using constraints (which you should) then rather create more views than adding layers to your existing views.
For instance you could try something like this:
#IBDesignable class CircleView: UIView {
#IBInspectable var lineWidth: CGFloat = 10 { didSet { refresh() } }
#IBInspectable var strokeColor: UIColor = .lightGray { didSet { refresh() } }
override var frame: CGRect { didSet { refresh() } }
override func layoutSubviews() {
super.layoutSubviews()
refresh()
}
override func draw(_ rect: CGRect) {
super.draw(rect)
let fillRadius: CGFloat = min(bounds.width, bounds.height)*0.5
let strokeRadius: CGFloat = fillRadius - lineWidth*0.5
let path = UIBezierPath(ovalIn: .init(x: bounds.midX-strokeRadius, y: bounds.midY-strokeRadius, width: strokeRadius*2.0, height: strokeRadius*2.0))
path.lineWidth = lineWidth
strokeColor.setStroke()
UIColor.clear.setFill() // Probably not needed
path.stroke()
}
private func refresh() {
setNeedsDisplay() // This is to force redraw
}
}
this view should draw your circle within itself by overriding draw rect method. You can easily use it in your storyboard (first time it might not draw in storyboard because Xcode. Simply close your project and reopen it and you should see the circle even in storyboard).
Also in storyboard you can directly modify both line width and stroke color which is very convenient.
About the code:
Using #IBDesignable to see drawing in storyboard
Using #IBInspectable to be able to set values in storyboard
Refreshing on any value change to force redraw (sometimes needed)
When frame changes forcing a redraw (Needed when setting frame from code)
A method layoutSubviews is called when resized from constraints. Again redrawing.
Path is computed so that it fits within the size of view.
I have all ready tried tried
extension UIView {
#discardableResult
func addLineDashedStroke(pattern: [NSNumber]?, radius: CGFloat, color: CGColor) -> CALayer {
let borderLayer = CAShapeLayer()
borderLayer.strokeColor = color
borderLayer.lineDashPattern = pattern
borderLayer.frame = bounds
borderLayer.fillColor = nil
borderLayer.path = UIBezierPath(roundedRect: bounds, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: radius, height: radius)).cgPath
layer.addSublayer(borderLayer)
return borderLayer
}
}
I am not able to figure out the use of pattern?
That extension adds a function to UIView that creates and returns a dashed border CAShapeLayer. It doesn't do anything with the layer. If you just call the function and don't do anything with the layer it returns, you won't see any change.
I was able to use the extension in your question to add a dashed, rounded-corner border around a view quite easily:
Here is a custom subclass of UIViewController I implemented in a test app (I added a UIView in the storyboard and linked it to the IBOutlet dashedBorderView:
class ViewController: UIViewController {
#IBOutlet weak var dashedBorderView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
override func viewWillAppear(_ animated: Bool) {
let dashedBorderLayer = dashedBorderView.addLineDashedStroke(pattern: [2, 5], radius: 10, color: UIColor.black.cgColor)
dashedBorderView.layer.addSublayer(dashedBorderLayer)
}
}
That code creates a bordered view that looks like this:
I want to shape a UIView and be able to see its shape in the Interface Builder, when I set the following class to my UIView it fails to build, I'm not sure where's the error.
#IBDesignable
class CircleExampleView: UIView {
override func layoutSubviews() {
setupMask()
}
func setupMask() {
let path = makePath()
// mask the whole view to that shape
let mask = CAShapeLayer()
mask.path = path.cgPath
self.layer.mask = mask
}
private func makePath() -> UIBezierPath {
//// Oval Drawing
let ovalPath = UIBezierPath(ovalIn: CGRect(x: 11, y: 12, width: 30, height: 30))
UIColor.gray.setFill()
ovalPath.fill()
return ovalPath
}
}
A couple of observations:
You are calling setFill followed by fill, but you only do that if drawing into a graphics context (e.g., in draw(_:)). When using CAShapeLayer, you should instead set the fillColor of the CAShapeLayer.
You are using the CAShapeLayer to set the mask of your view. If your UIView doesn't have a discernible backgroundColor, you won't see anything.
If you set the background color of the view to, say, blue, as shown below, your mask will reveal that blue background wherever the mask allows it to (in the oval of your path).
You have implemented layoutSubviews. You generally would do that only if you were doing something here that was contingent upon the bounds of the view. For example, here's a rendition where the oval path is based upon the bounds of the view:
#IBDesignable
class CircleView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
setupMask()
}
private func setupMask() {
let mask = CAShapeLayer()
mask.path = path.cgPath
mask.fillColor = UIColor.gray.cgColor
layer.mask = mask
}
private var path: UIBezierPath {
return UIBezierPath(ovalIn: bounds)
}
}
As E. Coms said, if you override layoutSubviews, you really should call the super implementation. This isn't critical, as the default implementation actually does nothing, but it's best practice. E.g. if you later changed this class to subclass some other UIView subclass, you don't want to have to go to revisit all these overrides.
If you have a designable view, it's advisable to put that in a separate target. That way, the rendering of the view in the storyboard is not dependent upon any work that may be underway in the main project. As long as the designables target (often the name of your main target with Kit suffix) can build, the designable view can be rendered.
For example, here is a rendition of your designable view, in a separate framework target, and used in a storyboard where the view in question has a blue backgroundColor:
For what it's worth, I think it's exceedingly confusing to have to mask to reveal the background color inside the oval. An app developer has to set "background" color in order to set what's inside the oval, but not the background.
I might instead remove the "mask" logic and instead give the designable view an inspectable property, fillColor, and just add a CAShapeLayer as a sublayer, using that fillColor:
#IBDesignable
class CircleView: UIView {
private var shapeLayer = CAShapeLayer()
#IBInspectable var fillColor: UIColor = .blue {
didSet {
shapeLayer.fillColor = fillColor.cgColor
}
}
override init(frame: CGRect = .zero) {
super.init(frame: frame)
configure()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
configure()
}
private func configure() {
shapeLayer.fillColor = fillColor.cgColor
layer.addSublayer(shapeLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
shapeLayer.path = UIBezierPath(ovalIn: bounds).cgPath
}
}
This accomplishes the same thing, but I think the distinction of fill colors vs background colors is more intuitive. But you may have had other reasons for using the masking approach, but just make sure if you do that, that you have something to reveal after it’s masked (e.g. a background color or something else you’re rendering).
Please add "super.layoutSubviews()"
override func layoutSubviews() {
setupMask()
super.layoutSubviews()
}
By this way, your design view will be "up to date" .
How to create this kind of UI?
So far, I end up creating the same UI like below.
I am not sure, how to curve that yellow colored border as in the above reference.
Simply adding a border layout and adding a mask you can achieve what you need
Full Example (only relevant code)
import UIKit
class ViewController: UIViewController {
#IBOutlet weak var tableView: UITableView!
var selectedIndex : Int = -1
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
self.tableView.layer.borderColor = UIColor.red.cgColor
self.tableView.layer.borderWidth = 3
}
func bezierPathWithShape(rect:CGRect,cornerRadius:CGFloat) ->UIBezierPath
{
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
return path
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let mask = CAShapeLayer(layer: self)
mask.path = self.bezierPathWithShape(rect: self.tableView.bounds, cornerRadius: 15).cgPath
self.tableView.layer.mask = mask
self.tableView.layer.masksToBounds = true
}
}
Result
Here is another way to do it.
Take a view which is superView to both the tableView and the lines.
Add cornerRadius, then set clipToBounds = true for the superView.
It will clip the lines according to the cornerRadius.
I'm trying to add a CAShapeLayer to a custom UIViews layer, which is going well, except for the fact that it's offsetting itself...
It's probably best I explain with code and screenshots.. So here it goes:
All I'm trying to achieve is a simple "slash" like CAShapeLayer in Swift 3.
in the custom UIView I have:
var slashLayer: CAShapeLayer?
var slashPath: CGPath?
override func layoutSublayers(of layer: CALayer) {
if layer == self.layer {
if slashLayer == nil {
slashLayer = CAShapeLayer()
slashLayer?.bounds = layer.bounds
slashLayer?.strokeColor = UIColor.black.cgColor
slashLayer?.fillColor = UIColor.black.cgColor
slashLayer?.lineWidth = 3.0
let slashPath = UIBezierPath()
slashPath.move(to: CGPoint(x: layer.frame.minX, y: layer.frame.minY))
slashPath.addLine(to: CGPoint(x: layer.frame.maxX, y: layer.frame.maxY))
slashPath.close()
slashLayer?.path = slashPath.cgPath
layer.addSublayer(slashLayer!)
}
}
}
And that's it in the UIView subclass.
This is what I wind up with in Interface Builder (and the App when I fire it up)..
As CALayer.layoutManager isn't around any more, how do I keep the layers in line with one-another?