I'm trying to draw a simple outlined circle inside my collection view cells. For some reason, only the first cell is being draw, the rest are not showing.
class UserCell: UICollectionViewCell {
override func draw(_ rect: CGRect) {
let center = CGPoint(x: self.center.x - 1, y: 41)
let circularPath = UIBezierPath()
circularPath.addArc(withCenter: center, radius: 36, startAngle: 0, endAngle: CGFloat(2 * Double.pi), clockwise: true)
UIColor.red.setStroke()
circularPath.lineWidth = 2
circularPath.stroke()
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.white
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
What am I missing here?
Collection view cells are configured for display using UICollectionViewDataSource method cellForItemAt(). The cells are reused and won't automatically redraw for each 'new' cell. Instead of overriding draw(rect), add subviews to the cell and configure the subviews in cellForItemAt().
You might want to define your center point differently. The center point is specified in points in the coordinate system of its superview. Either try convert the cell's center point from its superview's coordinate system or use the bounds of the cell instead and adjust the x and y values accordingly.
let center = self.convert(self.center, from: self.superview)
let center = CGPoint(x: bounds.midX, y: bounds.midY)
Created a new class conforming to UIView(), added the bezierPath info inside its draw function. Then subclassed this class inside the collectionView cell. Works as expected.
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.
If a subview of class MyClass is added to another view:
secondView = MyClass()
mainView.addSubview(secondView)
is it possible to get the subview's dimensions from within the class?
secondView = MyClass()
mainView.addSubview(secondView)
class MyClass: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
// do whatever the view will need to show
// *** can I get the view's dimensions here? <----------------------***
// e.g., draw a diagonal line
// from top left corner to bottom right corner
// self.bounds and self.frame return (0, 0, 0, 0) here
}
}
Or do they need to be passed in somehow from the view creating it (mainView)?
You cannot get a view's size reliably within init. A view's size can change. Therefore you should either do any drawing you need in drawrect, or if you are using CALAyer, resize your layer in layoutSubviews where you will have the bounds of your view and you can then use that to set the frame of your sublayer or subviews.
https://developer.apple.com/documentation/uikit/uiview/1622482-layoutsubviews
https://developer.apple.com/documentation/uikit/uiview/1622529-drawrect
Here is a playground with a simple line example.
import UIKit
import Combine
import PlaygroundSupport
final class XView: UIView {
override func draw(_ rect: CGRect) {
super.draw(rect)
UIColor.red.setStroke()
let path = UIBezierPath()
path.lineWidth = 10
path.move(to: rect.origin)
path.addLine(to: .init(x: rect.maxX, y: rect.maxY))
path.stroke()
}
}
let v = XView()
v.frame = .init(origin: .zero, size: .init(width: 300, height: 300))
PlaygroundPage.current.liveView = XView()
If you want the fancy version see my GitHub project here:
https://github.com/joshuajhomann/pathmorpher/tree/master/final
I am new to iOS development and need help from someone a bit more experienced than me. I searched the internet and couldn't find any working solution.
I need to draw to the screen like canvas in Android. Currently I have a CADisplayLink to call a function every frame. And that's working well. The problem is: How do I actually draw anything, like a rectangle, a circle or a line to the screen every frame?
This is what I have (I linked this class to the view in the storyboard):
class Canvas: UIView {
override func draw(_ rect: CGRect) {
let context = UIGraphicsGetCurrentContext()
context?.setLineWidth(2.0)
context?.setStrokeColor(UIColor.green.cgColor)
context?.move(to: CGPoint(x: 30, y: 30))
context?.addLine(to: CGPoint(x: Double(xBall), y: Double(yBall)))
context?.strokePath()
}
}
With the following code I can actually draw a line to the screen:
let canvas = Canvas()
canvas.draw(CGRect())
The problem is, that this works exactly ONE time. When I have canvas.draw(CGRect()) in my loop which repeats every frame, it works for the first frame (the initial values of xBall and yBall) and never again. When I print the values in the draw method, it gets called every frame and the variables have the correct values. But it does not draw it to the screen. I tried adding the line setNeedsDisplay() in the draw method, with similar results.
Any help will be appreciated! Thanks!
If you refer to the draw(_:) documentation, it says:
This method is called when a view is first displayed or when an event occurs that invalidates a visible part of the view. You should never call this method directly yourself. To invalidate part of your view, and thus cause that portion to be redrawn, call the setNeedsDisplay() or setNeedsDisplay(_:) method instead.
The common approach would be to have your view controller viewDidLoad method add Canvas view:
override func viewDidLoad() {
super.viewDidLoad()
let canvas = Canvas()
canvas.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(canvas)
NSLayoutConstraint.activate([
canvas.leadingAnchor.constraint(equalTo: view.leadingAnchor),
canvas.trailingAnchor.constraint(equalTo: view.trailingAnchor),
canvas.topAnchor.constraint(equalTo: view.topAnchor),
canvas.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
You don’t call draw(_:) yourself, but rather the OS will do so automatically. All you need to do is add it to your view hierarchy with addSubview(_:). And you can then just have your CADisplayLink update the properties and call setNeedsDisplay (or, better, add didSet observers to those properties that calls setNeedsDisplay for you).
By the way, if you don’t want to add this programmatically, like shown above, you can add Canvas right in Interface Builder. Just drag a UIView onto your storyboard scene, add all of the appropriate constraints, go to the “identity” inspector, and set the base class name to be Canvas:
And if you mark your class as #IBDesignable, you can actually see your path rendered right in Interface Builder, like shown above.
A number of refinements:
If you are going to implement draw(_:) yourself, instead of getting a graphics context with UIGraphicsGetCurrentContext, you might just stroke a UIBezierPath:
override func draw(_ rect: CGRect) {
let path = UIBezierPath()
path.move(to: CGPoint(x: 30, y: 30))
path.addLine(to: CGPoint(x: xBall, y: yBall))
path.lineWidth = 2
UIColor.green.setStroke()
path.stroke()
}
Like your solution, this requires that after you update xBall and yBall, if you call setNeedsDisplay to have the view re-rendered with the updated path.
Sometimes we wouldn’t even implement draw(_:). We would just add a CAShapeLayer as a sublayer:
#IBDesignable
class Canvas: UIView {
var xBall = ...
var yBall = ...
let shapeLayer: CAShapeLayer = {
let shapeLayer = CAShapeLayer()
shapeLayer.strokeColor = UIColor.green.cgColor
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.lineWidth = 2
return shapeLayer
}()
override init(frame: CGRect = .zero) {
super.init(frame: frame)
configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configure()
}
func configure() {
layer.addSublayer(shapeLayer)
updatePath()
}
func updatePath() {
let path = UIBezierPath()
path.move(to: CGPoint(x: 30, y: 30))
path.addLine(to: CGPoint(x: xBall, y: yBall))
shapeLayer.path = path.cgPath
}
}
In this sort of approach, you just update the path of the shapeLayer and the OS will render your shape layer (and its path) for you.
I have a UIImageView placed on the storyboard, and am trying to programmatically add a UIView as a subview to it, and have that UIView match the parent UIImageView's size and position exactly so that it covers it.
The UIView is a custom class of UIView thats drawing a CAShapeLayer that fills its frame, if that matters at all.
In addition to the following code, I've also tried "redOverlay.center = parentImage.center" without success.
In my viewdidload() i have:
let redOverlay = RedOverlay(frame: CGRect(x: 0.0, y: 0.0, width: parentImage.bounds.width, height: parentImage.bounds.height))
parentImage.addSubview(redOverlay)
And here is my RedOverlay subclass of UIView if that makes a difference:
import UIKit
extension CGFloat {
func toRadians() -> CGFloat {
return self * CGFloat.pi / 180.0
}
}
class RedOverlay: UIView {
var path: UIBezierPath!
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.darkGray.withAlphaComponent(0.5)
pie()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func pie() {
path = UIBezierPath()
path.move(to: CGPoint(x: self.bounds.size.width/2, y: self.bounds.size.height/2))
path.addArc(withCenter: CGPoint(x: self.bounds.size.width/2, y: self.bounds.size.height/2),
radius: self.bounds.size.width/2,
startAngle: CGFloat(215).toRadians(),
endAngle: CGFloat(90).toRadians(),
clockwise: true)
path.close()
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.fillColor = UIColor.red.cgColor
shapeLayer.opacity = 0.5
self.layer.addSublayer(shapeLayer)
}
}
The result I get actually works perfectly on iPhone 6, but anything larger like 6s or ipads, and the child UIView is actually noticeably smaller than the UIImageView. In the iPad, the UIView sits well to the left, while in the 6s it is only a bit left.
Basically, it seems like the UIView being generated isnt as wide as the image, even though parentImage.bounds.width was used to determine the UIView's width. I do have "Clip to Bounds" and Aspect Fit set in IB so I don't know why the image would be bigger than it's bounds if thats possible at all.
Whats the cleanest way to get this subview to match the parent view in all device sizes?
EDIT: The solution I found was to call my function pie() (that draws the CAShapeLayer), by overriding layoutSubviews and calling pie() there so that my shapeLayer is updated whenever the view changes. I removed .addSublayer(shapeLayer) from pie() and now I call it in viewdidload in case layoutsubviews being called repeatedly might cause many layers to be created.
override func layoutSubviews() {
super.layoutSubviews()
pie()
}
I have custom UIView in which I draw a UIBezierPath. This view is used in a UITableViewCell. So when I scroll, the custom view's bezier path gets redrawn. The problem is that the new path draws over old path drawings (the context isn't cleared properly). I'm calling setNeedsDisplay() on the table cell fetch and I've also set clearsContextBeforeDrawing = true for the view. The only thing that clears up the old drawing is calling context.clear(rect) but this isn't at all ideal since I lose the background (it becomes black).
Any ideas on how to fix this?
class CustomView: UIView {
var percentage: CGFloat = 0 {
didSet {
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
clearsContextBeforeDrawing = true
contentMode = .redraw
clipsToBounds = false
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
super.draw(rect)
// NOTE: don't really like doing this
let context = UIGraphicsGetCurrentContext()!
context.clear(rect)
let center = CGPoint(x: self.bounds.size.width/2, y: self.bounds.size.height/2)
let path = UIBezierPath(arcCenter: center,
radius: self.bounds.size.width/2-10,
startAngle: 0.5 * .pi,
endAngle: (-2.0 * self.percentage + 0.5) * .pi,
clockwise: false)
path.lineWidth = 4
UIColor.red.setStroke()
path.stroke()
}
}
This is where my cell and custom uiview gets set.
override func tableView(_ tableView: UITableView, cellForItemAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
let num = list[indexPath.item]
let percentage = CGFloat(num) / 10
cell.customView.percentage = percentage // Custom view should get redrawn after this call
return cell
}
I think the problem is merely that your drawing is huge and strays outside the cell because your views do not clip to bounds. That's incoherent. Each cell needs to draw just its own content.
In other words, the overlapping you are seeing has nothing to do with the custom view drawing; it has to do with the cell drawing. You are infecting the neighboring cells with your drawing.
By the way, you say:
but this isn't at all ideal since I lose the background (it becomes black)."
You can fix that by saying self.backgroundColor = .clear in your init.