Search code examples
swiftcocoacalayer

CALayer vertically centered text XCode 11.6


I am working on building a PDF document and the images and texts are being written to a view as CALayers. I am needing to vertically center my text within the bounding frame of a CATextLayer. I am using a class I found from 2016 as shown below that overrides the draw function. I was wondering if there are any new tricks to make this work?

As you can see when you run this code the text for cell 2 is not even being displayed, and cell 3 text is not being vertically centered.

Massive thanks to anyone who can help me.

//
//  ViewController.swift
//  CALayers Example
//
//  Created by Thomas Carroll on 8/18/20.
//  Copyright © 2020 Thomas Carroll. All rights reserved.
//

import Cocoa

class ViewController: NSViewController {

    let myLayers = MyLayers()
    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.wantsLayer = true
        self.view.layer?.addSublayer(myLayers.insertGrid())
        self.view.layer?.addSublayer(myLayers.insertText())

        // Do any additional setup after loading the view.
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }


}
//
//  MyLayers.swift
//  CALayers Example
//
//  Created by Thomas Carroll on 8/18/20.
//  Copyright © 2020 Thomas Carroll. All rights reserved.
//

import Cocoa

// Set up constant variables
let pageWidth:Float = 72*8.5
let pageHeight:Float = 72*11
// Set up coordinates
let leftX = Int(pageWidth/2-72*2.5)
let col1X = Int(leftX+72)
let col2X = Int(col1X+72)
let col3X = Int(col2X+72)
let col4X = Int(col3X+72)
let rightX = Int(col4X+72)
let bottomY = Int(pageHeight/2-72*2.5)
let row4Y = Int(bottomY+72)
let row3Y = Int(row4Y+72)
let row2Y = Int(row3Y+72)
let row1Y = Int(row2Y+72)
let topY = Int(row1Y+72)

// Set the extension to draw Bezier paths into a CAShapeLayer
extension NSBezierPath {
    // Credit - Henrick - 9/18/2016
    // https://stackoverflow.com/questions/1815568/how-can-i-convert-nsbezierpath-to-cgpath
    public var cgPath: CGPath {
        let path = CGMutablePath()
        var points = [CGPoint](repeating: .zero, count: 3)
        for i in 0 ..< self.elementCount {
            let type = self.element(at: i, associatedPoints: &points)
            switch type {
            case .moveTo:
                path.move(to: points[0])
            case .lineTo:
                path.addLine(to: points[0])
            case .curveTo:
                path.addCurve(to: points[2], control1: points[0], control2: points[1])
            case .closePath:
                path.closeSubpath()
            @unknown default:
                print("Error occured in NSBezierPath extension.")
            }
        }
        return path
    }
}

class MyLayers {
    
    class VerticallyAlignedTextLayer : CATextLayer {

        func calculateMaxLines() -> Int {
            let maxSize = CGSize(width: frame.size.width, height: frame.size.height)
            let font = NSFont(descriptor: self.font!.fontDescriptor, size: self.fontSize)
            let charSize = (font?.capHeight)!
            let text = (self.string ?? "") as! NSString
            let textSize = text.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font!], context: nil)
            let linesRoundedUp = Int(ceil(textSize.height/charSize))
            return linesRoundedUp
        }
        
        override func draw(in context: CGContext) {
            let height = self.bounds.size.height
            let fontSize = self.fontSize
            let lines = CGFloat(calculateMaxLines())
            let yDiff = (height - lines * fontSize) / 2 - lines * fontSize / 10

            context.saveGState()
            context.translateBy(x: 0, y: yDiff) // Use -yDiff when in non-flipped coordinates (like macOS's default)
            super.draw(in: context)
            context.restoreGState()
        }
    }
        
    func insertGrid() -> CALayer {
        
        /*
         Draws a single table grid of 25 boxes (5 high by 5 wide)
         centered on a letter sized page
         */
        
        // Create a new shape layer for the grid
        let gridLayer = CAShapeLayer()
        
        // Create the path
        let gridPath = NSBezierPath()

        // Assign the grid fill and stroke colors
        gridLayer.strokeColor = NSColor.purple.cgColor
        gridLayer.fillColor = NSColor.clear.cgColor

        // Draw the paths for the grid
        // Create the outside box
        gridPath.move(to: CGPoint(x: leftX, y: bottomY)) // Bottom left corner
        gridPath.line(to: CGPoint(x: leftX, y: topY)) // Column 1, left line
        gridPath.line(to: CGPoint(x: rightX, y: topY)) // Row 1, top line
        gridPath.line(to: CGPoint(x: rightX, y: bottomY)) // Column 5 right line
        gridPath.line(to: CGPoint(x: leftX, y: bottomY)) // Row 5 bottom line
        
        // Add in column lines
        gridPath.move(to: CGPoint(x: col1X, y: topY)) // Between columns 1 & 2
        gridPath.line(to: CGPoint(x: col1X, y: bottomY)) // Line between columns 1 & 2
        gridPath.move(to: CGPoint(x: col2X, y: topY)) // Between columns 2 & 3
        gridPath.line(to: CGPoint(x: col2X, y: bottomY)) // Line between columns 2 & 3
        gridPath.move(to: CGPoint(x: col3X, y: topY)) // Between columns 3 & 4
        gridPath.line(to: CGPoint(x: col3X, y: bottomY)) // Line between columns 3 & 4
        gridPath.move(to: CGPoint(x: col4X, y: topY)) // Between columns 4 & 5
        gridPath.line(to: CGPoint(x: col4X, y: bottomY)) // Line between columns 4 & 5
        // Add in row lines
        gridPath.move(to: CGPoint(x: leftX, y: row1Y)) // Between rows 1 & 2
        gridPath.line(to: CGPoint(x: rightX, y: row1Y)) // Line between rows 1 & 2
        gridPath.move(to: CGPoint(x: leftX, y: row2Y)) // Between rows 2 & 3
        gridPath.line(to: CGPoint(x: rightX, y: row2Y)) // Line between rows 2 & 3
        gridPath.move(to: CGPoint(x: leftX, y: row3Y)) // Between rows 3 & 4
        gridPath.line(to: CGPoint(x: rightX, y: row3Y)) // Line between rows 3 & 4
        gridPath.move(to: CGPoint(x: leftX, y: row4Y)) // Between rows 4 & 5
        gridPath.line(to: CGPoint(x: rightX, y: row4Y)) // Line between rows 4 & 5

        // Close the path
        gridPath.close()
        // Add grid to layer (note the use of the cgPath extension)
        gridLayer.path = gridPath.cgPath

        return gridLayer
    }

    func insertText() -> CALayer {
        
        // Create a CALayer to add the textLayer to
        let myCALayer = CALayer()
        // Set up an array to hold the x coordinate for each column
        let colPosX = [leftX, col1X, col2X, col3X, col4X]
        // Set up an array to hold the y coordinate for the first card
        let rowPosY = [row1Y, row2Y, row3Y, row4Y, bottomY]
        // Set some default text to be used in the textLayers
        let cellText = ["This is some cell 1 text", "Cell 2 text", "This is text cell 3"]
        
        for i in (0...2) {
            let textLayer = VerticallyAlignedTextLayer()
            textLayer.string = cellText[i]
            textLayer.fontSize = 14
            // Set the frame to be 1 pixel smaller than the grid cell to provide 1px padding
            textLayer.frame = CGRect(origin: CGPoint(x: Int(colPosX[i])+1, y: Int(rowPosY[i])+1), size: CGSize(width: 70, height: 70))
            textLayer.alignmentMode = .center
            textLayer.isWrapped = true
            textLayer.foregroundColor = NSColor.black.cgColor
            textLayer.backgroundColor = NSColor.clear.cgColor
            textLayer.truncationMode = .none
            myCALayer.addSublayer(textLayer)
        }
        
        return myCALayer
    }
}

Solution

  • Swift 5.3

    Okay, I finally figured out how to do what I needed. Specifically, I needed to change the fontSize of the textLayer so it would fit within the bounding box horizontally and vertically while also being vertically centered within the bounding box. To do this, I found some code that checks a boundingRect of an attributed string. Also note that I changed the yDiff calculation where the fontSize is being divided by 6.5 rather than 10 and it positions the text better vertically for me.

    class MyLayers {
        
        class VerticallyAlignedTextLayer : CATextLayer {
    
            func calculateMaxLines() -> Int {
                let maxSize = CGSize(width: frame.size.width, height: frame.size.width)
                let font = NSFont(descriptor: self.font!.fontDescriptor, size: self.fontSize)
                let charSize = floor(font!.capHeight)
                let text = (self.string ?? "") as! NSString
                let textSize = text.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font!], context: nil)
                let linesRoundedUp = Int(floor(textSize.height/charSize))
                return linesRoundedUp
            }
            
            override func draw(in context: CGContext) {
                let height = self.bounds.size.height
                let fontSize = self.fontSize
                let lines = CGFloat(calculateMaxLines())
                let yDiff = -(height - lines * fontSize) / 2 - lines * fontSize / 6.5 // Use -(height - lines * fontSize) / 2 - lines * fontSize / 6.5 when in non-flipped coordinates (like macOS's default)
    
                context.saveGState()
                context.translateBy(x: 0, y: yDiff)
                super.draw(in: context)
                context.restoreGState()
            }
        }
            
        func insertGrid() -> CALayer {
            
            /*
             Draws a single table grid of 25 boxes (5 high by 5 wide)
             centered on a letter sized page
             */
            
            // Create a new shape layer for the grid
            let gridLayer = CAShapeLayer()
            
            // Create the path
            let gridPath = NSBezierPath()
    
            // Assign the grid fill and stroke colors
            gridLayer.strokeColor = NSColor.purple.cgColor
            gridLayer.fillColor = NSColor.white.cgColor
    
            // Draw the paths for the grid
            // Create the outside box
            gridPath.move(to: CGPoint(x: leftX, y: bottomY)) // Bottom left corner
            gridPath.line(to: CGPoint(x: leftX, y: topY)) // Column 1, left line
            gridPath.line(to: CGPoint(x: rightX, y: topY)) // Row 1, top line
            gridPath.line(to: CGPoint(x: rightX, y: bottomY)) // Column 5 right line
            gridPath.line(to: CGPoint(x: leftX, y: bottomY)) // Row 5 bottom line
            //gridPath.close()
            //gridLayer.path = gridPath.cgPath
            
            // Add in column lines
            gridPath.move(to: CGPoint(x: col1X, y: topY)) // Between columns 1 & 2
            gridPath.line(to: CGPoint(x: col1X, y: bottomY)) // Line between columns 1 & 2
            gridPath.move(to: CGPoint(x: col2X, y: topY)) // Between columns 2 & 3
            gridPath.line(to: CGPoint(x: col2X, y: bottomY)) // Line between columns 2 & 3
            gridPath.move(to: CGPoint(x: col3X, y: topY)) // Between columns 3 & 4
            gridPath.line(to: CGPoint(x: col3X, y: bottomY)) // Line between columns 3 & 4
            gridPath.move(to: CGPoint(x: col4X, y: topY)) // Between columns 4 & 5
            gridPath.line(to: CGPoint(x: col4X, y: bottomY)) // Line between columns 4 & 5
            // Add in row lines
            gridPath.move(to: CGPoint(x: leftX, y: row1Y)) // Between rows 1 & 2
            gridPath.line(to: CGPoint(x: rightX, y: row1Y)) // Line between rows 1 & 2
            gridPath.move(to: CGPoint(x: leftX, y: row2Y)) // Between rows 2 & 3
            gridPath.line(to: CGPoint(x: rightX, y: row2Y)) // Line between rows 2 & 3
            gridPath.move(to: CGPoint(x: leftX, y: row3Y)) // Between rows 3 & 4
            gridPath.line(to: CGPoint(x: rightX, y: row3Y)) // Line between rows 3 & 4
            gridPath.move(to: CGPoint(x: leftX, y: row4Y)) // Between rows 4 & 5
            gridPath.line(to: CGPoint(x: rightX, y: row4Y)) // Line between rows 4 & 5
    
            // Close the path
            gridPath.close()
            // Add grid to layer (note the use of the cgPath extension)
            gridLayer.path = gridPath.cgPath
    
            return gridLayer
        }
        
        func sizeOfRect(string: NSString, fontSize: CGFloat) -> Int {
            /* Credit to Jake Marsh - 12/10/2015
                https://littlebitesofcocoa.com/144-drawing-multiline-strings
             
                Return the height of a boundingRect for a specified string at a specified fontSize
             */
            let cellFontSize:CGFloat = fontSize
            let cellFont:NSFont = NSFont.systemFont(ofSize: cellFontSize, weight: .regular)
            let cellParagraphStyle = NSMutableParagraphStyle()
            let cellTextAttributes = [NSAttributedString.Key.font: cellFont, NSAttributedString.Key.paragraphStyle: cellParagraphStyle]
            let cellDrawingOptions: NSString.DrawingOptions = [
            .usesLineFragmentOrigin, .usesFontLeading]
            cellParagraphStyle.lineHeightMultiple = 1.0
            cellParagraphStyle.lineBreakMode = .byWordWrapping
            
            return Int(string.boundingRect(with: CGSize(width: 70, height: CGFloat.infinity), options: cellDrawingOptions, attributes: cellTextAttributes).height)
        }
    
        func insertText() -> CALayer {
            
            // Create a CALayer to add the textLayer to
            let myCALayer = CALayer()
            // Set up an array to hold the x coordinate for each column
            let colPosX = [leftX, col1X, col2X, col3X, col4X]
            // Set up an array to hold the y coordinate for the first card
            let rowPosY = [row1Y, row2Y, row3Y, row4Y, bottomY]
            // Set some default text to be used in the textLayers
            let cellText = ["This is some cell 1 text that is kind of long", "Cell 2 text", "This is text cell 3", "Some really really long text"]
            
            for i in (0...3) {
                // Create a vertically centered textLayer
                let textLayer = VerticallyAlignedTextLayer()
                // Set up the initial font size for the text
                var fontSize:CGFloat = 14
                // Assign a string to the textLayer
                textLayer.string = cellText[i]
                // Check the vertical hieght of a rectangle that would contain the text based on the current fontSize.  If the text is taler than the specific box height, reduce the fontSize my a half point until it is within the specified height of the box.
                while sizeOfRect(string: cellText[i] as NSString, fontSize: fontSize) > 68 {
                    fontSize -= 0.5
                }
                // Assign the adjusted fontSize to the textLayer
                textLayer.fontSize = fontSize
                // Set the frame to be 4 pixel smaller than the grid cell to provide 2px padding
                textLayer.frame = CGRect(origin: CGPoint(x: Int(colPosX[i])+2, y: Int(rowPosY[i])+2), size: CGSize(width: 68, height: 68))
                textLayer.alignmentMode = .center
                textLayer.isWrapped = true
                textLayer.foregroundColor = NSColor.black.cgColor
                textLayer.backgroundColor = NSColor.clear.cgColor
                textLayer.truncationMode = .none
                myCALayer.addSublayer(textLayer)
            }
            
            return myCALayer
            
        }
    
    }