I have what I thought was a simple task, but it appears otherwise. As part of my code, I am simply displaying some circles and when the user clicks on one, it will navigate to a new view depending on which circle was pressed.
If I hard code the views in the NavigationLink, it works fine, but I want to use a dynamic array. In the following code, it doesn't matter which circle is pressed, it will always display the id of the last item in the array; ie it passes the last defined dot.destinationId to the Game view
struct Dot: Identifiable {
let id = UUID()
let x: CGFloat
let y: CGFloat
let color: Color
let radius: CGFloat
let destinationId: Int
}
struct ContentView: View {
var dots = [
Dot(x:10.0,y:10.0, color: Color.green, radius: 12, destinationId: 1),
Dot(x:110.0,y:10.0, color: Color.green, radius: 12, destinationId: 2),
Dot(x:110.0,y:110.0, color: Color.blue, radius: 12, destinationId: 3),
Dot(x:210.0,y:110.0, color: Color.blue, radius: 12, destinationId: 4),
Dot(x:310.0,y:110.0, color: Color.red, radius: 14, destinationId: 5),
Dot(x:210.0,y:210.0, color: Color.blue, radius: 12, destinationId: 6)]
var lineWidth: CGFloat = 1
var body: some View {
NavigationView {
ZStack
Group {
ForEach(dots){ dot in
NavigationLink(destination: Game(id: dot.destinationId))
{
Circle()
.fill(dot.color)
.frame(width: dot.radius, height: dot.radius)
.position(x:dot.x, y: dot.y )
}
}
}
.frame(width: .infinity, height: .infinity, alignment: .center )
}
.navigationBarTitle("")
.navigationBarTitleDisplayMode(.inline)
}
}
struct Game: View {
var id: Int
var body: some View {
Text("\(id)")
}
}
I tried to send the actual view as i want to show different views and used AnyView, but the same occurred It was always causing the last defined view to be navigated to.
I'm really not sure what I a doing wrong, any pointers would be greatly appreciated
While it might seem that you're clicking on different dots, the reality is that you have a ZStack with overlapping views and you're always clicking on the top-most view. You can see when you tap that the dot with ID 6 is always the one actually getting pressed (notice its opacity changes when you click).
To fix this, move your frame and position modifiers outside of the NavigationLink so that they affect the link and the circle contained in it, instead of just the inner view.
Also, I modified your last frame since there were warnings about an invalid frame size (note the different between width/maxWidth and height/maxHeight when specifying .infinite).
struct ContentView: View {
var dots = [
Dot(x:10.0,y:10.0, color: Color.green, radius: 12, destinationId: 1),
Dot(x:110.0,y:10.0, color: Color.green, radius: 12, destinationId: 2),
Dot(x:110.0,y:110.0, color: Color.blue, radius: 12, destinationId: 3),
Dot(x:210.0,y:110.0, color: Color.blue, radius: 12, destinationId: 4),
Dot(x:310.0,y:110.0, color: Color.red, radius: 14, destinationId: 5),
Dot(x:210.0,y:210.0, color: Color.blue, radius: 12, destinationId: 6)]
var lineWidth: CGFloat = 1
var body: some View {
NavigationView {
ZStack {
Group {
ForEach(dots){ dot in
NavigationLink(destination: Game(id: dot.destinationId))
{
Circle()
.fill(dot.color)
}
.frame(width: dot.radius, height: dot.radius) //<-- Here
.position(x:dot.x, y: dot.y ) //<-- Here
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center )
}
.navigationBarTitle("")
.navigationBarTitleDisplayMode(.inline)
}
}
}
Related
When adding a shadow to my view in a Grid, the scrolling experience is bad. It feels like the Frame Rate is dropping. I came across this post, option 1 made my whole background for my view the same color as my shadow. I Don't really know how to implement UIViewRepresentable in option 2.
So how would I be able to use UIViewRepresentable, or is there a better way to do this.
MRE CODE
struct ContentView: View {
#State var gridSpacing: CGFloat = 8
let columns: [GridItem] = [GridItem(.flexible(),spacing: 8), GridItem(.flexible(),spacing: 8),GridItem(.flexible(),spacing: 8)]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: gridSpacing) {
ForEach(0..<200) { x in
VStack(spacing: 8) {
Image("sumo-deadlift") //<----- Replace Image
.resizable()
.scaledToFit()
.background(.yellow)
.clipShape(Circle())
.padding(.horizontal)
.shadow(color: .blue, radius: 2, x: 1, y: 1) //<----- Comment/Uncomment
Text("Sumo Deadlift")
.font(.footnote.weight(.semibold))
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(maxWidth: .infinity)
Text("Legs")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.all)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.mask(RoundedRectangle(cornerRadius: 20))
.shadow(color: .red, radius: 2, x: 1, y: 1) //<----- Comment/Uncomment
}
}
.padding(.all, 8)
}
.background(.ultraThinMaterial)
}
}
I just upgraded my phone from 15.x (I think 15.2) to 15.3, and my SwiftUI layout is broken, seems to be a List border issue.
Here is the code, followed by a screen shot of normal behavior (13.x to 15.x) and then what I see in 15.3
struct SessionView: View {
var title: String
var panel: Int
var index: Int
var range: [Int] = [0,2,3,4]
#EnvironmentObject var state: MainViewModel
#EnvironmentObject var content: ContentViewModel
#ViewBuilder
var body: some View {
VStack() {
// -- Header
HStack() {
Text(" ")
Image(self.state.panelIcon(panel: panel)).resizable().frame(width: 12.0, height: 12.0)
Text(title)
Spacer()
}.padding(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
.background(Color(red: 0.9, green: 0.9, blue: 0.9))
.onTapGesture {
showDetailDialog()
}
// -- Rows
List {
ForEach(0..<5) { i in
if i != 1 { // Skip IP address
HStack(alignment: /*#START_MENU_TOKEN#*/.center/*#END_MENU_TOKEN#*/, spacing: -4, content: {
Text("\(state.keyValues[i+index]!.key)").frame(minWidth: 120, maxHeight: 20, alignment: .leading)
.font(Font.system(size: 15, design: .default)).padding(0)
Text("\(state.keyValues[i+index]!.value)").frame(maxHeight: 20, alignment: .leading)
.font(Font.system(size: 15, design: .default)).padding(0)
}).frame(height: 10)
}
}
}.environment(\.defaultMinListRowHeight, 10)
.frame(height: 4*20+20)
.listStyle(DefaultListStyle()).environment(\.defaultMinListRowHeight, 8).onAppear {
UITableView.appearance().isScrollEnabled = false
}.layoutPriority(1)
}.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color(red: 0.8, green: 0.8, blue: 0.8), lineWidth: 1.25)
).background(Color.white)
}
.
.
.
}
I had this problem too. However, it is not a border issue. When you updated your iPhone, the default list style for SwiftUI changed. The old default style is now called PlainListStyle(). Use that instead to get the old look back.
List {
}.listStyle(PlainListStyle())
I would like to show a bunch of vertical bars, and on the click of a button have the first one swap places with a randomly selected one. I want this to be animated and the gif below shows the closest I've come, but as you can see, the entire set of vertical bars flashes briefly on each button click, which I do not want.
I've tried a few different approaches. The most elegant one I think would be to have the array of values which I iterate through to show the bars be a state variable, and when the array is sorted, the list just reloads and the swapping is animated. However, this didn't result in a swapping effect. It would just shrink and grow the respective bars to match the sizes of the array elements that were swapped :( I still feel like this might be the best approach, so I'm open to completely changing the implementation.
So the closest I've come is what you see in the gif, but it's hacky and I don't like the flashing of the entire view when it reloads. I'm using matchedGeometryEffect (hero animation) and even to get that working, I had to have a state variable that would reload the view. As you will see in the code below, this forces me to have an if/else even if I am just reloading the view under either condition. So I don't truly need the if(animate){} else {}, but using a hero animation forces me to.
Here's my code:
import SwiftUI
class SortElement: Identifiable{
var id: Int
var name: String
var color: Color
var height: CGFloat
init(id: Int, name: String, color: Color, height: CGFloat){
self.id = id
self.name = name
self.color = color
self.height = height
}
}
struct SortArrayAnimationDemo: View {
#Namespace private var animationSwap
#State private var animate: Bool = false
private static var sortableElements: [SortElement] = [
SortElement(id: 6, name:"6", color: Color(UIColor.lightGray), height: 60),
SortElement(id: 2, name:"2", color: Color(UIColor.lightGray), height: 20),
SortElement(id: 5, name:"5", color: Color(UIColor.lightGray), height: 50),
SortElement(id: 1, name:"1", color: Color(UIColor.lightGray), height: 10),
SortElement(id: 3, name:"3", color: Color(UIColor.lightGray), height: 30),
SortElement(id: 9, name:"9", color: Color(UIColor.lightGray), height: 90),
SortElement(id: 4, name:"4", color: Color(UIColor.lightGray), height: 40),
SortElement(id: 8, name:"8", color: Color(UIColor.lightGray), height: 80)
]
var body: some View {
VStack{
RoundedRectangle(cornerRadius: 10.0)
.fill(Color.blue)
.frame(width: 200.0, height: 20.0)
if(animate){
fetchSwappingView()
}else {
fetchSwappingView()
}
RoundedRectangle(cornerRadius: 10.0)
.fill(Color.blue)
.frame(width: 200.0, height: 20.0)
Button(action: { doAnimate() }) { Text("Swap!") }.padding(.top, 30)
}//vstack
}
#ViewBuilder
private func fetchSwappingView() -> some View {
HStack(alignment: .bottom){
RoundedRectangle(cornerRadius: 10.0)
.fill(Color.red)
.frame(width: 30.0, height: 100.0)
ForEach(0..<SortArrayAnimationDemo.sortableElements.count) { i in
let sortableElement = SortArrayAnimationDemo.sortableElements[i]
RoundedRectangle(cornerRadius: 10.0)
.fill(Color.gray)
.frame(width: 20.0, height: sortableElement.height)
.matchedGeometryEffect(id: sortableElement.id,
in: animationSwap,
properties: .position)
}
RoundedRectangle(cornerRadius: 10.0)
.fill(Color.red)
.frame(width: 30.0, height: 100.0)
}//hstack
}
private func doAnimate() -> Void {
let randomIndex = Int.random(in: 1..<8)
let tmpLeftSortableElement: SortElement = SortArrayAnimationDemo.sortableElements[0]
SortArrayAnimationDemo.sortableElements[0] = SortArrayAnimationDemo.sortableElements[randomIndex]
SortArrayAnimationDemo.sortableElements[randomIndex] = tmpLeftSortableElement
withAnimation(.spring(dampingFraction: 0.7).speed(1.5).delay(0.05)){
animate.toggle()
}
}
}
Your code has a couple problems. First, you're working around the immutability of structs with this:
private static var sortableElements: [SortElement] = [
static isn't going to make SwiftUI refresh the view. I think you found this out too, so added another #State:
#State private var animate: Bool = false
...
if (animate) {
fetchSwappingView()
} else {
fetchSwappingView()
}
Well... this is where your flashing is coming from. The default transition applied to an if-else is a simple fade, which is what you're seeing.
But, if you think about it, this if-else doesn't make any sense. There's no need for an intermediary animate state — just let SwiftUI do the work!
You were initially on the right track.
The most elegant one I think would be to have the array of values which I iterate through to show the bars be a state variable, and when the array is sorted, the list just reloads and the swapping is animated. However, this didn't result in a swapping effect. It would just shrink and grow the respective bars to match the sizes of the array elements that were swapped :( I still feel like this might be the best approach, so I'm open to completely changing the implementation.
As you thought, the better way would be to just have a single #State array of SortElements. I'll get to the swapping effect problem in a bit — first, replace private static var with #State private var:
#State private var sortableElements: [SortElement] = [
This makes it possible to directly modify sortableElements. Now, in your doAnimate function, just set sortableElements instead of doing all sorts of weird stuff.
private func doAnimate() -> Void {
let randomIndex = Int.random(in: 1..<8)
let tmpLeftSortableElement: SortElement = sortableElements[0]
withAnimation(.spring(dampingFraction: 0.7).speed(1.5).delay(0.05)){
sortableElements[0] = sortableElements[randomIndex]
sortableElements[randomIndex] = tmpLeftSortableElement
}
}
Don't forget to also replace
if (animate) {
fetchSwappingView()
} else {
fetchSwappingView()
}
with:
fetchSwappingView()
At this point, your result should look like this:
This matches your problem description: the heights change fine, but there's no animation!
this didn't result in a swapping effect. It would just shrink and grow the respective bars to match the sizes of the array elements that were swapped :(
The problem is here:
ForEach(0..<sortableElements.count) { i in
Since you're simply looping over sortableElements.count, SwiftUI has no idea how the order changed. Instead, you should directly loop over sortableElements. The array's elements, SortElement, all conform to Identifiable — so SwiftUI can now determine how the order changed.
ForEach(sortableElements) { element in
RoundedRectangle(cornerRadius: 10.0)
.fill(Color.gray)
.frame(width: 20.0, height: element.height)
.matchedGeometryEffect(
id: element.id,
in: animationSwap,
properties: .position
)
}
Here's the final code:
struct SortArrayAnimationDemo: View {
#Namespace private var animationSwap
#State private var sortableElements: [SortElement] = [
SortElement(id: 6, name:"6", color: Color(UIColor.lightGray), height: 60),
SortElement(id: 2, name:"2", color: Color(UIColor.lightGray), height: 20),
SortElement(id: 5, name:"5", color: Color(UIColor.lightGray), height: 50),
SortElement(id: 1, name:"1", color: Color(UIColor.lightGray), height: 10),
SortElement(id: 3, name:"3", color: Color(UIColor.lightGray), height: 30),
SortElement(id: 9, name:"9", color: Color(UIColor.lightGray), height: 90),
SortElement(id: 4, name:"4", color: Color(UIColor.lightGray), height: 40),
SortElement(id: 8, name:"8", color: Color(UIColor.lightGray), height: 80)
]
var body: some View {
VStack{
RoundedRectangle(cornerRadius: 10.0)
.fill(Color.blue)
.frame(width: 200.0, height: 20.0)
fetchSwappingView() /// NO if else needed!
RoundedRectangle(cornerRadius: 10.0)
.fill(Color.blue)
.frame(width: 200.0, height: 20.0)
Button(action: { doAnimate() }) { Text("Swap!") }.padding(.top, 30)
}//vstack
}
#ViewBuilder
private func fetchSwappingView() -> some View {
HStack(alignment: .bottom){
RoundedRectangle(cornerRadius: 10.0)
.fill(Color.red)
.frame(width: 30.0, height: 100.0)
/// directly loop over `sortableElements` instead of its count
ForEach(sortableElements) { element in
RoundedRectangle(cornerRadius: 10.0)
.fill(Color.gray)
.frame(width: 20.0, height: element.height)
.matchedGeometryEffect(
id: element.id,
in: animationSwap,
properties: .position
)
}
RoundedRectangle(cornerRadius: 10.0)
.fill(Color.red)
.frame(width: 30.0, height: 100.0)
}//hstack
}
private func doAnimate() -> Void {
let randomIndex = Int.random(in: 1..<8)
let tmpLeftSortableElement: SortElement = sortableElements[0]
/// directly set `sortableElements`
withAnimation(.spring(dampingFraction: 0.7).speed(1.5).delay(0.05)){
sortableElements[0] = sortableElements[randomIndex]
sortableElements[randomIndex] = tmpLeftSortableElement
}
}
}
Result:
I have the following code:
struct ViewA: View {
var body: some View {
Rectangle()
.fill(Color(.yellow))
}
}
struct ViewB: View {
var body: some View {
Rectangle()
.fill(Color(.white))
}
}
struct ViewC: View {
var body: some View {
Rectangle()
.fill(Color(.blue))
}
}
struct TestView: View {
var body: some View {
GeometryReader { geo in
VStack(spacing: 0) {
ViewA()
.frame(width: geo.size.width, height: geo.size.width/4, alignment: .center)
.shadow(color: .black, radius: 10, x: 0, y: 10)
ViewB()
ViewC()
.frame(width: geo.size.width, height: 100, alignment: .center)
.shadow(color: .black, radius: 10, x: 0, y: 0)
.opacity(0.8)
}
}
}
}
It draws:
The shadow at the bottom Blue over White view (ViewC over ViewB) shows up.
I want the yellow view (ViewA) to drop a shadow on top of the white view.
I want the white view to appear as if it were under both the top view (yellow) and the bottom view (blue).
I am not sure how to organize the views so that the top view also drops shadow on the middle view.
How would I accomplish this?
You could apply zindex modifier to ViewA. For example .zIndex(.infinity). It will put ViewA on top of everything.
https://developer.apple.com/documentation/swiftui/view/zindex(_:)
If you place the views in a ZStack with ViewB behind ViewA and ViewC you will see the shadow:
GeometryReader { geo in
ZStack {
ViewB()
VStack(spacing: 0) {
ViewA()
.frame(
width: geo.size.width,
height: geo.size.width/4,
alignment: .center)
.shadow(
color: .black,
radius: 10,
x: 0, y: 0)
Spacer()
ViewC()
.frame(
width: geo.size.width,
height: 100,
alignment: .center)
.shadow(
color: .black,
radius: 10,
x: 0, y: 0)
.opacity(0.8)
}
}
}
I have several dozen Texts that I would like to position such that their leading baseline (lastTextBaseline) is at a specific coordinate. position can only set the center. For example:
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 {
var body: some View {
ZStack {
ForEach(locations) { run in
Text(verbatim: run.string)
.font(.system(size: 48))
.border(Color.green)
.rotationEffect(.radians(run.angle))
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
}
PlaygroundPage.current.setLiveView(ContentView())
This locates the strings such that their center is at the desired point (marked as a red circle):
I would like to adjust this so that the leading baseline is at this red dot. In this example, a correct layout would move the glyphs up and to the right.
I have tried adding .topLeading alignment to the ZStack, and then using offset rather than position. This will let me align based on the top-leading corner, but that's not the corner I want to layout. For example:
ZStack(alignment: .topLeading) { // add alignment
Rectangle().foregroundColor(.clear) // to force ZStack to full size
ForEach(locations) { run in
Text(verbatim: run.string)
.font(.system(size: 48))
.border(Color.green)
.rotationEffect(.radians(run.angle), anchor: .topLeading) // rotate on top-leading
.offset(x: run.point.x, y: run.point.y)
}
}
I've also tried changing the "top" alignment guide for the Texts:
.alignmentGuide(.top) { d in d[.lastTextBaseline]}
This moves the red dots rather than the text, so I don't believe this is on the right path.
I am considering trying to adjust the locations themselves to take into account the size of the Text (which I can predict using Core Text), but I am hoping to avoid calculating a lot of extra bounding boxes.
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 and overlay 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).
Overview
We're going to combine a number of SwiftUI features to get the outcome we want here. Specifically, transforms, overlays and the GeometryReader view.
First, we'll 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're going to overlay our glyph view with a duplicate view. Why? Because within an overlay we're able to grab the exact metrics of the view underneath. In fact, our overlay will be the only view the user sees, the original view will only be utilised for its metrics.
A couple of simple transforms will position our overlay where we want it, and we'll then hide the view that sits underneath to complete the effect.
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 add a couple of helper properties to CTFont:
extension CTFont {
var ascent: CGFloat { CTFontGetAscent(self) }
var descent: CGFloat { CTFontGetDescent(self) }
}
And finally we create a new helper function to generate our Text views that uses the CTFont we defined above:
private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
}
Step 2: Adopt our glyphView(_:) in our main body call
This step is simple and has us adopt the glyphView(_:) helper function we define above:
var body: some View {
ZStack {
ForEach(locations) { run in
self.glyphView(for: run.string)
.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 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 to our new glyphView(_:)function that utilises the padding calculation we define above.
private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
.padding(textPadding) // Added padding modifier
}
Notice how the glyphs are now sitting flush with the bottom of their enclosing views.
Step 4: Add an overlay
We need to get the metrics of our glyph so that we are able to accurately place it. However, we can't get those metrics until we've laid out our view. One way around this is to duplicate our view and use one view as a source of metrics that is otherwise hidden, and then present a duplicate view that we position using the metrics we've gathered.
We can do this with the overlay modifier together with a GeometryReader view. And we'll also add a purple border and make our overlay text blue to differentiate it from the previous step.
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
})
.position(run.point)
Step 5: Translate
Making use of the metrics we now have available for us to use, we can shift our overlay up and to the right so that the bottom left corner of the glyph view sits on our red positioning spot.
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
})
.position(run.point)
Step 6: Rotate
Now we have our view in position we can finally rotate.
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)
Step 7: Hide our workings out
Last step is to hide our source view and set our overlay glyph to its proper colour:
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.hidden()
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.black)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)
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 body: some View {
ZStack {
ForEach(locations) { run in
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.hidden()
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.black)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
.padding(textPadding)
}
}
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!
this code takes care of the font metrics, and position text as you asked
(If I properly understood your requirements :-))
import SwiftUI
import PlaygroundSupport
struct BaseLine: ViewModifier {
let alignment: HorizontalAlignment
#State private var ref = CGSize.zero
private var align: CGFloat {
switch alignment {
case .leading:
return 1
case .center:
return 0
case .trailing:
return -1
default:
return 0
}
}
func body(content: Content) -> some View {
ZStack {
Circle().frame(width: 0, height: 0, alignment: .center)
content.alignmentGuide(VerticalAlignment.center) { (d) -> CGFloat in
DispatchQueue.main.async {
self.ref.height = d[VerticalAlignment.center] - d[.lastTextBaseline]
self.ref.width = d.width / 2
}
return d[VerticalAlignment.center]
}
.offset(x: align * ref.width, y: ref.height)
}
}
}
struct ContentView: View {
var body: some View {
ZStack {
Cross(size: 20, color: Color.red).position(x: 200, y: 200)
Cross(size: 20, color: Color.red).position(x: 200, y: 250)
Cross(size: 20, color: Color.red).position(x: 200, y: 300)
Cross(size: 20, color: Color.red).position(x: 200, y: 350)
Text("WORLD").font(.title).border(Color.gray).modifier(BaseLine(alignment: .trailing))
.rotationEffect(.degrees(45))
.position(x: 200, y: 200)
Text("Y").font(.system(size: 150)).border(Color.gray).modifier(BaseLine(alignment: .center))
.rotationEffect(.degrees(45))
.position(x: 200, y: 250)
Text("Y").font(.system(size: 150)).border(Color.gray).modifier(BaseLine(alignment: .leading))
.rotationEffect(.degrees(45))
.position(x: 200, y: 350)
Text("WORLD").font(.title).border(Color.gray).modifier(BaseLine(alignment: .leading))
.rotationEffect(.degrees(225))
.position(x: 200, y: 300)
}
}
}
struct Cross: View {
let size: CGFloat
var color = Color.clear
var body: some View {
Path { p in
p.move(to: CGPoint(x: size / 2, y: 0))
p.addLine(to: CGPoint(x: size / 2, y: size))
p.move(to: CGPoint(x: 0, y: size / 2))
p.addLine(to: CGPoint(x: size, y: size / 2))
}
.stroke().foregroundColor(color)
.frame(width: size, height: size, alignment: .center)
}
}
PlaygroundPage.current.setLiveView(ContentView())
Updated: you could try the following variants
let font = UIFont.systemFont(ofSize: 48)
var body: some View {
ZStack {
ForEach(locations) { run in
Text(verbatim: run.string)
.font(Font(self.font))
.border(Color.green)
.offset(x: 0, y: -self.font.lineHeight / 2.0)
.rotationEffect(.radians(run.angle))
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
there is also next interesting variant, use ascender instead of above lineHeight
.offset(x: 0, y: -self.font.ascender / 2.0)