Why does path draw towards the opposite direction? - swift

I have the following code snippet that draws a circular sector shape:
struct CircularSector: Shape {
let centralAngle: Angle
func path(in rect: CGRect) -> Path {
let radius = min(rect.width, rect.height) / 2
let center = CGPoint(x: rect.midX, y: rect.midY)
var path = Path()
path.addArc(center: center, radius: radius, startAngle: .degrees(0), endAngle: centralAngle, clockwise: true)
path.addLine(to: center)
path.closeSubpath()
return path
}
}
When I preview it,
struct CircularSector_Previews: PreviewProvider {
static var previews: some View {
CircularSector(centralAngle: .degrees(45)).fill(Color.black)
}
}
}
instead of a 45° sector clockwise, it draws a 315° sector counterclockwise. Is this the correct behaviour, or did I do something wrong?

It does seem like a bug. (See https://stackoverflow.com/a/57034585/341994 where exactly the same thing happens.) This is not how UIBezierPath behaves over on the UIKit side. If we say, mutatis mutandis:
let path = UIBezierPath()
path.addArc(withCenter: center, radius: radius,
startAngle: 0, endAngle: .pi/4, clockwise: true)
path.addLine(to: center)
path.close()
We get
which is just what you are expecting. It's easy to see how to compensate, but it does seem that what you are compensating for is a mistake in the SwiftUI Path implementation.

Related

Draw Shape over another Shape

Problem :
I cannot draw a shape on another shape.
What I am trying to achieve :
Draw circles on the line.
Anyway, the circle is shifting the line. I didn't find a way to make it as swift UI seems relatively new. I am currently learning swift and I prefer swift UI rater than storyboard.
If circle and line are different struct, this is because I want to reuse the shape later on.
There is the code :
import SwiftUI
public var PointArray = [CGPoint]()
public var PointArrayInit:Bool = false
struct Arc: Shape {
var startAngle: Angle
var endAngle: Angle
var clockwise: Bool
var centerCustom:CGPoint
func path(in rect: CGRect) -> Path {
let rotationAdjustment = Angle.degrees(90)
let modifiedStart = startAngle - rotationAdjustment
let modifiedEnd = endAngle - rotationAdjustment
var path = Path()
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: 20, startAngle: modifiedStart, endAngle: modifiedEnd, clockwise: !clockwise)
return path
}
}
struct CurveCustomInit: Shape {
private var Divider:Int = 10
func path(in rect: CGRect) -> Path {
var path = Path()
let xStep:CGFloat = DrawingZoneWidth / CGFloat(Divider)
let yStep:CGFloat = DrawingZoneHeight / 2
var xStepLoopIncrement:CGFloat = 0
path.move(to: CGPoint(x: 0, y: yStep))
for _ in 0...Divider {
let Point:CGPoint = CGPoint(x: xStepLoopIncrement, y: yStep)
PointArray.append(Point)
path.addLine(to: Point)
xStepLoopIncrement += xStep
}
PointArrayInit = true
return (path)
}
}
struct TouchCurveBasic: View {
var body: some View {
if !PointArrayInit {
Arc(startAngle: .degrees(0), endAngle: .degrees(360), clockwise: true, centerCustom: CGPoint(x: 50, y: 400))
.stroke(Color.blue, lineWidth: 4)
CurveCustomInit()
.stroke(Color.red, style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round))
.frame(width: 300, height: 300)
} else {
}
}
}
struct TouchCurveBasic_Previews: PreviewProvider {
static var previews: some View {
TouchCurveBasic()
}
}
There is what I get :
Here is an other way for you, you can limit the size of drawing with giving a frame or you can use the available size of view without limiting it or even you can use the current limit coming from parent and updated it, like i did on drawing the Line. The method that I used was overlay modifier.
struct ContentView: View {
var body: some View {
ArcView(radius: 30.0)
.stroke(lineWidth: 10)
.foregroundColor(.blue)
.frame(width: 60, height: 60)
.overlay(LineView().stroke(lineWidth: 10).foregroundColor(.red).frame(width: 400))
}
}
struct ArcView: Shape {
let radius: CGFloat
func path(in rect: CGRect) -> Path {
return Path { path in
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: radius, startAngle: Angle(degrees: 0.0), endAngle: Angle(degrees: 360.0), clockwise: true)
}
}
}
struct LineView: Shape {
func path(in rect: CGRect) -> Path {
return Path { path in
path.move(to: CGPoint(x: rect.minX, y: rect.midY))
path.addLines([CGPoint(x: rect.minX, y: rect.midY), CGPoint(x: rect.maxX, y: rect.midY)])
}
}
}
result:

How to draw a UIBezierPath with an ARC to the right similar to the image shown?

How do I draw this UIBezierPath to make it look identical to the green strip on the left of the image below? The rounded arc to the right.
path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 20, y: 0))
path.addLine(to: CGPoint(x: 20, y: 80))
//Add Half Circle Arc To Right
path.addArc(withCenter: CGPoint(x: 0, y: 160), radius: bounds.width, startAngle: 0, endAngle: 90, clockwise: true)
path.addLine(to: CGPoint(x: 20, y: 215))
path.addLine(to: CGPoint(x: 20, y: bounds.maxY))
path.addLine(to: CGPoint(x: 0, y: bounds.maxY))
path.close()
What you need is a routine that given the height of the desired arc and how far this “bubble” should stick out, and will determine the desired arc angle, radius, and center offset.
To determine the center of the circle giving three points on that circle is to identify the chord between two points and then identify the line that bisects that line segment. That results in a line that goes through the center of the circle. Then repeat that for a different two points on the circle. The intersection of those two lines will be the center of the circle.
So, I used a little basic algebra to calculate the slope (m), the y-intercept (b), and the x-intercept (xIntercept) of the line that bisects the line segment between the start of the arc and its half point. We can take that line, and see where it intercepts the x-axis and determine the center of the circle.
From that, a little trigonometry gives us the angle and the radius of the arc that intersects these three points (the top of the arc, the middle of the arc, and the bottom of the arc).
You get something like:
/// Calculate parameters necessary for arc.
/// - Parameter height: The height of top half of the arc.
/// - Parameter distance: How far out the arc should project.
func angleRadiusAndOffset(height: CGFloat, distance: CGFloat) -> (CGFloat, CGFloat, CGFloat) {
let m = distance / height
let b = height / 2 - distance * distance / (2 * height)
let xIntercept = -b / m
let angle = atan2(height, -xIntercept)
let radius = height / sin(angle)
return (angle, radius, xIntercept)
}
And you can then use that to create your path:
var point: CGPoint = CGPoint(x: bounds.minX, y: bounds.minY)
let path = UIBezierPath()
path.move(to: point)
point.x += edgeWidth
path.addLine(to: point)
point.y += bubbleStartY
path.addLine(to: point)
let (angle, radius, offset) = angleRadiusAndOffset(height: bubbleHeight / 2, distance: bubbleWidth)
let center = CGPoint(x: point.x + offset, y:point.y + bubbleHeight / 2)
path.addArc(withCenter: center, radius: radius, startAngle: -angle, endAngle: angle, clockwise: true)
point.y = bounds.maxY
path.addLine(to: point)
point.x = bounds.minX
path.addLine(to: point)
path.close()
And that yields:
That’s using these values:
var edgeWidth: CGFloat = 10
var bubbleWidth: CGFloat = 30
var bubbleHeight: CGFloat = 100
var bubbleStartY: CGFloat = 80
But you can obviously adjust these values as needed.

How to call UIBezier outline in Swift Playground?

EDIT: Sorry, I wasn't clear originally. I want to get the "outline" path of a line or shape. I'm specifically trying to understand how to use:
context.replacePathWithStrokedPath()
and / or:
CGPathRef CGPathCreateCopyByStrokingPath(CGPathRef path, const CGAffineTransform *transform, CGFloat lineWidth, CGLineCap lineCap, CGLineJoin lineJoin, CGFloat miterLimit);
https://developer.apple.com/documentation/coregraphics/1411128-cgpathcreatecopybystrokingpath?language=objc
I'm not looking for workarounds, thanks.
=====
I'm really trying to wrap my head around drawing a line with an outline around it. i'm using UIBezier, but running into brick walls. So far, I've got this:
import UIKit
import PlaygroundSupport
let screenWidth = 375.0 // points
let screenHeight = 467.0 // points
let centerX = screenWidth / 2.0
let centerY = screenHeight / 2.0
let screenCenterCoordinate = CGPoint(x: centerX, y: centerY)
class LineDrawingView: UIView {
override func draw(_ rect: CGRect) {
let path = UIBezierPath()
path.lineWidth = 5
path.lineCapStyle = .round
//Move to Drawing Point
path.move(to: CGPoint(x:20, y:120))
path.addLine(to: CGPoint(x:200, y:120))
path.stroke()
let dot = UIBezierPath()
dot.lineWidth = 1
dot.lineCapStyle = .round
dot.move(to: CGPoint(x:200, y:120))
dot.addArc(withCenter: CGPoint(x:200, y:120), radius: 5, startAngle: CGFloat(0.0), endAngle: CGFloat(8.0), clockwise: true)
UIColor.orange.setStroke()
UIColor.orange.setFill()
path.stroke()
dot.fill()
let myStrokedPath = UIBezierPath.copy(path)
myStrokedPath().stroke()
}
}
let tView = LineDrawingView(frame: CGRect(x: 0,y: 0, width: screenWidth, height: screenHeight))
tView.backgroundColor = UIColor.white
PlaygroundPage.current.liveView = tView
So, where am I going wrong in this? I cannot seem to figure out where to use CGPathCreateCopyByStrokingPath...or how...
EDIT 2:
Ok, now I've got this. Closer, but how do I fill the path again?
let c = UIGraphicsGetCurrentContext()!
c.setLineWidth(15.0)
let clipPath = UIBezierPath(arcCenter: CGPoint(x:centerX,y:centerY), radius: 90.0, startAngle: -0.5 * .pi, endAngle: 1.0 * .pi, clockwise: true).cgPath
c.addPath(clipPath)
c.saveGState()
c.replacePathWithStrokedPath()
c.setLineWidth(0.2)
c.setStrokeColor(UIColor.black.cgColor)
c.strokePath()
The class was modified slightly to produce this graphic:
The path was not copied in the modified code. Instead the existing path was used to draw, then modified and reused. The dot did not have a stroke so that was added. Since only closed paths can be filled, I drew a thinner path on top of a thicker path by changing the line width.
This is the modified code:
class LineDrawingView: UIView {
override func draw(_ rect: CGRect) {
let path = UIBezierPath()
path.lineWidth = 7
path.lineCapStyle = .round
//Move to Drawing Point
path.move(to: CGPoint(x:20, y:120))
path.addLine(to: CGPoint(x:200, y:120))
path.stroke()
let dot = UIBezierPath()
dot.lineWidth = 1
dot.lineCapStyle = .round
dot.move(to: CGPoint(x:200, y:120))
dot.addArc(withCenter: CGPoint(x:200, y:120), radius: 5, startAngle: CGFloat(0.0), endAngle: CGFloat(8.0), clockwise: true)
dot.stroke()
UIColor.orange.setStroke()
UIColor.orange.setFill()
path.lineWidth = 5
path.stroke()
dot.fill()
}
}
So, I've found the (an) answer. I used CAShapeLayer:
let c = UIGraphicsGetCurrentContext()!
c.setLineCap(.round)
c.setLineWidth(15.0)
c.addArc(center: CGPoint(x:centerX,y:centerY), radius: 90.0, startAngle: -0.5 * .pi, endAngle: (-0.5 * .pi) + (3 / 2 * .pi ), clockwise: false)
c.replacePathWithStrokedPath()
let shape = CAShapeLayer()
shape.path = c.path
shape.fillColor = UIColor.yellow.cgColor
shape.strokeColor = UIColor.darkGray.cgColor
shape.lineWidth = 1
myView.layer.addSublayer(shape)
It works well enough, but not on overlapping layers. I need to learn how to connect contours or something.

Swift Custom Pie Chart - Strange behavior cutting transparent circle from multiple UIBezierPaths

Creating a custom pie chart / doughnut style graph with Swift, and am running into a strange problem when trying to cut the hole out of the doughnut. I've tried variations on center and radius for the second UIBezierPath, but I haven't been able to accomplish a clean cut hole from the center. Any help would be greatly appreciated.
Subclass of UIView:
import UIKit
public class DoughnutView: UIView {
public var data: [Float]? {
didSet { setNeedsDisplay() }
}
public var colors: [UIColor]? {
didSet { setNeedsDisplay() }
}
#IBInspectable public var spacerWidth: CGFloat = 2 {
didSet { setNeedsDisplay() }
}
#IBInspectable public var thickness: CGFloat = 20 {
didSet { setNeedsDisplay() }
}
public override func draw(_ rect: CGRect) {
guard data != nil && colors != nil else {
return
}
guard data?.count == colors?.count else {
return
}
let center = CGPoint(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0)
let radius = min(bounds.size.width, bounds.size.height) / 2.0
let total = data?.reduce(Float(0)) { $0 + $1 }
var startAngle = CGFloat(Float.pi)
UIColor.clear.setStroke()
for (key, value) in data!.enumerated() {
let endAngle = startAngle + CGFloat(2.0 * Float.pi) * CGFloat(value / total!)
let doughnut = UIBezierPath()
doughnut.move(to: center)
doughnut.addArc(withCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
let hole = UIBezierPath(arcCenter: center, radius: radius - thickness, startAngle: startAngle, endAngle: endAngle, clockwise: true)
hole.move(to: center)
doughnut.append(hole)
doughnut.usesEvenOddFillRule = true
doughnut.close()
doughnut.lineWidth = spacerWidth
colors?[key].setFill()
doughnut.fill()
doughnut.stroke()
startAngle = endAngle
}
}
public override func layoutSubviews() {
super.layoutSubviews()
self.backgroundColor = UIColor.clear
setNeedsDisplay()
}
}
And then a ViewController:
class ViewController: UIViewController {
#IBOutlet weak var doughnut: DoughnutView!
override func viewDidLoad() {
super.viewDidLoad()
doughnut.data = [3, 14, 5]
doughnut.colors = [UIColor.red, UIColor.yellow, UIColor.blue]
view.backgroundColor = .purple
}
}
The result:
So you want this:
The problem is that addArc creates, well, an arc, not a wedge. That is, it creates a path that traces part of a circle, without radial segments going to or from the center of the circle. Since you haven't been careful adding those radial segments, when you call close(), you get straight lines where you don't want them.
I guess you're trying to add those radial segments with your move(to:) calls, but you haven't done everything necessary to make that work.
Anyway, this can be done more simply. Start with an arc tracing the outer edge of the slice, then add an arc tracing the inner edge of the slice in the opposite direction. UIBezierPath will automatically connect the end of the first arc to the start of the second arc with a straight line, and close() will connect the end of the second arc to the start of the first arc with another straight line. Thus:
let slice = UIBezierPath()
slice.addArc(withCenter: center, radius: radius,
startAngle: startAngle, endAngle: endAngle, clockwise: true)
slice.addArc(withCenter: center, radius: radius - thickness,
startAngle: endAngle, endAngle: startAngle, clockwise: false)
slice.close()
That said, we can improve your draw(_:) method in some other ways:
We can use guard to rebind data and colors to non-optionals.
We can also guard that data is not empty.
We can reduce radius by spacerWidth to avoid clipping the stroked borders. (You changed the stroke color to .clear in your question's code, but your image shows it as .white.)
We can use CGFloat uniformly to have fewer conversions.
We can divide total by 2π once instead of multiplying every value by 2π.
We can zip(colors, data) into a sequence instead of using enumerated() and subscripting colors.
Thus:
public override func draw(_ rect: CGRect) {
guard
let data = data, !data.isEmpty,
let colors = colors, data.count == colors.count
else { return }
let center = CGPoint(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0)
let radius = min(bounds.size.width, bounds.size.height) / 2.0 - spacerWidth
let total: CGFloat = data.reduce(0) { $0 + CGFloat($1) } / (2 * .pi)
var startAngle = CGFloat.pi
UIColor.white.setStroke()
for (color, value) in zip(colors, data) {
let endAngle = startAngle + CGFloat(value) / total
let slice = UIBezierPath()
slice.addArc(withCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
slice.addArc(withCenter: center, radius: radius - thickness, startAngle: endAngle, endAngle: startAngle, clockwise: false)
slice.close()
color.setFill()
slice.fill()
slice.lineWidth = spacerWidth
slice.stroke()
startAngle = endAngle
}
}

Drawing a partial circle

I'm writing a program that will take a number between 0 and 1, and then spits out a circle (or arc I guess) that is completed by that much.
So for example, if 0.5 was inputted, the program would output a semicircle
if 0.1, the program would output a tiny little arc that would ultimately be 10% of the whole circle.
I can get this to work by making the angle starting point 0, and the angle ending point 2*M_PI*decimalInput
However, I need to have the starting point at the top of the circle, so the starting point is 3*M_PI_2 and the ending point would be 7*M_PI_2
I'm just having trouble drawing a circle partially complete with these new starting/ending points. And I'll admit, my math is not the best so any advice/input is appreciated
Here is what I have so far
var decimalInput = 0.75 //this number can be any number between 0 and 1
let start = CGFloat(3*M_PI_2)
let end = CGFloat(7*M_PI_2*decimalInput)
let circlePath = UIBezierPath(arcCenter: circleCenter, radius: circleRadius, startAngle: start, endAngle: end, clockwise: true)
circlePath.stroke()
I just cannot seem to get it right despite what I try. I reckon the end angle is culprit, unless I'm going about this the wrong way
The arc length is 2 * M_PI * decimalInput. You need to add the arc length to the starting angle, like this:
let circleCenter = CGPointMake(100, 100)
let circleRadius = CGFloat(80)
var decimalInput = 0.75
let start = CGFloat(3 * M_PI_2)
let end = start + CGFloat(2 * M_PI * decimalInput)
let circlePath = UIBezierPath(arcCenter: circleCenter, radius: circleRadius, startAngle: start, endAngle: end, clockwise: true)
XCPCaptureValue("path", circlePath)
Result:
Note that the path will be flipped vertically when used to draw in a UIView.
You can use this extension to draw a partial circle
extension UIBezierPath {
func addCircle(center: CGPoint, radius: CGFloat, startAngle: Double, circlePercentage: Double) {
let start = deg2rad(startAngle)
let end = start + CGFloat(2 * Double.pi * circlePercentage)
addArc(withCenter: center,
radius: radius,
startAngle: start,
endAngle: end,
clockwise: true)
}
private func deg2rad(_ number: Double) -> CGFloat {
return CGFloat(number * Double.pi / 180)
}
}
Example usage (you can copy paste it in a playground to see the result)
let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
view.backgroundColor = UIColor.green
let layer = CAShapeLayer()
layer.strokeColor = UIColor.red.cgColor
layer.fillColor = UIColor.clear.cgColor
layer.lineWidth = 8
let path = UIBezierPath()
path.addCircle(center: CGPoint(x: 50, y: 50), radius: 50, startAngle: 270, circlePercentage: 0.87)
layer.path = path.cgPath
view.layer.addSublayer(layer)
view.setNeedsLayout()