How to draw text when using layers in NSView with Swift - swift

I'm using layers in my NSView as much of the display will change infrequently. This is working fine. I'm now at the point where I'd like to add text to the layer. This is not working.
To draw the text, I'm trying to use code from another answer on how to draw text on a curve. I've researched and nothing I've tried helps. I've created a Playground to demonstrate. I can see when I run the Playground that the code to display the text is actually called, but nothing is rendered to the screen. I'm sure I've got something wrong, just not sure what.
The playground is below, along with the code referenced from above (updated for Swift 5, NS* versus UI*, and comments removed for size).
import AppKit
import PlaygroundSupport
func centreArcPerpendicular(text str: String,
context: CGContext,
radius r: CGFloat,
angle theta: CGFloat,
colour c: NSColor,
font: NSFont,
clockwise: Bool){
let characters: [String] = str.map { String($0) }
let l = characters.count
let attributes = [NSAttributedString.Key.font: font]
var arcs: [CGFloat] = []
var totalArc: CGFloat = 0
for i in 0 ..< l {
arcs += [chordToArc(characters[i].size(withAttributes: attributes).width, radius: r)]
totalArc += arcs[i]
}
let direction: CGFloat = clockwise ? -1 : 1
let slantCorrection: CGFloat = clockwise ? -.pi / 2 : .pi / 2
var thetaI = theta - direction * totalArc / 2
for i in 0 ..< l {
thetaI += direction * arcs[i] / 2
centre(text: characters[i],
context: context,
radius: r,
angle: thetaI,
colour: c,
font: font,
slantAngle: thetaI + slantCorrection)
thetaI += direction * arcs[i] / 2
}
}
func chordToArc(_ chord: CGFloat, radius: CGFloat) -> CGFloat {
return 2 * asin(chord / (2 * radius))
}
func centre(text str: String,
context: CGContext,
radius r: CGFloat,
angle theta: CGFloat,
colour c: NSColor,
font: NSFont,
slantAngle: CGFloat) {
let attributes = [NSAttributedString.Key.foregroundColor: c, NSAttributedString.Key.font: font]
context.saveGState()
context.translateBy(x: r * cos(theta), y: -(r * sin(theta)))
context.rotate(by: -slantAngle)
let offset = str.size(withAttributes: attributes)
context.translateBy (x: -offset.width / 2, y: -offset.height / 2)
str.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
context.restoreGState()
}
class CustomView: NSView {
let textOnCurve = TextOnCurve()
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateLayer() {
let textOnCurveLayer = CAShapeLayer()
textOnCurveLayer.frame = self.frame
textOnCurveLayer.delegate = textOnCurve
textOnCurveLayer.setNeedsDisplay()
layer?.addSublayer(textOnCurveLayer)
}
}
class TextOnCurve: NSObject, CALayerDelegate {
func draw(_ layer: CALayer, in ctx: CGContext) {
ctx.setFillColor(.white)
ctx.move(to: CGPoint(x: 100, y: 100))
ctx.addLine(to: CGPoint(x: 150, y: 100))
ctx.addLine(to: CGPoint(x: 150, y: 150))
ctx.addLine(to: CGPoint(x: 100, y: 150))
ctx.closePath()
ctx.drawPath(using: .fill)
ctx.translateBy(x: 200.0, y: 200.0)
centreArcPerpendicular(text: "Anticlockwise",
context: ctx,
radius: 100,
angle: CGFloat(-.pi/2.0),
colour: .red,
font: NSFont.systemFont(ofSize: 16),
clockwise: false)
centre(text: "Hello flat world",
context: ctx,
radius: 0,
angle: 0 ,
colour: .yellow,
font: NSFont.systemFont(ofSize: 16),
slantAngle: .pi / 4)
}
}
let containerView = CustomView(frame: CGRect(x: 0, y: 0, width: 400, height: 400))
PlaygroundPage.current.liveView = containerView
The box is drawn, but the text is not.
If I do not use layers, then I can draw text by calling the methods in the standard draw: method. So the code should work, but, something about the use of layers is preventing it.

Related

How can I draw text in Swift's CoreGraphics context in a UIImageView?

I'm trying to draw a string in a UIImageView in Swift's CoreGraphics for a dynamic geometry software app. I'm using context graphics for graphing points and lines, but when I try to draw a text string (for example, the length of a line segment), I either get no text (such as in these two examples), or everything else I'm drawing is erased except the text string (sorry, I didn't save examples of that). In the latter case, I could draw other stuff on top of the string, but that isn't really helpful either, since I could only draw a single string (trying to draw a second string erased the first).
Here are two attempts, both of which result in no text string drawn:
UIGraphicsBeginImageContext(canvas.frame.size)
canvas.draw(CGRect(x: 0, y: 0, width: canvas.frame.width, height: canvas.frame.height))
let context = UIGraphicsGetCurrentContext()
let text="\(value)"
let font=UIFont(name: "Helvetica", size: 12)!
let text_style=NSMutableParagraphStyle()
text_style.alignment=NSTextAlignment.center
let text_color=UIColor.black
let attributes=[NSAttributedString.Key.font:font, NSAttributedString.Key.paragraphStyle:text_style, NSAttributedString.Key.foregroundColor:text_color]
let text_x=coordinates.x
let text_y=coordinates.y
let text_rect=CGRect(x: text_x, y: text_y, width: 100, height: font.lineHeight)
text.draw(in: text_rect.integral, withAttributes: attributes)
UIGraphicsEndImageContext()
}```
```override func draw(_ canvas: UIImageView, _ isRed: Bool) {
UIGraphicsBeginImageContext(canvas.frame.size)
canvas.draw(CGRect(x: 0, y: 0, width: canvas.frame.width, height: canvas.frame.height))
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 512, height: 512))
let img = renderer.image { ctx in
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center
let attrs = [NSAttributedString.Key.font: UIFont(name: "HelveticaNeue-Thin", size: 12)!, NSAttributedString.Key.paragraphStyle: paragraphStyle]
let string = "\(value)"
string.draw(with: CGRect(x: coordinates.x, y: coordinates.y, width: 100, height: 16), options: .usesLineFragmentOrigin, attributes: attrs, context: nil)
}
}```
I have also found a page that says something to the effect that it is impossible to draw text in a UIImageView. However, it is hard to believe that a graphics system as mature as core graphics wouldn't allow drawing text.
I am working on a barChart in MacOS Cocoa, using core Graphic. The program embed some usefull link for SwiftUI. You should be able to adapt my code to your program.
// ViewController.swift
// Chart_test
//
// Created by slareau on 2022-10-10.
//
//https://stackoverflow.com/questions/38079917/drawing-in-cocoa-swift
//https://www.raywenderlich.com/1101-core-graphics-on-macos-tutorial
//https://stackoverflow.com/questions/10627557/mac-os-x-drawing-into-an-offscreen-nsgraphicscontext-using-cgcontextref-c-funct
//https://nshipster.com/cggeometry/
//https://www.hackingwithswift.com/example-code/core-graphics/how-to-draw-a-text-string-using-core-graphics
import Cocoa
import CoreGraphics
class ChartsVC: NSViewController {
override func viewDidLoad() {
super.viewDidLoad()
print("ChartsVC")
let dr = Drawing(frame: NSRect(x: 50, y: 50, width: 550, height: 500))
self.view.addSubview(dr)
}
}
class Drawing: NSView {
//var facts_list: [myfact] = [] //complete database
//var fact_total: [Double] = [0.0, 0.0, 0.0, 0.0, 0.0]
var fact_total: [Double] = [12454, 22365,18989, 14656, 16777]
//var comm_list: [myComm] = []
// var comm_total: [Double] = [0.0, 0.0, 0.0, 0.0, 0.0]
var comm_total: [Double] = [8976, 14989, 13989, 12065, 15040]
var Current_Year : Int = 0
var Current_Month : Int = 0
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
let todays_date = Date()
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy"
Current_Year = Int(dateFormatter.string(from: todays_date))!
// print ("VC60:", dateFormatter.string(from: todays_date), Current_Year!) OK
dateFormatter.dateFormat = "MM"
Current_Month = Int(dateFormatter.string(from: todays_date))!
// Load_CommDB()
// Load_FactDB()
var j = 0
while j < 5 { // last 5 years
print ( "VCH165: ", Current_Year - j, fact_total[j], comm_total[j] )
j += 1
}
// frame background
myRect(x: 0, y: 0, wd: 500, ht: 400, color: NSColor.lightGray)
for i in 0...4 {
let factT = String(fact_total[4 - i])
let commT = String(comm_total[4 - i])
let diffT = String(fact_total[4 - i] - comm_total[4 - i])
myRect(x: Double(50 + 80 * i) , y: 80, wd: 50, ht: Double(fact_total[4 - i]/100), color: NSColor.blue)
myRect(x: Double(60 + 80 * i) , y: 80, wd: 50, ht: Double(comm_total[4 - i]/100), color: NSColor.orange)
myRect(x: Double(70 + 80 * i) , y: 80, wd: 50, ht: Double((fact_total[4 - i] - comm_total[4 - i])/100), color: NSColor.green)
mytext(txt: factT.toCurrencyFormat(), x: Double(45 + 80 * i), y: 60)
mytext(txt: commT.toCurrencyFormat(), x: Double(45 + 80 * i), y: 40)
mytext(txt: diffT.toCurrencyFormat(), x: Double(45 + 80 * i), y: 20)
mytext(txt: String(Current_Year - (4 - i)), x: Double(45 + 80 * i), y: 3)
}
let context = NSGraphicsContext.current!.cgContext;
context.beginPath()
context.move(to: CGPoint(x: 10.0, y: 78.0))
context.addLine(to: CGPoint(x: 420.0, y: 78.0))
//context.setStrokeColor(red: 1, green: 0, blue: 0, alpha: 1)
context.setStrokeColor(NSColor.black.cgColor)
context.setLineWidth(2.0)
context.strokePath()
}
func myRect( x: Double, y: Double, wd: Double, ht :Double, color: NSColor) {
let context = NSGraphicsContext.current!.cgContext;
let rectangle = CGRect(x: x, y: y, width: wd, height: ht) // NSRect or CGRect work
//context.setFillColor(NSColor.yellow.cgColor)
context.setFillColor(color.cgColor)
context.setStrokeColor(NSColor.black.cgColor)
context.setLineWidth(2)
context.addRect(rectangle)
context.drawPath(using: .fillStroke)
}
func mytext ( txt: String, x: Double, y: Double) {
// let myfont = CTFontCreateWithName("Papyrus" as CFString, 12, nil)
let myfont = CTFontCreateWithName("Arial" as CFString, 12, nil)
let attributedString = NSAttributedString(string: txt, attributes: [NSAttributedString.Key.font: myfont])
// let attributedString = NSAttributedString(string: "allo le monde")
let line = CTLineCreateWithAttributedString(attributedString)
// Draw text
if let context = NSGraphicsContext.current?.cgContext {
context.textPosition = .init(x: x , y: y)
CTLineDraw(line, context)
}
//https://blog.krzyzanowskim.com/2020/07/09/coretext-academy-part-1/
//https://blog.krzyzanowskim.com/2020/07/10/coretext-swift-academy-part-2/
//https://blog.krzyzanowskim.com/2020/07/13/coretext-swift-academy-part-3/
}
}

SpriteKit progressive darkness in the Scene

Does anyone know how to bring progressive darkness on a SKScene using SpriteKit?
I tried using a SKLightNode, which works great to have "full" darkness in the scene and some light sources.
I also tried to have a Node in front of everything else where I adjust the alpha to get darker and darker. which works great if there is on light source
But the 2 solutions doesn't work together.
In my example, the blue bar on the buttom control the alpha of the "darkness" node (left = 0 so fully transparent, right = 1 so fully dark), and the white part on the buttom left is to swift on and off the light.
My goal would be to use the bar on the buttom to go from light on to light off, with a gradual transition.
class GameScene: SKScene {
override func didMove(to view: SKView) {
let background = SKSpriteNode(color: .lightGray, size: view.frame.size) //imageNamed: "background")
background.zPosition = 1
background.position = CGPoint(x: view.frame.midX, y: view.frame.midY)
background.lightingBitMask = 0b0001
addChild(background)
let character = SKSpriteNode(color: .blue, size: CGSize(width: 100, height: 100))//imageNamed: "character")
character.zPosition = 2
character.position = CGPoint(x: view.frame.midX, y: view.frame.midY)
character.lightingBitMask = 0b0001
character.shadowCastBitMask = 0b0001
addChild(character)
let lightNode = SKLightNode()
lightNode.name = "SKLightNode"
lightNode.position = CGPoint(x: view.frame.midX, y: view.frame.midY)
lightNode.categoryBitMask = 0b0001
lightNode.lightColor = .white
//lightNode.ambientColor = .white
lightNode.isEnabled = false
addChild(lightNode)
// Control
let elementSize: CGFloat = 40
let lightToggleSize = CGSize(width: elementSize, height: elementSize)
let lightToggle = SKSpriteNode(color: .white, size: lightToggleSize)
lightToggle.name = "LightToggle"
lightToggle.zPosition = 10000
lightToggle.position = CGPoint(x: view.frame.width - (elementSize / 2), y: elementSize / 2)
addChild(lightToggle)
let sideBarSize = CGSize(width: view.frame.width - elementSize, height: elementSize)
let sideBar = SKSpriteNode(color: .blue, size: sideBarSize)
sideBar.name = "SideBar"
sideBar.position = CGPoint(x:(view.frame.width - elementSize) / 2, y: elementSize / 2)
sideBar.zPosition = lightToggle.zPosition
addChild(sideBar)
let darknessNodeSize = CGSize(width: view.frame.width , height: view.frame.height)
let darknessNode = SKSpriteNode(color: .black, size: darknessNodeSize)
darknessNode.name = "DarknessNode"
darknessNode.position = CGPoint(x: view.frame.width / 2, y: view.frame.height / 2)
darknessNode.alpha = 0
darknessNode.zPosition = lightToggle.zPosition - 1
addChild(darknessNode)
}
func handleTouches(_ point: CGPoint) {
let darknessNode = childNode(withName: "DarknessNode") as! SKSpriteNode
let lightNode = childNode(withName: "SKLightNode") as! SKLightNode
let sideBar = childNode(withName: "SideBar")!
let lightToggle = childNode(withName: "LightToggle") as! SKSpriteNode
if lightToggle.contains(point) {
lightNode.isEnabled = !lightNode.isEnabled
lightNode.position = CGPoint(x: lightNode.position.x + 1, y: lightNode.position.y)
} else if sideBar.contains(point) {
darknessNode.alpha = point.x / sideBar.frame.width
} else {
lightNode.position = point
}
}
override func touchesBegan(_ touches: Set<UITouch>,
with event: UIEvent?) {}
override func touchesMoved(_ touches: Set<UITouch>,
with event: UIEvent?) {
handleTouches(touches.first?.location(in: self))
}
override func touchesEnded(_ touches: Set<UITouch>,
with event: UIEvent?) {
handleTouches(touches.first?.location(in: self))
}
}
If anyone is interested by this topic, here is the solution I found: change the alpha of the source.
It works well on a Playground project but on my real one the local light is having a weird flat shape (I posted a question here
This is what it looks like.
And the solution is bellow.
There is a green background and blue box on the scene.
There are 2 white boxes that are switches for the Background light (that simulate the sunlight, which is off screen) and the "local" light (that simulate a light source)
The white bar is to control the alpha of the background light (on the left the alpha is 0 and there is no light and the right the alpha is 1 and the light is full on). In the same time as I change the alpha of the background light, I also change the alpha of the local light for better look.
import Foundation
import SpriteKit
class GameScene: SKScene {
var background: SKSpriteNode!
var object: SKSpriteNode!
var backgroundLight: SKLightNode!
var localLight: SKLightNode!
var backgroundLightSwitch: SKSpriteNode!
var backgroundLightBar: SKSpriteNode!
var localLightSwitch: SKSpriteNode!
var isBackgroundLightOn = false
var isLocalLightOn = false
var selectedElement: SKSpriteNode? = nil
class func newGameScene() -> GameScene {
let scene = GameScene()
scene.scaleMode = .resizeFill
return scene
}
override func didMove(to view: SKView) {
super.didMove(to: view)
let objectSize = CGSize(width: 100, height: 100)
background = createSpriteNode(color: .green, x: view.frame.width / 2, y: view.frame.height / 2, width: view.frame.width, height: view.frame.height, lightMask: 1)
object = createSpriteNode(color: .blue, x: background.position.x, y: background.position.y, width: objectSize.width, height: objectSize.height, lightMask: 1)
backgroundLightSwitch = createSpriteNode(color: .white, x: view.frame.width / 4, y: view.frame.height / 4, width: objectSize.width / 2, height: objectSize.height / 2, lightMask: 2)
backgroundLightBar = createSpriteNode(color: .white, x:view.frame.width * 2 / 3, y: backgroundLightSwitch.position.y, width: view.frame.width / 2, height: objectSize.height / 2, lightMask: 2)
localLightSwitch = createSpriteNode(color: .white, x: backgroundLightSwitch.position.x, y: view.frame.height / 8, width: objectSize.width / 2, height: objectSize.height / 2, lightMask: 2)
let color = NSColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.5)
backgroundLight = createLightNode(color: nil, ambiantColor: color, x: -view.frame.width * 2, y: -view.frame.height * 2)
localLight = createLightNode(color: color, ambiantColor: nil, x:view.frame.width * 2 / 3, y: view.frame.height * 2 / 3)
}
func createSpriteNode(color: NSColor, x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat, lightMask: UInt32) -> SKSpriteNode {
let nodePosition = CGPoint(x: x, y: y)
let nodeSize = CGSize(width: width, height: height)
let node = SKSpriteNode(color: color, size: nodeSize)
node.zPosition = 1
node.position = nodePosition
node.lightingBitMask = lightMask
addChild(node)
return node
}
func createLightNode(color: NSColor?, ambiantColor: NSColor? , x: CGFloat, y: CGFloat) -> SKLightNode {
let light = SKLightNode()
light.falloff = 1.5
light.position = CGPoint(x:x, y: y)
light.isEnabled = false
light.categoryBitMask = 1
if color != nil {
light.lightColor = color!
}
if ambiantColor != nil {
light.ambientColor = ambiantColor!
}
addChild(light)
return light
}
func getSelectElement(location: CGPoint) -> SKSpriteNode? {
let elements: [SKSpriteNode] = [backgroundLightSwitch, localLightSwitch, backgroundLightBar]
for element in elements {
if element.contains(location) {
return element
}
}
return nil
}
func switchLight(_ light: SKLightNode, selected: Bool) -> Bool {
light.isEnabled = selected
light.position.x += selected ? 1 : -1
return selected
}
func getAlpha(node: SKSpriteNode, location: CGPoint) -> CGFloat {
if location.x < node.frame.minX {
return 0
} else if location.x > node.frame.maxX {
return 1
} else {
return (location.x - node.frame.minX) / node.frame.width
}
}
func changeAlpha(_ alpha: CGFloat) {
backgroundLight.ambientColor = NSColor(red: 1.0, green: 1.0, blue: 1.0, alpha: alpha)
localLight.lightColor = NSColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 1 - alpha)
backgroundLight.position.x -= 1
}
override func mouseDown(with event: NSEvent) {
let location = event.location(in: self)
selectedElement = getSelectElement(location: location)
}
override func mouseDragged(with event: NSEvent) {
if selectedElement == backgroundLightBar {
let location = event.location(in: self)
let newAlpha = getAlpha(node: backgroundLightBar, location: location)
changeAlpha(newAlpha)
}
}
override func mouseUp(with event: NSEvent) {
let location = event.location(in: self)
let newSelectedElement = getSelectElement(location: location)
if selectedElement == newSelectedElement {
if selectedElement == backgroundLightSwitch {
isBackgroundLightOn = switchLight(backgroundLight, selected: !isBackgroundLightOn)
} else if selectedElement == localLightSwitch {
isLocalLightOn = switchLight(localLight, selected: !isLocalLightOn)
}
}
}
}

curve in tabbar view and bottom popup not working?

Hello all, I tried to add arc for UIBezierPath I could not able to get the exact curve,
here is my code here I have added the bezier path for the added curve from the center position.
#IBDesignable
class MyTabBar: UITabBar {
private var shapeLayer: CALayer?
private func addShape() {
let shapeLayer = CAShapeLayer()
shapeLayer.path = createPath()
shapeLayer.strokeColor = UIColor.lightGray.cgColor
shapeLayer.fillColor = UIColor.white.cgColor
shapeLayer.lineWidth = 1.0
//The below 4 lines are for shadow above the bar. you can skip them if you do not want a shadow
shapeLayer.shadowOffset = CGSize(width:0, height:0)
shapeLayer.shadowRadius = 10
shapeLayer.shadowColor = UIColor.gray.cgColor
shapeLayer.shadowOpacity = 0.3
if let oldShapeLayer = self.shapeLayer {
self.layer.replaceSublayer(oldShapeLayer, with: shapeLayer)
} else {
self.layer.insertSublayer(shapeLayer, at: 0)
}
self.shapeLayer = shapeLayer
}
override func draw(_ rect: CGRect) {
self.addShape()
}
func createPath() -> CGPath {
let height: CGFloat = 37.0
let path = UIBezierPath()
let centerWidth = self.frame.width / 2
path.move(to: CGPoint(x: 0, y: 0)) // start top left
path.addLine(to: CGPoint(x: (centerWidth - height * 2), y: 0)) // the beginning of the trough
path.addCurve(to: CGPoint(x: centerWidth, y: height),
controlPoint1: CGPoint(x: (centerWidth - 30), y: 0), controlPoint2: CGPoint(x: centerWidth - 35, y: height))
path.addCurve(to: CGPoint(x: (centerWidth + height * 2), y: 0),
controlPoint1: CGPoint(x: centerWidth + 35, y: height), controlPoint2: CGPoint(x: (centerWidth + 30), y: 0))
path.addLine(to: CGPoint(x: self.frame.width, y: 0))
path.addLine(to: CGPoint(x: self.frame.width, y: self.frame.height))
path.addLine(to: CGPoint(x: 0, y: self.frame.height))
path.close()
return path.cgPath
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard !clipsToBounds && !isHidden && alpha > 0 else { return nil }
for member in subviews.reversed() {
let subPoint = member.convert(point, from: self)
guard let result = member.hitTest(subPoint, with: event) else { continue }
return result
}
return nil
}
}
this is tab bar controller added plus button in center view center, and the when tap the plus button to add the curve based popup should show, I don't know how to add curve based popup.
class TabbarViewController: UITabBarController,UITabBarControllerDelegate {
override func viewDidLoad() {
super.viewDidLoad()
self.delegate = self
self.navigationController?.navigationBar.isHidden = true
setupMiddleButton()
}
// TabBarButton – Setup Middle Button
func setupMiddleButton() {
let middleBtn = UIButton(frame: CGRect(x: (self.view.bounds.width / 2)-25, y: -20, width: 50, height: 50))
middleBtn.setImage(UIImage(named: "PlusBtn"), for: .normal)
self.tabBar.addSubview(middleBtn)
middleBtn.addTarget(self, action: #selector(self.menuButtonAction), for: .touchUpInside)
self.view.layoutIfNeeded()
}
// Menu Button Touch Action
#objc func menuButtonAction(sender: UIButton) {
//show the popUp
}
}
Please share me the findings & share me your refreance
Thanks
New edit:
I created a general-purpose method that will generate polygons with a mixture of sharp and rounded corners of different radii. I used it to create a project with a look rather like what you are after. You can download it from Github:
https://github.com/DuncanMC/CustomTabBarController.git
Here's what it looks like:
Note that the area of the tab's view controller that extends into the tab bar controller does not get taps. If you try to tap there, it triggers the tab bar controller. You will have to do some more tinkering to get that part to work.
Ultimately you may have to create a custom UITabBar (or UITabBar-like component) and possibly a custom parent view controller that acts like a UITabBar, in order to get what you want.
The method that creates polygon paths is called buildPolygonPathFrom(points:defaultCornerRadius:)
It takes an array of PolygonPoint structs. Those are defined like this:
/// A struct describing a single vertex in a polygon. Used in building polygon paths with a mixture of rounded an sharp-edged vertexes.
public struct PolygonPoint {
let point: CGPoint
let isRounded: Bool
let customCornerRadius: CGFloat?
init(point: CGPoint, isRounded: Bool, customCornerRadius: CGFloat? = nil) {
self.point = point
self.isRounded = isRounded
self.customCornerRadius = customCornerRadius
}
init(previousPoint: PolygonPoint, isRounded: Bool) {
self.init(point: previousPoint.point, isRounded: isRounded, customCornerRadius: previousPoint.customCornerRadius)
}
}
The code to build the path for the custom tab bar looks like this:
func tabBarMaskPath() -> CGPath? {
let width = bounds.width
let height = bounds.height
guard width > 0 && height > 0 else { return nil }
let dentRadius: CGFloat = 35
let cornerRadius: CGFloat = 20
let topFlatPartWidth = (width - dentRadius * 2.0) / 2
let polygonPoints = [
PolygonPoint(point: CGPoint(x: 0, y: 0), // Point 0
isRounded: true,
customCornerRadius: cornerRadius),
PolygonPoint(point: CGPoint(x: 0, y: height), // Point 1
isRounded: false),
PolygonPoint(point: CGPoint(x: width, y: height), // Point 2
isRounded: false),
PolygonPoint(point: CGPoint(x: width, y: 0), // Point 3
isRounded: true,
customCornerRadius: cornerRadius),
PolygonPoint(point: CGPoint(x: topFlatPartWidth + dentRadius * 2, y: 0), // Point 4
isRounded: true,
customCornerRadius: cornerRadius),
PolygonPoint(point: CGPoint(x: topFlatPartWidth + dentRadius * 2, y: dentRadius + cornerRadius), // Point 5
isRounded: true,
customCornerRadius: dentRadius),
PolygonPoint(point: CGPoint(x: topFlatPartWidth , y: dentRadius + cornerRadius), // Point 6
isRounded: true,
customCornerRadius: dentRadius),
PolygonPoint(point: CGPoint(x: topFlatPartWidth , y: 0), // Point 7
isRounded: true,
customCornerRadius: cornerRadius),
]
return buildPolygonPathFrom(points: polygonPoints, defaultCornerRadius: 0)
}
Previous answer:
I just tried it, and it is possible to subclass UITabBar. I created a subclass of UITabBar where I use a mask layer to cut a circular "notch" out of the top of the tab bar. The code is below. It looks like the screenshot below. It isn't quite what you're after, but it's a start:
(The background color for the "Page 1" view controller is set to light gray, and you can see that color showing through in the "notch" I cut out of the tab bar.)
//
// CustomTabBar.swift
// TabBarController
//
// Created by Duncan Champney on 3/31/21.
//
import UIKit
class CustomTabBar: UITabBar {
var maskLayer = CAShapeLayer()
override var frame: CGRect {
didSet {
configureMaskLayer()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
configureMaskLayer()
self.layer.mask = maskLayer
self.layer.borderWidth = 0
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configureMaskLayer()
self.layer.mask = maskLayer
}
func configureMaskLayer() {
let rect = layer.bounds
maskLayer.frame = rect
let circleBoxSize = rect.size.height * 1.25
maskLayer.fillRule = .evenOdd
let path = UIBezierPath(rect: rect)
let circleRect = CGRect(x: rect.size.width/2 - circleBoxSize / 2,
y: -circleBoxSize/2,
width: circleBoxSize,
height: circleBoxSize)
let circle = UIBezierPath.init(ovalIn: circleRect)
path.append(circle)
maskLayer.path = path.cgPath
maskLayer.fillColor = UIColor.white.cgColor // Any opaque color works and has no effect
}
}
Edit:
To draw your popover view you'll need to create a filled path that shape. You'll have to construct a custom shape like that with a combination of lines and arcs. I suggest using a CGMutablePath and the method addArc(tangent1End:tangent2End:radius:transform:) since that enables you to provide endpoints rather than angles.
Edit #2:
Another part of the puzzle:
Here is a custom UIView subclass that masks itself in the shape you're after
//
// ShapeWithTabView.swift
// ShapeWithTab
//
// Created by Duncan Champney on 4/1/21.
//
import UIKit
class ShapeWithTabView: UIView {
var cornerRadius: CGFloat = 20
var tabRadius: CGFloat = 60
var tabExtent: CGFloat = 0
var shapeLayer = CAShapeLayer()
var maskLayer = CAShapeLayer()
func buildShapeLayerPath() -> CGPath {
let boxWidth = min(bounds.size.width - 40, 686)
let boxHeight = min(bounds.size.height - 40 - tabRadius * 2 - tabExtent, 832)
// These are the corners of the view's primary rectangle
let point1 = CGPoint(x: 0, y: boxHeight)
let point2 = CGPoint(x: 0, y: 0)
let point3 = CGPoint(x: boxWidth, y: 0)
let point4 = CGPoint(x: boxWidth, y: boxHeight)
// These are the corners of the "tab" that extends outside the view's normal bounds.
let tabPoint1 = CGPoint(x: boxWidth / 2 + tabRadius, y: boxHeight)
let tabPoint2 = CGPoint(x: boxWidth / 2 + tabRadius, y: boxHeight + tabExtent + tabRadius * 2 )
let tabPoint3 = CGPoint(x: boxWidth / 2 - tabRadius, y: boxHeight + tabExtent + tabRadius * 2)
let tabPoint4 = CGPoint(x: boxWidth / 2 - tabRadius , y: boxHeight)
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: boxHeight - cornerRadius))
path.addArc(tangent1End: point2,
tangent2End: point3,
radius: cornerRadius)
path.addArc(tangent1End: point3,
tangent2End: point4,
radius: cornerRadius)
path.addArc(tangent1End: point4,
tangent2End: point1,
radius: cornerRadius)
//
path.addArc(tangent1End: tabPoint1,
tangent2End: tabPoint2,
radius: tabRadius)
path.addArc(tangent1End: tabPoint2,
tangent2End: tabPoint3,
radius: tabRadius)
path.addArc(tangent1End: tabPoint3,
tangent2End: tabPoint4,
radius: tabRadius)
path.addArc(tangent1End: tabPoint4,
tangent2End: point1,
radius: tabRadius)
path.addArc(tangent1End: point1,
tangent2End: point2,
radius: cornerRadius)
return path
}
func doInitSetup() {
self.layer.addSublayer(shapeLayer)
self.layer.mask = maskLayer
backgroundColor = .lightGray
//Configure a shape layer to draw an outline
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.strokeColor = UIColor.blue.cgColor
shapeLayer.lineWidth = 2
//Configure a mask layer to mask the view to our custom shape
maskLayer.fillColor = UIColor.white.cgColor
maskLayer.strokeColor = UIColor.white.cgColor
maskLayer.lineWidth = 2
}
override init(frame: CGRect) {
super.init(frame: frame)
self.doInitSetup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
self.doInitSetup()
}
public func updateShapeLayerPath() {
let path = buildShapeLayerPath()
shapeLayer.path = path
maskLayer.path = path
}
override var frame: CGRect {
didSet {
print("New frame = \(frame)")
shapeLayer.frame = layer.bounds
}
}
}
Combined with the modified tab bar from above, it looks like the image below. The final task is to get the custom view sized and positioned correctly, and have it land on top of the tab bar.

Why are these CAShapeLayers not going where expected?

I'm working on a custom loading indicator and am having a lot of issues with CAShapeLayers.
The loading indicator will be contained within a custom UIView so that any viewController can use it.
First issue:
The frame of the subview is not matching the bounds.
When using this code to display a circle in each corner of the frame the circles are placed in a square shape but it is no where near the view.
import UIKit
View Controller:
class MergingCicles: UIViewController, HolderViewDelegate {
func animateLabel() {
}
var holderView = HolderView(frame: CGRect.zero)
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
addHolderView()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
func addHolderView() {
let boxSize: CGFloat = 100.0
holderView.frame = CGRect(x: view.bounds.width / 2 - boxSize / 2,
y: view.bounds.height / 2 - boxSize / 2,
width: boxSize,
height: boxSize)
holderView.parentFrame = view.frame
holderView.delegate = self
holderView.center = self.view.center
view.addSubview(holderView)
holderView.addCircleLayer()
}
}
Subview:
Import UIKit
protocol HolderViewDelegate:class {
func animateLabel()
}
class HolderView: UIView {
let initalLayer = InitialLayer()
let triangleLayer = TriangleLayer()
let redRectangleLayer = RectangleLayer()
let blueRectangleLayer = RectangleLayer()
let arcLayer = ArcLayer()
var parentFrame :CGRect = CGRect.zero
weak var delegate:HolderViewDelegate?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.clear
}
required init(coder: NSCoder) {
super.init(coder: coder)!
}
func addCircleLayer() {
var circleLocations = [CGPoint]()
let offset = CircleLayer().maxSize / 2
circleLocations.append(CGPoint(x: self.frame.maxX - offset, y: self.frame.maxY - offset))
circleLocations.append(CGPoint(x: self.frame.minX + offset, y: self.frame.minY + offset))
circleLocations.append(CGPoint(x: self.frame.maxX - offset, y: self.frame.minY + offset))
circleLocations.append(CGPoint(x: self.frame.minX + offset, y: self.frame.maxY - offset))
circleLocations.append(layer.anchorPoint)
for point in circleLocations {
let circle = CircleLayer()
circle.updateLocation(Size: .medium, center: point)
self.layer.addSublayer(circle)
}
self.backgroundColor = .blue
// layer.anchorPoint = CGPoint(x: (self.bounds.maxX + self.bounds.maxX)/2, y: (self.bounds.maxY + self.bounds.minY)/2)
let rotationAnimation: CABasicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.toValue = CGFloat(Double.pi * 2)
rotationAnimation.duration = 0.45
rotationAnimation.isCumulative = true
//rotationAnimation.repeatCount = 1000
//rotationAnimation.isRemovedOnCompletion = true
// layer.add(rotationAnimation, forKey: nil)
}
}
Circle Layer:
import Foundation
import UIKit
enum ShapeSize {
case medium
case small
case large
}
class CircleLayer: CAShapeLayer {
let animationDuration: CFTimeInterval = 0.3
let maxSize = CGFloat(50)
override init() {
super.init()
fillColor = UIColor.red.cgColor
}
func updateLocation(Size: ShapeSize, center: CGPoint){
switch Size {
case .medium:
path = UIBezierPath(ovalIn: CGRect(x: center.x, y: center.y, width: maxSize/3, height: maxSize/3)).cgPath
case .small:
path = UIBezierPath(ovalIn: CGRect(x: center.x, y: center.y, width: (2*maxSize)/3, height: (2*maxSize)/3)).cgPath
case .large:
path = UIBezierPath(ovalIn: CGRect(x: center.x, y: center.y, width: maxSize, height: maxSize)).cgPath
}
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Result:
This really shows that the frame is no where near the uiView.
If I change addCircleLayer to use bounds instead I get something much closer:
But still the circles are not in the corners (except the bottom right one, that one is correct). It appears there is some extra space on the left and top of the view that is not captured using self.bounds.
The ultimate goal is to also rotate the circles 360 degrees around the center but as shown by the circle in the upper left corner the layer anchor is not in the center of the view, I changed the anchor to be the center of the circles but then nothing appeared on screen at all.
You're saying things like
circleLocations.append(CGPoint(x: self.frame.maxX - offset, y: self.frame.maxY - offset))
But self.frame is where the view is located in its own superview. Thus, the shape layer ends up offset from the view by as much as the view is offset from its own superview. Wherever you say frame here, you mean bounds.
I found the problem was then when drawing the circles I was using UIBezierPath(ovalIn:CGRect, width: CGFloat, height: CGFloat which was using the x value for the left side of the circle. When I changed to UIBezierPath(arcCenter: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat, clockwise: Bool) the point was used for the center of the circle and made it all fit where expected when using self.bounds to calculate the points.
After that I no longer had to change the anchor point as it was in the correct location by default.
I didn't figure out why the frame is in a completely different spot but it is no longer impacting the project.

Reload values for draw circle

Hi I'm creating a semicircle that updates your line widh depending on the values I give you that are always varying. I want to know how to update the values to determine the end angle and that these are always updated. Thanks
class Circle: UIView {
//These 2 var (total dictionary and Secondary dictionary) have the number of the keys and the values ​​are always varying because they are obtained from json data
var dictionaryTotal: [String:String] = ["a":"153.23", "b":"162.45", "c":"143.65", "d":"140.78", "e":"150.23"]
var dictionarySecundario: [String:String] = ["w":"153.23", "l":"162.45", "v":"143.65"]
var lineWidth: CGFloat = 25
// circles radius
var half = CGFloat.pi
var quarter = CGFloat(3 * CGFloat.pi / 2)
func drawCircleCenteredAtVariavel(center:CGPoint, withRadius radius:CGFloat) -> UIBezierPath{
let circlePath = UIBezierPath(arcCenter: centerOfCircleView, radius: halfOfViewsSize, startAngle: half, endAngle: CGFloat(((CGFloat(self.dictionarySecundario.count) * CGFloat.pi) / CGFloat(self. dictionaryTotal.count)) + CGFloat.pi), clockwise: true)
circlePath.lineWidth = lineWidth
UIColor(red: 0, green: 0.88, blue: 0.70, alpha: 1).set()
return circlePath
}
override func draw(_ rect: CGRect) {
drawCircleCenteredAtVariavel(center: centerOfCircleView, withRadius: halfOfViewsSize).stroke()
}
}
This is the picture of semicircle
It's not clear from your question what you're trying to accomplish, so this isn't really answer, but it's more than I wanted to type in a comment.
The first thing you should do is separate responsibilities in so the Circle class above should only be responsible for drawing the semicircle for a particular configuration.
For example, a semicircular progress view based on your code above:
class CircleProgressView: UIView {
var lineWidth: CGFloat = 25
var barColor = UIColor(red: 0, green: 0.88, blue: 0.70, alpha: 1)
var progress: Float = 0
{
didSet {
progress = max(min(progress, 1.0), 0.0)
setNeedsDisplay()
}
}
func semicirclePath(at center: CGPoint, radius: CGFloat, percentage: Float) -> UIBezierPath {
let endAngle = CGFloat.pi + (CGFloat(percentage) * CGFloat.pi)
let circlePath = UIBezierPath(arcCenter: center, radius: radius, startAngle: CGFloat.pi, endAngle: endAngle, clockwise: true)
circlePath.lineWidth = lineWidth
return circlePath
}
override func draw(_ rect: CGRect) {
barColor.set()
let center = CGPoint.init(x: bounds.midX, y: bounds.midY)
let radius = max(bounds.width, bounds.height)/2 - lineWidth/2
semicirclePath(at: center, radius: radius, percentage: progress).stroke()
}
}
Then, outside of this class, you would have other code that would determine what progress is based on the JSON values you're receiving.
Assuming that your drawCircleCenteredAtVariavel works, you should redraw whenever the lineWidth value is changed. Try this
var lineWidth: CGFloat = 25 {
didSet {
setNeedsDisplay()
}
}
override func draw(_ rect: CGRect) {
super.draw(rect)
drawCircleCenteredAtVariavel(center: centerOfCircleView, withRadius: halfOfViewsSize).stroke()
}