Conflicts with custom alignment guides in SwiftUI - swift

In my project I have to use custom alignment guides with nested views. It is quite complex, but I'll try to simplify my case in the following way:
There are 3 possible views: a circle, an upContainer and a downContainer. The two containers are just arrays of other circles/containers. The downContainer aligns only first array to the current line (where there are, for example, the other circles), regardless of what is inside the second array; the upContainer does the opposite. Here's an image to visualize them:
I want to be able to build complex views dynamically using this three elements. Thus I create a data model (which is a simple enum) with nested associated types:
enum Data: Hashable {
case circle
case upContainer([Data], [Data])
case downContainer([Data], [Data])
}
In order to manually align the three elements I create a custom SwiftUI alignment in this way:
extension VerticalAlignment {
struct MyAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[VerticalAlignment.center]
}
}
static let myAlignment = VerticalAlignment(MyAlignment.self)
}
I want the circles in a specific container to have the same color, so I create an extension to quickly generate a random color:
extension Color {
static var random: Color {
return Color(
red: .random(in: 0...1),
green: .random(in: 0...1),
blue: .random(in: 0...1)
)
}
}
Now I am able to create the views corresponding to the three elements:
struct CirclesView: View {
var circles: [Data]
var color: Color = Color.random
var body: some View {
HStack(alignment: .myAlignment) {
ForEach(circles, id: \.self) { value in
switch value {
case .circle:
Circle()
.frame(width: 20, height: 20)
.foregroundColor(color)
case .upContainer(let firstData, let secondData):
UpContainerView(container: (firstData, secondData))
case .downContainer(let firstData, let secondData):
DownContainerView(container: (firstData, secondData))
}
}
}
}
}
struct UpContainerView: View {
var container: ([Data], [Data])
var color = Color.random
var body: some View {
VStack(alignment: .leading) {
CirclesView(circles: container.0, color: color)
CirclesView(circles: container.1, color: color)
.alignmentGuide(.myAlignment) { $0[VerticalAlignment.center] }
}
}
}
struct DownContainerView: View {
var container: ([Data], [Data])
var color = Color.random
var body: some View {
VStack(alignment: .leading) {
CirclesView(circles: container.0, color: color)
.alignmentGuide(.myAlignment) { $0[VerticalAlignment.center] }
CirclesView(circles: container.1, color: color)
}
}
}
At this point the problem should be solved. For example, if I wanted to render this image:
I should be able to do it by writing:
struct ContentView : View {
var myData: [Data] = [.circle, .circle, .downContainer(
[.circle, .circle, .circle, .downContainer(
[.circle, .circle, .circle, .circle], [.circle]
)], [.circle, .circle, .upContainer(
[.circle, .circle], [.circle, .circle, .circle, .circle]
)]
)]
var body: some View {
CirclesView(circles: myData)
}
}
However this is the result:
As you can see, the first container (the brown one) is not rendered as downContainer, in fact it is in centrally aligned to the first two circles green circles. This is because even if we set explicit alignment guides for only one of the two arrays, SwiftUI takes into account also the alignment guides of the subviews of the other array (even if we want to ignore them) and there is a conflict.
struct DownContainerView: View {
var container: ([Data], [Data])
var color = Color.random
var body: some View {
VStack(alignment: .leading) {
CirclesView(circles: container.0, color: color)
.alignmentGuide(.myAlignment) { $0[VerticalAlignment.center] }
//We set the explicit alignment guide only for the first array, I am looking for a way to ignore completely the alignment guides of the second one and to calculate the alignment based only on the first one.
CirclesView(circles: container.1, color: color)
}
}
}
To solve the conflict we should simply ignore the alignment guides of the other array.
The actual question becomes "How can we ignore those alignment guides, which are implicitly and automatically calculated by SwiftUI?"
Note: I now that a different solution could be to use PreferenceKeys, but I believe this problem should be solved using only alignment guides, since it is a mere alignment problem. I also thought that a solution could be to dynamically create a new custom alignment guide for every subcontainer, but I don't how to do it (if it is possible in Swift).

First up some background info that might help:
EVERY view has EXACTLY ONE alignment which is an x and y value in the coordinate system of the view. Note there is no name (like .top or .leading) mentioned here, those are just ways to tell the view which x an y value to return as the alignment guide for this layout.
If you don't tell the view which alignment to use, it defaults to .center.
The parent view uses the child alignment guides by calculating where that coordinate is in its own coordinate system and places all the children so the alignments are all coincident. Some parents may ignore the child value and place the child where it wants (such as a HStack which ignores the x value of its children and places the children side by side.)
The OP asks if the problem can be solved with layout alone, or are preferences required?
The answers are: it can be done with layout alone depending on how sophisticated you need to be, but you may need preferences depending on how sophisticated you need to be.
Looking at the OPs example we see that a CirclesView is an alias for a HStack and both up and down containers are aliases for a VStack containing two CirclesViews.
Also the test case only has CirclesViews with Circles and either an UpContainerView or a DownContainerView (never both up and down, which gives a false impression of how well the layout is working, it is not even as good as the OP thinks).
So unrolling the test data, using the HStacks and VStacks directly, using Text with fixed colours so we can tell what is what and putting it all into one view so we can play with alignment gives:
struct SOAlignment: View {
var body: some View {
HStack { //CirclesView (solid gray)
Text("C")
.foregroundColor(Color.blue)
.overlay(Rectangle().strokeBorder(style: StrokeStyle(lineWidth: 1.0, dash: [5,5])))
Text("C")
.foregroundColor(Color.blue)
.overlay(Rectangle().strokeBorder(style: StrokeStyle(lineWidth: 1.0, dash: [5,5])))
VStack(alignment: .leading) { //DownContainerView (black dashes)
HStack { //CirclesView (top gray dashes)
Text("C")
.foregroundColor(Color.red)
Text("C")
.foregroundColor(Color.red)
Text("C")
.foregroundColor(Color.red)
VStack(alignment: .leading) { //DownContainerView (yellow)
HStack {
Text("C")
Text("C")
Text("C")
Text("C")
}
HStack {
Text("C")
}
}
.foregroundColor(Color.yellow)
}
.foregroundColor(Color.red)
.overlay(Rectangle()
.inset(by: 1.0)
.strokeBorder(style: StrokeStyle(lineWidth: 0.5, dash: [2.5,2.5]))
.foregroundColor(Color.gray))
HStack { //CirclesView (bottom gray dashes)
Text("C")
.foregroundColor(Color.red)
Text("C")
.foregroundColor(Color.red)
VStack(alignment: .leading) { //UpContainerView (pink)
HStack {
Text("C")
Text("C")
}
HStack {
Text("C")
Text("C")
Text("C")
Text("C")
}
}
.foregroundColor(Color.lightPink)
}
.overlay(Rectangle()
.inset(by: 1.0)
.strokeBorder(style: StrokeStyle(lineWidth: 0.5, dash: [2.5,2.5]))
.foregroundColor(Color.gray))
}
.overlay(Rectangle().strokeBorder(style: StrokeStyle(lineWidth: 1.0, dash: [5,5])))
}
.border(Color.gray.opacity(0.5))
.padding()
}
}
I have added .leading horizontal alignment so the output is as close to the circle example as possible, but these play no role so can be ignored. We are only interested in the vertical alignment.
We get the following output.
I have put borders around the top level views. The top CirclesView is solid gray, it contains a blue Circle, a blue Circle and a DownContainerView all with black dashes, and the DownContainerView contains two CirclesViews with gray dashes.
None of the views have an alignment, by which I mean every view has exactly one alignment which has the default .center value. It is worth pausing for a second here—we are trying to align circles and they all have alignment guides at their centres but the centres of the blue circles do not align with any of the centres of the other coloured circles! The centres of the two black dashed blue circles are of course aligned with the centre of the black dashed DownContainerView/VStack.
The fact that the circles have been substituted for text hints at a possible solution. This is precisely the same problem that Apple solved with .firstTextBaseline and .lastTextBaseline. We want our top three red circles to align with the top row of the yellow down container, and we want the bottom two red circles to align with the bottom row of the pink up container. We can achieve this by putting .firstTextBaseline on the top gray dashed CirclesView and .lastTextBaseline on the bottom gray dashed CirclesView. Now that those are aligned we can also put .firstTextBaseline on the solid gray CirclesView.
e.g.
HStack(alignment: .firstTextBaseline) { //CirclesView (solid gray)
And we get...
Hooray problem solved! Not quite: we put the alignment property on the parent CirclesView/HStack not the child Up/DownContainer/VStack. The point is illustrated by changing the solid gray view alignment to .lastTextBaseline
HStack(alignment: .lastTextBaseline) { //CirclesView (solid gray)
The black dashed DownContainerView has been turned into a black dashed UpContainerView without any changes to itself!
This is what I hinted at previously about having only one type of container in a CirclesView in the test example data. We need to ensure that CirclesViews with both up and down containers work but with this solution the property on the CirclesView (first or last text baseline) turns all the immediate child container views into one type (either up or down).
What we need here is a custom alignment and the OP’s custom alignment is perfect. So let’s use it and replace all the text baselines with myAlignments.
e.g.
HStack(alignment: .myAlignment) { //CirclesView (solid gray)
We get the same as before with all centre alignments because myAlignment defaults to .center. The black dashed down container still doesn’t know how to choose between all the myAlignments of its children without some help from the developer. We need to explicitly tell the DownContainerView/VStack how to calculate the value of myAlignment which is half a circle hight from its top and then give it that alignment guide. Similarly the UpContainerView/VStack needs a .myAlignment guide half a circle height above its .bottom.
VStack(alignment: .leading) { //DownContainerView
.
.
.
}
.alignmentGuide(.myAlignment) { d in
d[.top] + halfCircleHeight
}
and
VStack(alignment: .leading) { //UpContainerView
.
.
.
}
.alignmentGuide(.myAlignment) { d in
d[.bottom] - halfCircleHeight
}
Now critically the CirclesView specifies what alignment it wants with (alignment: .myAlignment) and the Up/DownContainerView returns where that is with .alignmentGuide(.myAlignment)
This is the result:
Now the eagle eyed amongst you will have noticed that the top row blue, red and yellow circles do not line up exactly, and similarly the bottom red and pink circles do not line up exactly. This is because I have used a constant
private let halfCircleHeight = 6.0
for my calculations (half the default system font height.) Text has a little bit of white space above and below the line that I have not accounted for, and variations in font size would also misalign things slightly.
This is where preferences would come in. If you have different sized Circles or .padding or some other effect you may need to get the Up/DownContainerViews to interrogate their child view preferences to get the correct value to use in their alignment guide calculation. If this was wanted the child would use an anchorPreference to record its centre and the Up/DownContainerView would choose between all the child view preferences and use the appropriate one in its alignment guide calculation. (This is left as an exercise for the reader.) By the way, an anchorPreference is simply a preference that carries with it information about the coordinate system it is defined. The child can set the value in its coordinate system (its own centre) and the parent can read the value in its coordinate system (where that is in the parent view).
In summary there are no conflicts with alignment guides because there is only one value and therefore nothing to conflict with. Hopefully this explains why the OPs two green circles .myAlignment which have defaulted to .center aligns with the VStack (in the DownContainerView) .myAlignment which has also defaulted to .center.

Related

How do you add a view to the end of a multi-line Text view?

I have a Text view that is dynamic, and can sometimes be a long name that spans 2 or 3 lines. How can I add a view immediately next the last word of a multi-line Text view?
How can I move Age (23) immediately to the right of "last-name" ?
HStack(alignment: .bottom) {
Text("First Middle hyphenated-long-last-name")
Text("Age (\(age))")
.font(.caption2)
}
Add a plus(+) sign between the Text views. This will concatenate the Text views while keeping each Text view's unique formatting.
var body: some View {
HStack(alignment: .bottom) {
Text("First Middle hyphenated-long-last-name") +
Text(" Age (\(age))")
.font(.caption2)
}
}
Examples with different frame widths:
...and the HStack or other grouping is not needed when concatenating unless additional group formatting is needed.
If you don’t mind the age being the same size as the name, you can just put them in the same Text view:
HStack(alignment: .bottom) {
Text("First Middle hyphenated-long-last-name" + " Age (\(age))")
}

Can I use a GeometryReader to make a dynamic text size?

I have an app that has a view made of 8 VStacks of Text elements with an Image at the center. This works great on larger phones and tablets but on smaller phones, the Image gets resized so small so that the text content around it can fit.
I am using hardcoded font sizes. Is it a solution to this using a dynamic font size with the use of GeometryReader so that I can have a small "base" font size that will look decent on small screens and for large screens, I can have a multiplier based on the screen height?
See Option Three for GeometryReader
Yes, and here are three ways to do so (but only one uses GeometryReader).
Option One - #ScaledMetric
SwiftUI has a property wrapper called ScaledMetric - here are the docs: https://developer.apple.com/documentation/swiftui/scaledmetric. This is
A dynamic property that scales a numeric value.
So this means that you could assign your font sizes to variables, make them ScaledMetrics, and then they would auto-adjust. Here is some sample code for how to impliment this first option:
struct ExampleView: View {
#ScaledMetric(relativeTo: .body) var fontSize = 50
var body: some View {
Text("This will be scaled according to the Body font.")
.font(.system(size: fontSize))
}
}
Note
While you don't have to use the relativeTo property, this will ensure it scales according to that font. That property is a Font.TextStyle.
Citation: I used this article by Hacking With Swift for information: https://www.hackingwithswift.com/quick-start/swiftui/what-is-the-scaledmetric-property-wrapper.
Option Two - relativeTo in Font.Custom
SwiftUI provides the .font View Modifier. When initializing a font with this modifier, fill in the relativeTo field. See the docs: https://developer.apple.com/documentation/swiftui/font/custom(_:size:relativeto:). Example:
struct ExampleView: View {
var body: some View {
Text("This will be scaled according to the Body font.")
.font(.custom("Times New Roman", size: 24, relativeTo: .body))
}
}
And once again, that relativeTo property is a is a Font.TextStyle. This will scale the Text according to how that default font would normally scale.
Option Three - GeometryReader
Using a GeometryReader, you can get the width and height of a screen. Docs here: https://developer.apple.com/documentation/swiftui/geometryreader. Here is an article that I used for some information, and has a lot of good stuff: https://www.hackingwithswift.com/quick-start/swiftui/how-to-provide-relative-sizes-using-geometryreader. Here is an example:
struct ExampleView: View {
var body: some View {
GeometryReader{ geo in
Text("This will be scaled by screen height.")
.font(.custom("Times New Roman", size: geo.size.height * 0.05))
}
}
}

SwiftUI - grid with irregular rows

I'm trying to create the following grid format in SwiftUI:
For context, this is a row in a basket. I'm trying to use a LazyVGrid and so far have the following column layout defined:
let columns = [
GridItem(.flexible()),
GridItem(.flexible(minimum: 150), alignment: .topLeading),
GridItem(.flexible(), alignment: .topTrailing),
GridItem(.flexible(), alignment: .topTrailing)
]
private func refundedLine(_ line: PlacedOrderLine) -> some View {
LazyVGrid(columns: columns) {
itemImage(item: line.item)
VStack(alignment: .leading, spacing: 4) {
Text(viewModel.itemName(line.item))
.font(.Body2.regular())
Text(line.item.price.pricePerItemString)
.font(.Body2.semiBold())
}
Text("\(line.quantity)")
.font(.Body2.semiBold())
.foregroundColor(colorPalette.primaryRed)
Text(viewModel.pricePaid(line: line))
.font(.Body2.semiBold())
.foregroundColor(colorPalette.primaryRed)
.strikethrough()
}
}
This gets me everything I need, except for the 'Replaces' line. This line needs to sit just below the unit price, aligned with the item description, but should run the remaining width of the overall grid.
This line format is obviously repeated for each basket item. I obviously want to avoid using H/VStacks because aligning, for example, the quantity column (column 3) will be very difficult and it doesn't seem very extendible.
I'm aware of some advancements with grid layouts in iOS16 but we need to support at least iOS15

Unable to control SwiftUI macOS list selection styling

On macOS running natively (not in catalyst mode), a SwiftUI List with selection enabled has multiple style states controlled by what appear to be implicit variables that I can't seem to access. As a result, a custom foregroundColor on a View in a List behaves erratically. In particular, given the below example, there should in theory be 3 total styling states, but in reality there are effectively 5 possible styling states of Item 2:
State
Foreground color
Background color
Screenshot
A. Item 2 is selected and window is active
White
Accent
B. Item 2 is selected and window is inactive
*Black (but should be faded blue)
Gray
C. Item 2 is not selected and window is active (Item 2 also looks the same for an inactive window)
Blue
None
D. Item 2 was just clicked, but we need to wait ~300ms before it becomes selected
*Blue (but should be white)
Accent
E. Item 2 was previously selected, a new item is clicked, but we need to wait ~300ms before the new item becomes selected
*Black (but should be blue)
None
How can I control the foreground color in states B, D, and E? For cases D and E, it seems that there's some kind of built in debouncing on the macOS List selection that causes a brief delay after first clicking before actually becoming selected. Is there something like a not-debounced isHighlighted property? Clearly the built in system is using something like this as the non-custom list label colors immediately switch from black to white regardless of the debounce, as in Item 1 in state 5 above. For case B perhaps there's a different color property instead of foregroundColor that I need to use?
Any suggestions are much appreciated!
Minimally reproducable example (run on macOS native, not catalyst):
#main
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#State private var selected: String?
var body: some View {
NavigationView {
List {
NavigationLink(destination: Text("Detail 1"), tag: "1", selection: $selected) {
Label("Item 1", systemImage: "tag")
}
NavigationLink(destination: Text("Detail 2"), tag: "2", selection: $selected) {
Label {
Text("Item 2")
.foregroundColor(selected == "2" ? nil : .blue)
} icon: {
Image(systemName: "tag")
.foregroundColor(selected == "2" ? nil : .blue)
}
}
}
.toolbar {
Button {
} label: {
Image(systemName: "sidebar.left")
}
}
}
.navigationTitle("Test")
.listStyle(.sidebar)
}
}
Style state screenshots above all come from this example:

maxWidth infinity failure SwiftUI

I have the following code:
Button(action: {
}, label: {
Text("Save".uppercased())
.foregroundColor(.white)
.font(.headline)
.background(Color.accentColor)
.frame(height: 55)
.frame(maxWidth: .infinity)
.cornerRadius(10)
})
}
.padding(14)
I've checked it over and am clearly missing something because the max width is not working whatsoever. The button is still tightly confined around the "SAVE" text. I have also tried manually adjusting the width but this hasn't changed anything.
Any suggestions? I am running XCode 13.
Order matters a lot in view modifiers ;)
I suppose you want this:
Text("Save".uppercased())
.frame(maxWidth: .infinity)
.frame(height: 55)
.background(Color.accentColor)
.cornerRadius(10)
.foregroundColor(.white)
.font(.headline)
The Text itself is only as tall & wide as it needs to be, so first the frames to define the size, then the background color for that area, then the corner radius.
Foreground color and font can go anywhere.
You can see and check many of the (also if working) effects in the preview, where you can select single lines of code and see the resulting frame.