So, as far as I can tell, alignment guides can't be used in this way – yet. Hopefully this will be coming soon, but in the meantime we can do a little padding trickery to get the desired effect.
- You will need to have some way of retrieving the font metrics – I'm using
CTFont
to initialise myFont
instances and retrieving metrics that way. - As far as I can tell, Playgrounds aren't always representative of how a SwiftUI layout will be laid out on the device, and certain inconsistencies arise. One that I've identified is that the
displayScale
environment value (and the derivedpixelLength
value) is not set correctly by default in playgrounds and even previews. Therefore, you have to set this manually in these environments if you want a representative layout (FB7280058). - Significantly, the default text view height appears to differ between macOS and iOS. I have a calculation that works with most fonts on iOS, and here I've found a calculation for macOS that seems to work – but ideally we'd have a platform-agnostic, consistent and concrete way to determine these metrics. Hopefully it's coming.
The basic idea here is to sequence a number of transforms on our glyph. (SwiftUI more generally is all about sequencing transforms.)
First, we want to align the baseline of our glyph to the baseline of our view. If we have the font's metrics we can use the font's 'descent' to shift our glyph down a little so it sits flush with the baseline – we can use the padding
view modifier to help us with this.
Next we want to translate the position of our view up by exactly half its height. Again, we rely on the font's metrics to get a good idea of what our text view height will be.
Finally, we apply our rotation.
First, we're going to need some additional properties to help with our calculations. In a proper project you could organise this into a view modifier or similar, but for conciseness we'll add them to our existing view.
@Environment(\.pixelLength) var pixelLength: CGFloat
@Environment(\.displayScale) var displayScale: CGFloat
We'll also need a our font initialised as a CTFont
so we can grab its metrics:
let baseFont: CTFont = {
let desc = CTFontDescriptorCreateWithNameAndSize("SFProDisplay-Medium" as CFString, 0)
return CTFontCreateWithFontDescriptor(desc, 48, nil)
}()
Then some calculations. This calculates some EdgeInsets for a text view that will have the effect of moving the text view's baseline to the bottom edge of the enclosing padding view:
var textPadding: EdgeInsets {
let baselineShift = (displayScale * baseFont.descent).rounded(.down) / displayScale
let baselineOffsetInsets = EdgeInsets(top: baselineShift, leading: 0, bottom: -baselineShift, trailing: 0)
return baselineOffsetInsets
}
We'll also need to calculate the view height that SwiftUI will give a text view using this font. In my brief testing for macOS the following seems to work:
var defaultFirstLineHeight: CGFloat {
round(baseFont.ascent) + round(baseFont.descent)
}
Whilst if you're targeting iOS I've had good success with:
var defaultFirstLineHeight: CGFloat {
// you'd expect this to be ascent + descent + leading. Not so in my experiments.
let bufferedLineHeight = baseFont.ascent + baseFont.descent + pixelLength
return floor(displayScale * bufferedLineHeight) / displayScale
}
We'll also add a couple of helper properties to CTFont:
extension CTFont {
var ascent: CGFloat { CTFontGetAscent(self) }
var descent: CGFloat { CTFontGetDescent(self) }
}
This first step is simple and has us adopt the CTFont
we defined above:
var body: some View {
ZStack {
ForEach(locations) { run in
Text(verbatim: run.string)
.font(Font(self.baseFont)) // new CTFont initializer
.border(Color.green, width: self.pixelLength)
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
This gets us here:
Next we shift the baseline of our text view so that it sits flush with the bottom of our enclosing padding view. This is just a case of adding a padding modifier and using the calculation we defined above.
This modifies the Text portion of our body
computed view variable to:
Text(verbatim: run.string)
.font(Font(self.baseFont))
.padding(self.textPadding)
.border(Color.green, width: self.pixelLength)
.position(run.point)
Notice how the glyphs are now sitting flush with the bottom of their enclosing views.
We want the bottom edges of of our text views sitting on top of the positions marked by the red dots. Here, we apply a translation that moves our text views by exactly half our calculated text view heights upwards:
Text(verbatim: run.string)
.font(Font(self.baseFont))
.padding(self.textPadding)
.border(Color.green, width: self.pixelLength
.transformEffect(.init(translationX: 0, y: -self.defaultFirstLineHeight / 2))
.position(run.point)
Now we have our view in position we can finally rotate.
Before we make our rotation though, it might help to highlight how our transform from the previous step affects our view hierarchy. If we add a another border to our view transform chain (this time in purple) we can see that our transform has affected all view elements prior to itself in the chain, but crucially it itself exists in the position prior to the transform.
Text(verbatim: run.string)
.font(Font(self.baseFont))
.padding(self.textPadding)
.border(Color.green, width: self.pixelLength
.transformEffect(.init(translationX: 0, y: -self.defaultFirstLineHeight / 2))
.border(Color.purple, width: self.pixelLength)
.position(run.point)
This means our transform view remains with its centre exactly on top of our anchor point, and so therefore we can simply apply a rotation to our transform view to move our views into place.
Text(verbatim: run.string)
.font(Font(self.baseFont))
.padding(self.textPadding)
.border(Color.green, width: self.pixelLength
.transformEffect(.init(translationX: 0, y: -self.defaultFirstLineHeight / 2))
.border(Color.purple, width: self.pixelLength)
.rotationEffect(.radians(run.angle))
.position(run.point)
//: A Cocoa based Playground to present user interface
import SwiftUI
import PlaygroundSupport
struct Location: Identifiable {
let id = UUID()
let point: CGPoint
let angle: Double
let string: String
}
let locations = [
Location(point: CGPoint(x: 54.48386479999999, y: 296.4645408), angle: -0.6605166885682314, string: "Y"),
Location(point: CGPoint(x: 74.99159120000002, y: 281.6336352), angle: -0.589411952788817, string: "o"),
]
struct ContentView: View {
@Environment(\.pixelLength) var pixelLength: CGFloat
@Environment(\.displayScale) var displayScale: CGFloat
let baseFont: CTFont = {
let desc = CTFontDescriptorCreateWithNameAndSize("SFProDisplay-Medium" as CFString, 0)
return CTFontCreateWithFontDescriptor(desc, 48, nil)
}()
var textPadding: EdgeInsets {
let baselineShift = (displayScale * baseFont.descent).rounded(.down) / displayScale
let baselineOffsetInsets = EdgeInsets(top: baselineShift, leading: 0, bottom: -baselineShift, trailing: 0)
return baselineOffsetInsets
}
var defaultFirstLineHeight: CGFloat {
round(baseFont.ascent) + round(baseFont.descent)
}
var body: some View {
ZStack {
ForEach(locations) { run in
Text(verbatim: run.string)
.font(Font(self.baseFont))
.padding(self.textPadding)
.border(Color.green, width: self.pixelLength)
.transformEffect(.init(translationX: 0, y: -self.defaultFirstLineHeight / 2))
.rotationEffect(.radians(run.angle))
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
}
private extension CTFont {
var ascent: CGFloat { CTFontGetAscent(self) }
var descent: CGFloat { CTFontGetDescent(self) }
}
PlaygroundPage.current.setLiveView(
ContentView()
.environment(\.displayScale, NSScreen.main?.backingScaleFactor ?? 1.0)
.frame(width: 640, height: 480)
.background(Color.white)
)
And that's it. It's not perfect, but until SwiftUI gives us an API that allows us to use alignment anchors to anchor our transforms, it might get us by!