Open aoberoi opened 5 months ago
Did some research regarding .containerRelativeFrame()
and ContainerRelativeShape
. Unfortunately, it does not compute a relative inset frame from an arbitrary container. I was hoping this would work, so that i could just use the count
and spacing
arguments to place 1...3
card symbols inside the CardView
. It seems like the description in the devdocs is accurate:
Different things can represent a “container” including:
- The window presenting a view on iPadOS or macOS, or the screen of a device on iOS.
- A column of a NavigationSplitView
- A NavigationStack
- A tab of a TabView
- A scrollable view like ScrollView or List
Here's a sub-problem:
Within CardSymbols
, a GeometryReader
is used in order to read the size of the area so that a decision can be made about whether the symbols will be stacked vertically or horizontally. However, this leads to the use of a switch
in the body where the two branches have distinct view identity. This is bad practice, and might actually be interfering with animations (I have no evidence of this right now, but it seems to theoretically be possible that an orientation change would trigger an animated change where the previous symbols fade out and the new symbols fade in).
I want to explore at least 3 potential solutions:
ViewThatFits
to automatically switch between a VStack
and HStack
. I'm actually not sure how this does or does not impact view identity, so that's worth checking first.Layout
that switches under the right circumstances.Layout
implementation, type-erased (to not affect identity) to AnyLayout
, that is not actually tied to VStack
and HStack
as views. These are VStackLayout
and HStackLayout
.Thinking about whether I should actually be using a Grid
for the card symbols:
I think its nice that a Grid can actually subdivide the view area evenly across a number of rows or columns, using a given spacing. However, I'm not sure if a Grid can be used to lay out a variable number of symbols in the way I want. Take the layout for 2 symbols; I wouldn't want them to be in the first two "rows" and leave the 3rd row blank. Instead the 2 symbols should be centered within the column with spacing between, and take the width of a single row, if there were 3 rows total.
Since I'm not sure, it seems worthwhile to explore this as I'm working on the sub-problem mentioned above. There's also a GridLayout
that can be type-erased to AnyLayout
. I guess two separate Grid
s could be listed in the ViewThatFits
as well.
Relevant and applied notes coming out of WWDC2022's Compose custom layouts with SwiftUI
So far, it seems like the Grid
view only arranges its children using GridRow
containers. This would make it challenging to express a vertical stack arrangement of symbols that switches to a horizontal stack. It would require conditionally removing up GridRow
s from the body and conditionally iterating symbols within a single GridRow
.
Actually, a view outside of a GridRow
is shorthand for a single cell that spans all of the columns. So in theory the conditional logic could switch between including the symbols in a single GridRow
and including the symbols in the Grid
(outside of any GridRow
). This still seems like it would change view identity, which is not helping with the current issue.
One cool thing about flexible frames (the .frame()
modifier with arguments that have max/min in the name): they still have ideal width and ideal height from the views they contain and is not the size the frame wants to grow/shrink to. This allows parent views/layouts of the frame to "measure" ideal width and height in a way that still communicates down to the frame's children.
ViewThatFits
just takes the first child that fits within the available space (the space that was proposed to it or some idea that reaches out to the container?). I don't think the distance-to-ideal-size is ever checked. It seems like if the child takes up exactly all the space that was proposed to it, then ViewThatFits will always select it, and its not really doing anything. The way it asks the child whether it fits is similar to putting that child in a .fixedSize()
- which means its essentially asking that child for its ideal size. In our case, shapes have an ideal size of 10x10, and that would be reflected in the ideal size of a stack of those shapes. The ideal size of GeometryReader
is also 10x10. We could change whether the symbols themselves just take up exactly all the space that is proposed to them. I think this can be done by using a flexible frame with idealWidth
/idealHeight
arguments. I think fixedSize()
only overwrites the ideal size, so that's probably not how we accomplish that. But I'm not sure this is the correct check I'm looking for. I think what I'd rather have is the distance-to-ideal-size check (which in theory can be done with a custom Layout
).
AnyLayout
is the way to switch layout types while maintaining view identity for the child views.
Now that I've done a little research, lets revisit the sub-problem ideas shared above.
I want to explore at least 3 potential solutions:
- Using a
ViewThatFits
to automatically switch between aVStack
andHStack
. I'm actually not sure how this does or does not impact view identity, so that's worth checking first.- Implementing a custom
Layout
that switches under the right circumstances.- Using a built-in
Layout
implementation, type-erased (to not affect identity) toAnyLayout
, that is not actually tied toVStack
andHStack
as views. These areVStackLayout
andHStackLayout
.
ViewThatFits
on its own does not seem to preserve view identity (I have not tested this, but it seems to follow from the fact that it wouldn't be possible to preserve structural identity if the options were not similar. Even if this could be worked around with explicit identity (.id()
), I don't think it would yield a desirable result). The other problem is that there's no way to use a "distance to ideal size" as the way to select the view that fits. And in the case we have here, both vertical and horizontal stacking will always fit, it's just that one be less ideal. I could "fake" the fit or unfit by manipulating the ideal size of certain views with modifiers, but I can't think of a way this would actually result in switching layouts when it makes the most sense (it would always be based on some arbitrary minimum that I define is bad in one direction but okay in the other). This won't on its own be a solution.
Custom Layout
: this does seem to be the best way to solve the problem because it can both preserve view identity and can operate based on the proposed size (without the use of a GeometryReader). The downside would be that its a bit more complex to implement. However, the fact that I can call into the VStackLayout
and HStackLayout
implementations should greatly simplify the logic I actually need to perform in the custom layout.
Using AnyLayout
as a way to preserve view identity and apply conditional layouts. In theory, this is the most direct and elegant solution to the problem. However, I can't think of a way to use the proposed size in the condition without using a GeometryReader. It should actually be okay to use a GeometryReader for this purpose, since it's flowing layout information from parent to child (as opposed to the other direction, in which GeometryReader is a problematic choice). But it would be a little less encapsulated. Given that the custom layout approach above should be a relatively simple implementation, that's what I should try first.
After some time attempting to implement a custom Layout for this purpose, I'm starting to realize its not as straight-forward as I thought.
The simple logic I was hoping to apply was: Given a certain proposal from the parent, if the width is greater than the height, use the HStackLayout and return the size it returns. Otherwise, use the VStackLayout and return the size it returns. Also use that layout choice to respond to all other protocol methods.
However, the proposal isn't always a concrete size - it can be 0.0
, nil
, or .infinity
. These represent the parent view querying to know what the minimum, ideal, and maximum size are for the layout. It is further complicated because these values or a concrete value can be used in either dimension in the proposal.
I tried to respond to these other possibilities by updating my logic, but after several approaches I've concluded that there isn't a great way to express the choice between the two layouts in a simple way. The closest I came was to pass the proposal to both layouts, and calculate how close they came to the offered space, and pick the layout which came closest. In this, closest meant the lowest 2-dimensional distance found by the Pythagorean formula. But this doesn't help because how can you compare the distance to nil
or .infinity
? I tried baking in rules like a proposal of infinity in one dimension means the correct layout choice is the one that stacks in that direction, but what about when both dimensions are infinity? Then I'd make an arbitrary (and stable) choice. Okay, then what about nil? Maybe just treat nil like 0.0. At this point the logic is no longer simple, and its no longer clear that its accomplishing the original intent.
Taking a step back, in this app it seems I'd never want to choose a VStackLayout
because the aspect ratio of CardView
s is fixed at 2/1
(twice as wide as they are tall). This means I could avoid this entire issue by just sticking with an HStack
. Then there's also no need to use a GeometryReader and no concern for structural view identity to undesirably change. It should just be noted that this layout choice is closely coupled to the DrawingConstant chosen for aspect ratio. If I want to take this a step further, maybe I can use a computed value for the layout based on the constant aspect ratio so the relationship is codified.
Some kind of an IdealStack
might be useful in other contexts, but at this point I won't waste time trying to implement it when it wouldn't be used for anything in this application.
CardSymbols
andCardSymbol
are kind of a mess right now. There's a bunch of TODOs and commented out code citing why things are not ideal. But overall, there's an opportunity to simplify the implementation using newer SwiftUI API.First, the
.containerRelativeFrame()
modifier could be used to layout the symbols insideCardSymbols
. This might eliminate the use of the GeometryReader and might simplify animation debugging.It's a bit strange that we handle the number of symbols on the card in
CardSymbols
but all the other properties of the card (shading, color, shape) are handled inCardSymbol
. I think this was mostly done because I needed some layer of abstraction to deal with erasing the type. If this is the case, I'd like to document this in the code/comments better. It might make sense to pass theCard
model deeper into the view hierarchy to reduce code duplication.There are some code cleanup opportunities in
CardSymbol
.