Skip to content

Instantly share code, notes, and snippets.

@tcldr
Created December 16, 2019 16:17
Show Gist options
  • Save tcldr/6169f16ec9dcb98f8ee5ebd7559a56d5 to your computer and use it in GitHub Desktop.
Save tcldr/6169f16ec9dcb98f8ee5ebd7559a56d5 to your computer and use it in GitHub Desktop.
StackOverflow: Positioning View using anchor point

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.

Caveats

  • You will need to have some way of retrieving the font metrics – I'm using CTFont to initialise my Font 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 derived pixelLength 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.

Overview

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.

Step 1: Set up

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) }
}

Step 2: Adopt CTFont for our Text views

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:

Step 2

Step 3: Baseline shift

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)

Step 3

Notice how the glyphs are now sitting flush with the bottom of their enclosing views.

Step 4: Translate our text views upwards

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)

Step 4

Step 5: Rotate

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)

Step 5.1

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)

Step 5.2

The final code

//: 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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment