admin管理员组

文章数量:1357652

The different coordinate systems of Cocoa and UIKit cause problem when drawing a string in a UIGraphicsContext.

I have written code for macOS, where I draw a combination of primitives, attributedText, and single CGGlyphs onto a CGContext. Therefore I first create a container object that holds several arrays of objects. These objects confirm to a protocol DrawingContent. The only protocol requirement is a method .draw(inContext: CGContext).

My custom NSView is holding such a container, and the view's draw method then iterates over the container elements, calling their .draw method with the current CGContext. I have stripped the code down to the minimum and will show it at the end of this question. The result of the sample code before being flipped looks like this:

My actual code is much more complex, as the objects to be drawn are a result of multiple algorithms. The reason I put the objects into a container is that from the start on I intended to also create an iOS version, so the results of the necessary calculations first go into a framework-agnostic abstract class. I was aware of the fact that UIKit Views use a flipped coordinate system and that I eventually would have to deal with that. Now I am attempting to write the iOS version – and I am at a loss.

The initial result when I just call the code without any alteration from an UIView looks like this:

This was – sort of – expected, although the text not being flipped is odd. So to flip the whole imagery I set a CGAffineTransform to the context before passing it to the drawing container like this:

currentContext.translateBy(x: 0, y: rect.height)
currentContext.scaleBy(x: 1, y: -1)

The result looks like this:

As you can see the canvas is flipped as expected, but, unfortunately, the AttributedString is drawn upside down. Interestingly, though, the single CGGlyph, although using the same font, is drawn correctly.

WHAT I HAVE TRIED SO FAR:

  1. Answers to similar questions suggest to apply n CGAffineTransform to the contexts's textMatrix (in addition to the context itself). This causes the CGGlyph (which had been drawn correctly) now to be upside down and at a wrong location, while the AttributedText remains untouched.

  2. In another place it was suggested to temporarily flip the context just for the drawing of the string like this:

context.saveGState()
context.scaleBy(x: 1, y: -1)
context.translateBy(x: 0, y: -bounds.height)
// draw string here...
context.restoreGState()

The problem with this is, that the container does not know about the frame's height. When I (for testing) hardcode the height the text now draws correctly, but at its „original“ wrong position at the top of the view. The two identical transformations obviously cancel out each other.

  1. I also tried to manually apply the scaling and tranlating to the y-position of the text, in addition to what I did in step 2. This revealed another problem: The text now shows at about the right position, but not quite: It now dangles from the imaginary baseline instead of sitting on it. I also want to state, that even if I would find out how the font metrics come into play here, applying this to my actual code would be a tremendous amount of work, as – as stated – the drawing methods of my objects do not know the height of the view they draw in, so I'd have to pass that as an additional argument, and – most importantly – I would have to write all of that scaling and translating conditionally when compiling for iOS – which would counteract my approach to keep the container-code framework-agnostic.

I could go down two different basic routes:

  1. I could rewrite my entire code to work in the flipped coordinate system of iOS, and for macOS I set the .isFlipped parameter on my custom NSView to return true. This would involve a lot of work, and it still would not solve the problem of the AttributedStrings being upside down.

  2. I continue trying to find a way to flip EVERYTHING on the canvas. I would prefer the second route, obviously.

QUESTION:

How do I draw Objects, that are positioned for a cartesian coordinate system (like Cocoa/Quartz 2D uses), into a CGContext that uses a flipped coodinate system (like UIKIT does), so that all objects of different types (primitives, Glyphs and Strings) are drawn at the correct position and in the correct orientation?

Here is my code. For convenience, instead of showing the UIView code I show a self containing version for macOS with the NSView set to .isFlipped - the behaviour is - as far as I could examine - exactly the same.

import AppKit

public protocol DrawingContent {
    func draw(inContext context: CGContext)
}

public struct TestPath: DrawingContent {
    
    public func draw(inContext context: CGContext) {
        
        context.beginPath()
        context.setLineWidth(CGFloat(2))
        context.move(to: CGPoint(x: 200, y: 30))
        context.addLine(to: CGPoint(x: 250, y: 30))
        context.addLine(to: CGPoint(x: 250, y: 60))
        context.addLine(to: CGPoint(x: 230, y: 60))
        context.strokePath()
    }
}

public struct TestText: DrawingContent {
    
    public func draw(inContext context: CGContext) {
      
        let font = CTFontCreateWithName("Helvetica" as CFString, 24, nil)
        
        var attributes: [NSAttributedString.Key: AnyObject] = [NSAttributedString.Key.font : font,
            NSAttributedString.Key.foregroundColor : NSColor.black]
        let attributedText = NSAttributedString(string: "Testtext", attributes: attributes)
        let descender = CTFontGetDescent(font)
        let textOrigin = CGPoint(x: 100, y: (30-descender))
        attributedText.draw(at: textOrigin)
    }
}

public struct TestGlyph: DrawingContent {
    
    public func draw(inContext context: CGContext) {
        
        var font = CTFontCreateWithName("Helvetica" as CFString, 24, nil)
        var position = CGPoint(x: 30, y: 30)
        var glyph = CGGlyph(36) // Capital Letter A
        CTFontDrawGlyphs(font, &glyph, &position, 1, context)
    }
}

public class FlippedTestView: NSView {
    
    var drawingContent: [DrawingContent]
    
    override public var isFlipped: Bool {return true}
    
    override public init(frame: CGRect)  {
        let drawingContent: [DrawingContent] = [TestGlyph(), TestText(), TestPath()]
        self.drawingContent = drawingContent
        super.init(frame: frame)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override open func draw(_ rect: CGRect) {
        
        let currentContext = NSGraphicsContext.current!.cgContext
        currentContext.translateBy(x: 0, y: rect.height)
        currentContext.scaleBy(x: 1, y: -1)
        
        for element in drawingContent {
            element.draw(inContext: currentContext)
        }
    }
}

@main
class AppDelegate: NSObject, NSApplicationDelegate {

    @IBOutlet var window: NSWindow!

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        // Insert code here to initialize your application
        
        let testView = FlippedTestView(frame: CGRect(x: 0, y: 0, width: 300, height: 100))
        
        self.window.contentView = testView
        self.window.setFrame(CGRect(x: 200, y: 200, width: 300, height: 100), display: true)
    }

    func applicationWillTerminate(_ aNotification: Notification) {
        // Insert code here to tear down your application
    }

    func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
        return true
    }
}

The different coordinate systems of Cocoa and UIKit cause problem when drawing a string in a UIGraphicsContext.

I have written code for macOS, where I draw a combination of primitives, attributedText, and single CGGlyphs onto a CGContext. Therefore I first create a container object that holds several arrays of objects. These objects confirm to a protocol DrawingContent. The only protocol requirement is a method .draw(inContext: CGContext).

My custom NSView is holding such a container, and the view's draw method then iterates over the container elements, calling their .draw method with the current CGContext. I have stripped the code down to the minimum and will show it at the end of this question. The result of the sample code before being flipped looks like this:

My actual code is much more complex, as the objects to be drawn are a result of multiple algorithms. The reason I put the objects into a container is that from the start on I intended to also create an iOS version, so the results of the necessary calculations first go into a framework-agnostic abstract class. I was aware of the fact that UIKit Views use a flipped coordinate system and that I eventually would have to deal with that. Now I am attempting to write the iOS version – and I am at a loss.

The initial result when I just call the code without any alteration from an UIView looks like this:

This was – sort of – expected, although the text not being flipped is odd. So to flip the whole imagery I set a CGAffineTransform to the context before passing it to the drawing container like this:

currentContext.translateBy(x: 0, y: rect.height)
currentContext.scaleBy(x: 1, y: -1)

The result looks like this:

As you can see the canvas is flipped as expected, but, unfortunately, the AttributedString is drawn upside down. Interestingly, though, the single CGGlyph, although using the same font, is drawn correctly.

WHAT I HAVE TRIED SO FAR:

  1. Answers to similar questions suggest to apply n CGAffineTransform to the contexts's textMatrix (in addition to the context itself). This causes the CGGlyph (which had been drawn correctly) now to be upside down and at a wrong location, while the AttributedText remains untouched.

  2. In another place it was suggested to temporarily flip the context just for the drawing of the string like this:

context.saveGState()
context.scaleBy(x: 1, y: -1)
context.translateBy(x: 0, y: -bounds.height)
// draw string here...
context.restoreGState()

The problem with this is, that the container does not know about the frame's height. When I (for testing) hardcode the height the text now draws correctly, but at its „original“ wrong position at the top of the view. The two identical transformations obviously cancel out each other.

  1. I also tried to manually apply the scaling and tranlating to the y-position of the text, in addition to what I did in step 2. This revealed another problem: The text now shows at about the right position, but not quite: It now dangles from the imaginary baseline instead of sitting on it. I also want to state, that even if I would find out how the font metrics come into play here, applying this to my actual code would be a tremendous amount of work, as – as stated – the drawing methods of my objects do not know the height of the view they draw in, so I'd have to pass that as an additional argument, and – most importantly – I would have to write all of that scaling and translating conditionally when compiling for iOS – which would counteract my approach to keep the container-code framework-agnostic.

I could go down two different basic routes:

  1. I could rewrite my entire code to work in the flipped coordinate system of iOS, and for macOS I set the .isFlipped parameter on my custom NSView to return true. This would involve a lot of work, and it still would not solve the problem of the AttributedStrings being upside down.

  2. I continue trying to find a way to flip EVERYTHING on the canvas. I would prefer the second route, obviously.

QUESTION:

How do I draw Objects, that are positioned for a cartesian coordinate system (like Cocoa/Quartz 2D uses), into a CGContext that uses a flipped coodinate system (like UIKIT does), so that all objects of different types (primitives, Glyphs and Strings) are drawn at the correct position and in the correct orientation?

Here is my code. For convenience, instead of showing the UIView code I show a self containing version for macOS with the NSView set to .isFlipped - the behaviour is - as far as I could examine - exactly the same.

import AppKit

public protocol DrawingContent {
    func draw(inContext context: CGContext)
}

public struct TestPath: DrawingContent {
    
    public func draw(inContext context: CGContext) {
        
        context.beginPath()
        context.setLineWidth(CGFloat(2))
        context.move(to: CGPoint(x: 200, y: 30))
        context.addLine(to: CGPoint(x: 250, y: 30))
        context.addLine(to: CGPoint(x: 250, y: 60))
        context.addLine(to: CGPoint(x: 230, y: 60))
        context.strokePath()
    }
}

public struct TestText: DrawingContent {
    
    public func draw(inContext context: CGContext) {
      
        let font = CTFontCreateWithName("Helvetica" as CFString, 24, nil)
        
        var attributes: [NSAttributedString.Key: AnyObject] = [NSAttributedString.Key.font : font,
            NSAttributedString.Key.foregroundColor : NSColor.black]
        let attributedText = NSAttributedString(string: "Testtext", attributes: attributes)
        let descender = CTFontGetDescent(font)
        let textOrigin = CGPoint(x: 100, y: (30-descender))
        attributedText.draw(at: textOrigin)
    }
}

public struct TestGlyph: DrawingContent {
    
    public func draw(inContext context: CGContext) {
        
        var font = CTFontCreateWithName("Helvetica" as CFString, 24, nil)
        var position = CGPoint(x: 30, y: 30)
        var glyph = CGGlyph(36) // Capital Letter A
        CTFontDrawGlyphs(font, &glyph, &position, 1, context)
    }
}

public class FlippedTestView: NSView {
    
    var drawingContent: [DrawingContent]
    
    override public var isFlipped: Bool {return true}
    
    override public init(frame: CGRect)  {
        let drawingContent: [DrawingContent] = [TestGlyph(), TestText(), TestPath()]
        self.drawingContent = drawingContent
        super.init(frame: frame)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override open func draw(_ rect: CGRect) {
        
        let currentContext = NSGraphicsContext.current!.cgContext
        currentContext.translateBy(x: 0, y: rect.height)
        currentContext.scaleBy(x: 1, y: -1)
        
        for element in drawingContent {
            element.draw(inContext: currentContext)
        }
    }
}

@main
class AppDelegate: NSObject, NSApplicationDelegate {

    @IBOutlet var window: NSWindow!

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        // Insert code here to initialize your application
        
        let testView = FlippedTestView(frame: CGRect(x: 0, y: 0, width: 300, height: 100))
        
        self.window.contentView = testView
        self.window.setFrame(CGRect(x: 200, y: 200, width: 300, height: 100), display: true)
    }

    func applicationWillTerminate(_ aNotification: Notification) {
        // Insert code here to tear down your application
    }

    func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
        return true
    }
}

Share Improve this question asked Mar 27 at 23:08 MassMoverMassMover 5772 silver badges21 bronze badges 4
  • Have you considered SwiftUI? You can get a CGContext from its GraphicsContext. – Sweeper Commented Mar 27 at 23:48
  • I see two parts that in this drawing: text and everything else. Text is drawn "OK", and "everything else" should be flipped. My idea is that you can create two "draw" parts like these, and draw them with different transformations? Identity for the text, and flip-transform for the other. – bealex Commented Mar 28 at 4:06
  • Mixing CTFontDrawGlyphs with NSAttributedString.draw(at:) is going to be problematic -- is there a reason you aren't using only one or the other? – DonMag Commented Mar 28 at 20:03
  • There is no other way. The whole thing is about musical notation, which mainly consists of single Glyphs from a special musical font, but also some text for verbal instructions. – MassMover Commented Mar 29 at 0:02
Add a comment  | 

2 Answers 2

Reset to default 2

I was able to get this:

It isn't perfect but as a proof of concept it shows that you can get there from here. There are two techniques:

  • For each of your drawing packets, start by getting the height of the context, which you can obtain by saying:

      let height = context.boundingBoxOfClipPath.height
    

    Now you can position your drawing vertically in relation to the size of what you are drawing into.

  • In the case of your text, which is flipping, you need to add a flip and then remove it when you are finished drawing; this is done by saving the context state at the start and restoring it at the end:

    public func draw(inContext context: CGContext) {
        let height = context.boundingBoxOfClipPath.height
        context.saveGState()
        context.translateBy(x: 0, y: height)
        context.scaleBy(x: 1, y: -1)
        // ...
        attributedText.draw(at: textOrigin)
        context.restoreGState()
    }

However, I do not know exactly where you want the drawing positioned vertically, so I leave it to you to do the necessary arithmetic.

After a lot of experimenting I finally found a working solution myself. I will post my updated code at the end of this post. I have altered it, so that four lines of text are written to different positions within the view, so that the correct positioning relative to each other can also be verified. In addition, I made the view and the window higher.

This is what happens:

Assuming a view of 400 points in height in Cocoa coordinate space. A text object at a y-position of 30 will sit close to the bottom of the view, with its lower left corner at the specified Point. For convenience, let's ignore the descender, that I subtract in the code to actually put the baseline to 30. The area in the view below 30 is completly void.

Now we want to call the same drawing methods from an UIView, or – as I do here – from an NSView which is flipped. The text now undergoes a true odyssey of transformations:

  1. The flipped view will position everything at the given coordinates, but the view's origin is now in the upper left corner. This means, the text will be drawn close to the top of the view, but still correctly orientated, with its lower left corner at the specified point. This means, in contrast to the original positioning, here the area below 30 (which actually means above) will be occupied by the text. This will be important later.

    As a side note: The single Glyph, that is drawn with CTFontDrawGlyphs draws upside down at this point, which seems wrong to me, but this is solved in the next step.

  2. We now have to re-flip the context, so that everything will be drawn at the bottom again. That means, things that originally were drawn 30 points from the bottom must now be drawn at y=370, as 400 is the bottom of the view. We do that by a transformation to the y-axis: (y * -1) – viewHeigth. But this does not mean, that the text will be drawn at y=370 with its origin at that position, but instead every „pixel“ of the text will be transformed that way. This results not only in the text being drawn upside down, but it will now occupy the area below the bottom-most 30 points.

    As a side note: this happens also to the single Glyph, but as this had been drawn already upside down in step 1, here it's a good thing.

  3. To prevent this from happening I step in when the text is drawn and apply another transformation. So the actual coded/calculated y-position that will eventually be passed to the string.draw(at:) method still is 30 in Cocoa space. I negate it to -30 and then apply an individual affine transformation to the context, again negating it, so it will become 30 again, but the context's transformation will then not only return the value back to positive 30 but also flip the text's orientation. In Addition, I get the font's height and calculate it in to compensate for the flip at the baseline that ocurred in step 1.

    And here is the interesting part: The value that finally gets passed to the draw(at:) method is -54 (-30 minus the font height). It now gets the individual transform which sets it to 54. But as we are in flipped coordinat space, 54 would place it close to the top of the screen. Instead the value should be 346 (400 – 54). And how do we get from 54 to 346? By applying the original affineTransform, that we already set before calling the draw method: (54 * -1) + 400 = 346.

I would have thought that the transformations I apply between saveGState and -restoreGState replace the ones already in place, but obviously they are cumulative.

So the true path of my textposition of y=30 can best be traced backwards:

  1. directly invert to -30 and translate by the font heigth to -54

  2. invert by context transformation back to 54 – now it's upside down

  3. invert by the outer transformation to -54 and translate by view's height(400) to 346 – now its standing on its feet again.

The reason why the route has to be that convoluted lies in the nature of my original code. If I had separate arrays for primitives, glyphs and strings I simply would recalculate the strings' y-positions and draw them directly, but separating them is not an option.

import Cocoa

@main
class AppDelegate: NSObject, NSApplicationDelegate {

    @IBOutlet var window: NSWindow!

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        
        let testView = FlippedTestView(frame: CGRect(x: 0, y: 0, width: 300, height: 400))
        self.window.contentView = testView
        self.window.setFrame(CGRect(x: 200, y: 200, width: 300, height: 400), display: true)
    }
}

public class FlippedTestView: NSView {
    
    var drawingContent: [DrawingContent]
    
    override public var isFlipped: Bool {return true}
    
    override public init(frame: CGRect)  {
        let drawingContent: [DrawingContent] = [TestGlyph(), TestText(), TestPath()]
        self.drawingContent = drawingContent
        super.init(frame: frame)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override open func draw(_ rect: CGRect) {
        
        let currentContext = NSGraphicsContext.current!.cgContext
        currentContext.translateBy(x: 0, y: rect.height)
        currentContext.scaleBy(x: 1, y: -1)
        
        for element in drawingContent {
            element.draw(inContext: currentContext)
        }
    }
}

public protocol DrawingContent {
    func draw(inContext context: CGContext)
}

public struct TestPath: DrawingContent {
 
    public func draw(inContext context: CGContext) {
        
        context.beginPath()
        context.setLineWidth(CGFloat(2))
        context.move(to: CGPoint(x: 200, y: 30))
        context.addLine(to: CGPoint(x: 250, y: 30))
        context.addLine(to: CGPoint(x: 250, y: 60))
        context.addLine(to: CGPoint(x: 230, y: 60))
        context.strokePath()
    }
}

public struct TestText: DrawingContent {
    
    var stringPositions: [CGPoint] = [
        CGPoint(x:100, y: 30),
        CGPoint(x:120, y: 50),
        CGPoint(x:140, y: 70),
        CGPoint(x:130, y: 90)
    ]
    
    public func draw(inContext context: CGContext) {
      
        let font = CTFontCreateWithName("Helvetica" as CFString, 24, nil)
        
        var attributes: [NSAttributedString.Key: AnyObject] = [NSAttributedString.Key.font : font]
        
        let attributedText = NSAttributedString(string: "Testtext", attributes: attributes)
        let descender = CTFontGetDescent(font)
        
        for point in stringPositions {
            let textOrigin = CGPoint(x: point.x, y: point.y-descender)
            context.saveGState()
            context.scaleBy(x: 1, y: -1)
            let fontHeight = attributedText.boundingRect(with: CGSize(width: 5000, height: 5000), context: nil).height
            let newY = (textOrigin.y * -1) - fontHeight
            let newPosition = CGPoint(x: textOrigin.x, y: newY)
            attributedText.draw(at: newPosition)
            context.restoreGState()
        }
    }
}

public struct TestGlyph: DrawingContent {
    
    public func draw(inContext context: CGContext) {
        
        var font = CTFontCreateWithName("Helvetica" as CFString, 24, nil)
        var position = CGPoint(x: 30, y: 30)
        var glyph = CGGlyph(36) // Capital Letter A
        CTFontDrawGlyphs(font, &glyph, &position, 1, context)
    }
}

本文标签: iosHow do I reuse code that draws onto a macOS GraphicsContext to draw onto an UIKit contextStack Overflow