aoberoi / SwiftUI-Set

CS193P Set game using SwiftUI (Spring 2023)
0 stars 0 forks source link

Try new ideas for layout of CardSymbols #23

Open aoberoi opened 5 months ago

aoberoi commented 5 months ago

CardSymbols and CardSymbol 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 inside CardSymbols. 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 in CardSymbol. 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 the Card model deeper into the view hierarchy to reduce code duplication.

There are some code cleanup opportunities in CardSymbol.

aoberoi commented 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
aoberoi commented 5 months ago

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:

aoberoi commented 5 months ago

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 Grids could be listed in the ViewThatFits as well.

aoberoi commented 5 months ago

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 GridRows 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.

aoberoi commented 5 months ago

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 a VStack and HStack. 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) to AnyLayout, that is not actually tied to VStack and HStack as views. These are VStackLayout and HStackLayout.
aoberoi commented 5 months ago

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 CardViews 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.