Animate layout change

I want to show a view, where the user can add or remove items shown as icons, which are sorted in two groups: squares and circles.

When there are only squares, they should be shown in one row:
[] [] []
When there are so many squares that they don’t fit horizontally, a (horizontal) scrollview will be used, with scroll-indicator always shown to indicate that not all squares are visible.

When there are only circles, they also should be shown in one row:
() () ()
When there are so many circles that they don’t fit horizontally, a (horizontal) scrollview will be used, with scroll-indicator always shown to indicate that not all circles are visible.

When there a few squares and a few circles, they should be shown adjacent in one row:
[] [] () ()
When there are so many squares and circles that they don’t fit horizontally, they should be shown in two rows, squares on top, circles below:
[] [] []
() () ()
When there are either too many squares or too many circles (or both) to fit horizontally, one common (horizontal) scrollview will be used, with scroll-indicator always shown to indicate that not all items are visible.

I started with ViewThatFits: (see first code block)

{
let squares = HStack {
ForEach(model.squares, id: \.self) { square in
Image(square)
}
}
let circles = HStack {
ForEach(model.circles, id: \.self) { circle in
Image(circle)
}
}
let oneLine = HStack {
squares
circles
}
let twoLines = VStack {
squares
circles
}
let scrollView = ScrollView(.horizontal) {
twoLines
}.scrollIndicators(.visible)
ViewThatFits(in: .horizontal) {
oneLine
twoLines
scrollView.clipped()
}
}

While this works in general, it doesn’t animate properly.

When the user adds or removes an image the model gets updated, (see second code block)

withAnimation(Animation.easeIn(duration: 0.25)) {
model.squares += image
}

and the view animates with the existing images either making space for a new appearing square/circle, or moving together to close the gap where an image disappeared. This works fine as long as ViewThatFits returns the same view.

However, when adding 1 image leads to ViewThatFits switching from oneLine to twoLines, this switch is not animated. The circles jump to the new position under the squares, instead of sliding there.

I searched online for a solution, but this seems to be a known problem of ViewThatFits. It doesn't animate when it switches...

(tbc)

Adapting sample code for a "Flow Layout", I built this Layout, to be able to animate changes: (see code block below)

struct MyLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard !subviews.isEmpty else {
return .zero
}
// ask subviews for their ideal size
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
var totalHeight: CGFloat = 0
var totalWidth: CGFloat = 0
var lineWidth: CGFloat = 0
var lineHeight: CGFloat = 0
for size in sizes {
if lineWidth > 0 {
if let propWidth = proposal.width {
print("sizeThatFits: proposal.width: \(propWidth)")
if lineWidth + horzSpacing + size.width > propWidth {
totalHeight += lineHeight + vertSpacing // next line + spacing
lineWidth = size.width
lineHeight = size.height
} else {
lineWidth += size.width + horzSpacing // next item + spacing
lineHeight = max(lineHeight, size.height)
}
} else {
lineWidth += size.width + horzSpacing // next item + spacing
lineHeight = max(lineHeight, size.height)
}
} else { // first subview, just place it
lineWidth = size.width
lineHeight = size.height
}
totalWidth = max(totalWidth, lineWidth)
}
totalHeight += lineHeight // first line, zero if there is no first
return .init(width: totalWidth, height: totalHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
var lineX = bounds.minX
var lineY = bounds.minY
var lineHeight: CGFloat = 0
for index in subviews.indices {
let size = sizes[index]
if lineX > bounds.minX {
if let propWidth = proposal.width {
print("placeSubviews: proposal.width: \(propWidth)")
if lineX + size.width > propWidth {
lineY += lineHeight + vertSpacing
lineHeight = 0
lineX = bounds.minX
}
}
}
subviews[index].place(at: .init(x: lineX, y: lineY),
anchor: .zero,
proposal: ProposedViewSize(size)
)
lineHeight = max(lineHeight, size.height)
lineX += size.width + horzSpacing
}
}
}

But this is never called with the space available in proposal, thus it cannot calculate whether the circles would fit adjacent to the squares, or whether they should be moved to the next row. I wonder how the "Flow Layout" should work at all...

Then I found this explanation about sizeThatFits:
/// the parent view can call this method more than once with different proposals:
/// .zero for the layout’s minimum size
/// .infinity for the layout’s maximum size
/// .unspecified for the layout’s ideal size
which absolutely makes no sense IMHO. I need the exact (horizontal) space available as input to be able to calculate my layout, and place the subviews... Of course, I could return oneLine both as ideal and maximum size, and twoLines as minimum size, but it really depends on how much space there is which layout should be chosen, and whether I need to put twoLines into a (horizontal) scrollView or not.

I remember having the same problem 15 years ago when implementing heightForTableViewCell: You tell me how much space I have horizontally, then I can return how much space I need vertically to place my content. Was tricky then, seems still not easy now :-(

The sample code Apple provides for layout:
"Composing custom layouts with SwiftUI"
is completely useless. sizeThatFits should measure the box around the circle layout where the Avatars are placed, but it always returns the same size since that doesn't change at all, only the subview placement changes based on the user input.

So, any tips how I could solve my animation problem?

Animate layout change
 
 
Q