I am trying to draw and animate a circular button using CAShapeLayer but just the drawing gives me a lot of headache - I can't seem to figure out how to pass data into my class.
This is my setup:
- a class of type UIView which will draw the CAShapeLayer
- the view is rendered in my view controller and built using auto layout constraints
I have tried using layoutIfNeeded but seem to be passing the data too late for the view to be drawn. I have also tried redrawing the view in vieWillLayoutSubviews() but nothing. Example code below. What am I doing wrong?
Am I passing the data too early/too late?
Am I drawing the bezierPath too late?
I'd highly appreciate pointers.
And maybe a second follow up question: is there a simpler way to draw a circular path that is bound to it's views size?
In my View Controller:
import UIKit
class ViewController: UIViewController {
let buttonView: CircleButton = {
let view = CircleButton()
view.backgroundColor = .black
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
override func viewWillLayoutSubviews() {
}
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(buttonView)
buttonView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
buttonView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
buttonView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.75).isActive = true
buttonView.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.25).isActive = true
buttonView.layoutIfNeeded()
buttonView.arcCenter = buttonView.center
buttonView.radius = buttonView.frame.width/2
}
override func viewDidAppear(_ animated: Bool) {
print(buttonView.arcCenter)
print(buttonView.radius)
}
}
And the class for the buttonView:
class CircleButton: UIView {
//Casting outer circular layers
let trackLayer = CAShapeLayer()
var arcCenter = CGPoint()
var radius = CGFloat()
//UIView Init
override init(frame: CGRect) {
super.init(frame: frame)
}
//UIView post init
override func layoutSubviews() {
super.layoutSubviews()
print("StudyButtonView arcCenter \(arcCenter)")
print("StudyButtonView radius \(radius)")
layer.addSublayer(trackLayer)
let outerCircularPath = UIBezierPath(arcCenter: arcCenter, radius: radius, startAngle: 0, endAngle: 2*CGFloat.pi, clockwise: true)
trackLayer.path = outerCircularPath.cgPath
trackLayer.strokeColor = UIColor.lightGray.cgColor
trackLayer.lineWidth = 5
trackLayer.strokeStart = 0
trackLayer.strokeEnd = 1
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.transform = CATransform3DMakeRotation(-CGFloat.pi/2, 0, 0, 1)
}
//Required for subclass
required init?(coder aDecoder: NSCoder) {
fatalError("has not been implemented")
}
}
There really isn't any correlation between auto-layout and the proper implementation of your CircleButton class. Your CircleButton class doesn't know or care whether it's being configured via auto-layout or whether it has some fixed size.
Your auto-layout code looks OK (other than points 5 and 6 below). Most of the issues in your code snippet rest in your CircleButton class. A couple of observations:
If you're going to rotate the shape layer, you have to set its frame, too, otherwise the size is .zero and it's going to end up rotating it about the origin of the view (and rotate outside of the bounds of the view, especially problematic if you're clipping subviews). Make sure to set the frame of the CAShapeLayer to be the bounds of the view before trying to rotate it. Frankly, I'd remove the transform, but given that you're playing around with strokeStart and strokeEnd, I'm guessing you may want to change these values later and have it start at 12 o'clock, in which case the transform makes sense.
Bottom line, if rotating, set the frame first. If not, setting the layer's frame is optional.
If you're going to change the properties of the view in order to update the shape layer, you'll want to make sure that the didSet observers do the appropriate updating of the shape layer (or call setNeedsLayout). You don't want your view controller from having to mess around with the internals of the shape layer, but you also want to make sure that these changes do get reflected in the shape layer.
It's a minor observation, but I'd suggest adding the shape layer during init and only configuring and adding it to the view hierarchy once. This is more efficient. So, have the various init methods call your own configure method. Then, do size-related stuff (like updating the path) in layoutSubviews. Finally, have properties observers that update the shape layer directly. This division of labor is more efficient.
If you want, you can make this #IBDesignable and put it in its own target in your project. Then you can add it right in IB and see what it will look like. You can also make all the various properties #IBInspectable, and you'll be able to set them right in IB, too. You then don't have to do anything in the code of your view controller if you don't want to. (But if you want to, feel free.)
A minor issue, but when you add your view programmatically, you don't need to call buttonView.layoutIfNeeded(). You only need to do that if you're animating constraints, which you're not doing here. Once you add the constraints (and fix the above issues), the button will be laid out correctly, with no explicit layoutIfNeeded required.
Your view controller has a line of code that says:
buttonView.arcCenter = buttonView.center
That is conflating arcCenter (which is a coordinate within the buttonView's coordinate space) and buttonView.center (which is the coordinate for the button's center within the view controller's root view's coordinate space). One has nothing to do with the other. Personally, I'd get rid of this manual setting of arcCenter, and instead have layoutSubviews in ButtonView take care of this dynamically, using bounds.midX and bounds.midY.
Pulling that all together, you get something like:
#IBDesignable
class CircleButton: UIView {
private let trackLayer = CAShapeLayer()
#IBInspectable var lineWidth: CGFloat = 5 { didSet { updatePath() } }
#IBInspectable var fillColor: UIColor = .clear { didSet { trackLayer.fillColor = fillColor.cgColor } }
#IBInspectable var strokeColor: UIColor = .lightGray { didSet { trackLayer.strokeColor = strokeColor.cgColor } }
#IBInspectable var strokeStart: CGFloat = 0 { didSet { trackLayer.strokeStart = strokeStart } }
#IBInspectable var strokeEnd: CGFloat = 1 { didSet { trackLayer.strokeEnd = strokeEnd } }
override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
configure()
}
private func configure() {
trackLayer.fillColor = fillColor.cgColor
trackLayer.strokeColor = strokeColor.cgColor
trackLayer.strokeStart = strokeStart
trackLayer.strokeEnd = strokeEnd
layer.addSublayer(trackLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
updatePath()
}
private func updatePath() {
let arcCenter = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = (min(bounds.width, bounds.height) - lineWidth) / 2
trackLayer.lineWidth = lineWidth
trackLayer.path = UIBezierPath(arcCenter: arcCenter, radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: true).cgPath
// There's no need to rotate it if you're drawing a complete circle.
// But if you're going to transform, set the `frame`, too.
trackLayer.transform = CATransform3DIdentity
trackLayer.frame = bounds
trackLayer.transform = CATransform3DMakeRotation(-.pi / 2, 0, 0, 1)
}
}
That yields:
Or you can tweak the settings right in IB, and you'll see it take effect:
And having made sure that all of the didSet observers for the properties of ButtonView either update the path or directly update some shape layer, the view controller can now update these properties and they'll automatically be rendered in the ButtonView.
The main issue that I see in your code is that you are adding the layer inside -layoutSubviews, this method is called multiple times during a view lifecycle.
If you don't want to make the view hosted layer a CAShapeLayer by using the layerClass property, you need to override the 2 init methods (frame and coder) and call a commonInit where you instantiate and add your CAShape layer as a sublayer.
In -layoutSubviews just set the frame property of it and the path according to the new view size.
Related
I've run into this problem before, and usually been able to fix it by placing the code in viewWillLayoutSubviews(). This works in every other view controller I have and I just can't find a solution.
I have a UIView and I'm adding a shadow to it like this:
func setUpUI() {
for tile in roundedTiles {
tile.layer.cornerRadius = t.frame.height/2
tile.layer.shadowColor = UIColor.black.cgColor
tile.layer.shadowRadius = 10.0
tile.layer.shadowOpacity = 0.15
tile.layer.addSpread(spread: -10)
tile.layer.borderColor = GlobalConstants.Colors.lightGray.cgColor
tile.layer.borderWidth = 1
}
}
I then call it here:
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
setUpUI()
}
However, this is what it looks like:
This is what it should look like, taken from another view controller where it's displaying correctly (and I have written the code there exactly the same):
After looking at other StackOverflow questions I've tried moving it to viewDidLayoutSubviews(), viewDidLoad(), and adding tile.layoutIfNeeded(). Nothing is working. Would really appreciate some help!
First, I would recommend subclassing UIView for your tiles and add the shadow and rounded corners in the init method, rather than adding it via the parent view controller.
The trick to getting rounded corners with a shadow is adding the shadow to the view itself, and then adding a subview for the rounded corners, which you can maskToBounds without affecting the parent.
So:
class roundedTile: UIView {
required init?(coder: NSCoder) {
super.init(coder: coder)
self.layer.cornerRadius = self.frame.height/2
// Add shadow to parent view
self.layer.shadowColor = UIColor.black.cgColor
self.layer.shadowOffset = CGSize(width: 5.0, height: 0.0)
self.layer.shadowOpacity = 0.15
self.layer.shadowRadius = 10.0
// Create subview for rounded corners
let subView = UIView()
subView.frame.size = self.frame.size
subView.layer.cornerRadius = frame.height/2
subView.layer.borderColor = UIColor.lightGray.cgColor
subView.layer.borderWidth = 1
subView.layer.masksToBounds = true
self.addSubview(subView)
}
}
Season to taste the shadow offset/radius/opacity.
I have created custom UIView
import UIKit
#IBDesignable
class CategoryIcon: UIView {
let pi:CGFloat = CGFloat(M_PI)
#IBInspectable var circleColor:UIColor = UIColor.green
#IBInspectable var width:CGFloat = 10
#IBInspectable var radius: CGFloat = 20
override func draw(_ rect: CGRect) {
center = CGPoint(x:bounds.width/2 ,y:bounds.height/2)
let path = UIBezierPath(ovalIn: rect)
path.lineWidth = width
circleColor.setStroke()
path.stroke()
}
}
But when I add it to the storyboard does not matter where i put it it stay at the left top corner of parent view as you can see dots shows that view has moved but drawings are out of view and at the left top
what is the problem and how can it be solved?
Delete the following line from your draw(rect:) method:
center = CGPoint(x:bounds.width/2 ,y:bounds.height/2)
It's generally inadvisable to change a view's state while executing drawing code. But in this particular case, you're changing the location of the view's frame based on the origin of its bounds, which is ordinarily (0, 0), unless you've modified the bounds elsewhere. It's not clear what you were hoping to accomplish by doing that, but what it actually does is move the view to the upper left corner.
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 am working on creating an image collage app. And I am going to have multiple UIScrollView's. The scroll views will have boundaries with custom shapes and the user will be able to dynamically change the corners of the shapes where they intersect. The scroll views have UIImageView's as subviews.
The scroll views are subviews of other UIView's. I applied a CAShapeLayer mask to each of these UIView's. That way I can mask the scroll views with no problem.
But the problem is that, I can only scroll the contents of the last scroll view added. Also, I can pan and zoom beyond the boundaries of the masks. I should only able to pan or zoom when I am touching inside the boundaries of the polygons that I have as masks.
I tried;
scrollView.clipsToBounds = true
scrollView.layer.masksToBounds = true
But the result is the same.
Unfortunately I'm not able to post screenshots but, here is the code that I use to create masks for the UIViews:
func createMask(v: UIView, viewsToMask: [UIView], anchorPoint: CGPoint)
{
let frame = v.bounds
var shapeLayer = [CAShapeLayer]()
var path = [CGMutablePathRef]()
for i in 0...3 {
path.append(CGPathCreateMutable())
shapeLayer.append(CAShapeLayer())
}
//define frame constants
let center = CGPointMake(frame.origin.x + frame.size.width / 2, frame.origin.y + frame.size.height / 2)
let bottomLeft = CGPointMake(frame.origin.x, frame.origin.y + frame.size.height)
let bottomRight = CGPointMake(frame.origin.x + frame.size.width, frame.origin.y + frame.size.height)
switch frameType {
case 1:
// First view for Frame Type 1
CGPathMoveToPoint(path[0], nil, 0, 0)
CGPathAddLineToPoint(path[0], nil, bottomLeft.x, bottomLeft.y)
CGPathAddLineToPoint(path[0], nil, anchorPoint.x, bottomLeft.y)
CGPathAddLineToPoint(path[0], nil, anchorPoint.x, anchorPoint.y)
CGPathCloseSubpath(path[0])
// Second view for Frame Type 1
CGPathMoveToPoint(path[1], nil, anchorPoint.x, anchorPoint.y)
CGPathAddLineToPoint(path[1], nil, anchorPoint.x, bottomLeft.y)
CGPathAddLineToPoint(path[1], nil, bottomRight.x, bottomRight.y)
CGPathAddLineToPoint(path[1], nil, bottomRight.x, anchorPoint.y)
CGPathCloseSubpath(path[1])
// Third view for Frame Type 1
CGPathMoveToPoint(path[2], nil, 0, 0)
CGPathAddLineToPoint(path[2], nil, anchorPoint.x, anchorPoint.y)
CGPathAddLineToPoint(path[2], nil, bottomRight.x, anchorPoint.y)
CGPathAddLineToPoint(path[2], nil, bottomRight.x, 0)
CGPathCloseSubpath(path[2])
default:
break
}
for (key, view) in enumerate(viewsToMask) {
shapeLayer[key].path = path[key]
view.layer.mask = shapeLayer[key]
}
}
So, how can I make the scroll views behave in such a way that they will only scroll or zoom content when touches happen inside their corresponding mask boundaries?
EDIT:
According to the answer to this question: UIView's masked-off area still touchable? the masks only modify what you can see, not the area that you can touch. So I subclassed the UIScrollView and tried to override the hitTest:withEvent: method like so,
protocol CoolScrollViewDelegate: class {
var scrollViewPaths: [CGMutablePathRef] { get set }
}
class CoolScrollView: UIScrollView
{
weak var coolDelegate: CoolScrollViewDelegate?
override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView?
{
if CGPathContainsPoint(coolDelegate?.scrollViewPaths[tag], nil, point, true) {
return self
} else {
return nil
}
}
}
But with this implementation, I can only check against the last scroll view and path boundaries change when I zoom in. For example if I zoom in on the image the hitTest:withEvent: method returns nil.
I would agree with #Kendel in the comments - to start with it might be an easier approach to create a UIScrollView subclass that knows how to mask itself with a particular shape. Keeping the shape logic within a scroll view subclass will keep things tidy, and allow you to easily restrict touches to within the shape (I'll come to that in a minute).
It's a little hard to tell from your description exactly how your shaped views should behave, but as a brief example your ShapedScrollView might look like something like this:
import UIKit
class ShapedScrollView: UIScrollView {
// MARK: Types
enum Shape {
case First // Choose a better name!
}
// MARK: Properties
private let shapeLayer = CAShapeLayer()
var shape: Shape = .First {
didSet { setNeedsLayout() }
}
// MARK: Initializers
init(frame: CGRect, shape: Shape = .First) {
self.shape = shape
super.init(frame: frame)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
// MARK: Layout
override func layoutSubviews() {
super.layoutSubviews()
updateShape()
}
// MARK: Updating the Shape
private func updateShape() {
// Disable core animation actions to prevent changes to the shape layer animating implicitly
CATransaction.begin()
CATransaction.setDisableActions(true)
if bounds.size != shapeLayer.bounds.size {
// Bounds size has changed, completely update the shape
shapeLayer.frame = CGRect(origin: contentOffset, size: bounds.size)
shapeLayer.path = pathForShape(shape).CGPath
layer.mask = shapeLayer
} else {
// Bounds size has NOT changed, just update origin of shape path to
// match content offset - makes it appear stationary as we scroll
var shapeFrame = shapeLayer.frame
shapeFrame.origin = contentOffset
shapeLayer.frame = shapeFrame
}
CATransaction.commit()
}
private func pathForShape(shape: Shape) -> UIBezierPath {
let path = UIBezierPath()
switch shape {
case .First:
// Build the shape path, whatever that might be...
// path.moveToPoint(...)
// ...
}
return path
}
}
So making the touches only work inside the specified shape is the easy part. We already have a reference to a shape layer that describes the shape we want to restrict touches to. UIView provides a helpful hit-testing method that lets you specify whether or not a particular point should be considered to be "inside" that view: pointInside(_:withEvent:). Simply add the following override to ShapedScrollView:
override func pointInside(point: CGPoint, withEvent event: UIEvent?) -> Bool {
return CGPathContainsPoint(shapeLayer.path, nil, layer.convertPoint(point, toLayer: shapeLayer), false)
}
This just says: "If point (converted to the shape layer's coordinate system) is inside the shape's path, consider it to be inside the view; otherwise consider it outside the view."
If a scroll view that masks itself isn't appropriate, you can still adopt this technique by using a ShapedScrollContainerView: UIView with a scrollView property. Then, apply the shape mask to the container as above, and again use pointInside(_:withEvent:) to test whether it should respond to particular touch points.
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?