iOS Swift draw to screen - ios

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.

Related

How can I get the coordinates of a Label inside a view?

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.

Possible to get view size from within a class? (Swift)

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

How to get UIImageView's subview (a UIView) to cover it entirely?

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()
}

How to create an IBDesignable custom UIView from a UIBezierPath?

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 unique shapes or UIViews in Swift

I don't really have any knowledge of drawing in Swift, but I'm looking to create part of my app background that's slightly different to a square. See the image below for what I'm trying to make:
The idea is that this will sit at the top of the screen, and be nothing more than a background. It'll stretch half way down the screen. I'll then add an image over the top etc. I would need the point at the bottom of the square to always sit in the middle of the screen.
Can I make this shape/UIView in Swift by code? And if so, how?
Or, is it better to just create this as an image and do it that way?
Can I make this shape/UIView in Swift by code? And if so, how?
Views are always rectangular, but a view's content can be any shape at all and it's background can be transparent, so the visible part of a view can be anything you can draw.
There are a lot of ways to draw something in iOS. It's usually best to start with the highest level tool that'll work for you and move down to a lower level when you need more control. With that in mind, I'd suggest starting by creating a view that draws your pentagon using a UIBezierPath. The (Swift 5) code for this is very simple:
class PentagonView : UIView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.clear
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
backgroundColor = UIColor.clear
}
override func draw(_ rect: CGRect) {
let size = self.bounds.size
let h = size.height * 0.85 // adjust the multiplier to taste
// calculate the 5 points of the pentagon
let p1 = self.bounds.origin
let p2 = CGPoint(x:p1.x + size.width, y:p1.y)
let p3 = CGPoint(x:p2.x, y:p2.y + h)
let p4 = CGPoint(x:size.width/2, y:size.height)
let p5 = CGPoint(x:p1.x, y:h)
// create the path
let path = UIBezierPath()
path.move(to: p1)
path.addLine(to: p2)
path.addLine(to: p3)
path.addLine(to: p4)
path.addLine(to: p5)
path.close()
// fill the path
UIColor.red.set()
path.fill()
}
}
And now you can use that view anywhere in your UI:
If you want to add a 150x150px PentagonView to your view controller's main view at coordinates (100, 200), you could put the following in viewDidLoad():
let pg = PentagonView(frame:CGRect(x:100, y:200, width:150, height:150))
self.view.addSubview(pg)
Or, you could also add a PentagonView to some view in your storyboard by dropping in a UIView of the right size and then changing it's class to PentagonView.
Or, is it better to just create this as an image and do it that way?
If you've fully described your requirement, then no, I don't think so: the image itself will probably be a lot larger than the code that draws it, and the code that I've given above will work at any size you choose. On the other hand, if you're going to add a lot of complex detail, and if the image only needs to appear at one size, then it might be easier to create it once in a drawing program and store that work as an image. Like I said above, there are a lot of options for drawing in iOS; which you choose should be determined by your needs.
Caleb's answer updated to Swift 5:
class PentagonView : UIView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
backgroundColor = .clear
}
override func draw(_ rect: CGRect) {
let size = self.bounds.size
let h = size.height * 0.85 // adjust the multiplier to taste
// calculate the 5 points of the pentagon
let p1 = self.bounds.origin
let p2 = CGPoint(x:p1.x + size.width, y:p1.y)
let p3 = CGPoint(x:p2.x, y:p2.y + h)
let p4 = CGPoint(x:size.width/2, y:size.height)
let p5 = CGPoint(x:p1.x, y:h)
// create the path
let path = UIBezierPath()
path.move(to: p1)
path.addLine(to: p2)
path.addLine(to: p3)
path.addLine(to: p4)
path.addLine(to: p5)
path.close()
// fill the path
UIColor.red.set()
path.fill()
}
}
The best way to tackle this is not by creating a view (UIView) but a layer (CAShapeLayer). You can create a CAShapeLayer from a path, thus describing your shape with vectors (as opposed to a bitmap).
There are many tutorials about CAShapeLayer that will show you code for achieving this.
It would probably be best if you loaded an image from file of the shape onto a UIImageView, especially if its just a background. You can then constraint the bottom of the view to the middle of your app. You might also be able to do some tweaking of the view's CALayer property to achieve your effect however.

Resources