Search code examples
swiftswiftuiautolayout

Is it possible to modify the position of items in a LazyVGrid/LazyHGrid (SwiftUI 5.5)?


I'm relatively new to Swift and have a grid layout question: I'm currently using a LazyHGrid with adaptive grid items and a single column to flexibly layout items without a predetermined column count. I determine the number of columns shown by setting a maxWidth the grid layout with a maxWidth based on the number of items to be displayed

What I'm trying to accomplish in each of the scenarios below (with a solution that would scale up too) is essentially to adjust the positions in the examples below by shifting items out of position "🔴" and into position "⚪️", eliminating the "floating orphan" item not hugging the edge. The "⚫️" positions are already correct.

CONSTRAINTS

  • I want to minimize the width that this grid uses, but not all rows can accommodate the taller stacks (so setting a bunch of brittle rules seems like a poor solution) and I'm not interested in setting a fixed row item height to remove the need for a dynamic solution
  • I'd like to avoid creating grid manually with nested HStacks and VStacks and writing a bunch of extra logic to manage the contents of each row and column given the height of the parent row (as retrieved via GeometryReader).

WHAT I'VE TRIED

  • I looked in the Lazy_Grid docs to see if there was a view modifier that would accomplish this -- didn't come across one (though it's entirely possible I've missed it!)
  • I thought maybe flipping the layoutDirection (from LTR to RTL) might do it, but no such luck
  • I shifted around the alignment values of the repeated gridItem and lazyHGrid container, but the floating orphan remained

EXAMPLES

  • For three items:
/* short */
___________
      ⚫️⚫️|
      🔴⚪️|

/* tall */
___________
        ⚫️|
        ⚫️|
        ⚫️|
  • For four items:
/* short */
___________
      ⚫️⚫️|
      ⚫️⚫️|

/* tall */
___________    ___________
      ⚫️⚫️|          ⚫️⚫️| // NOTE: I *could* constrain the height in this case
      🔴⚪️|          ⚫️⚫️| // to achieve the 2x2 grid but would rather not
      🔴⚪️|
  • For five items:
/* short */
___________
    ⚫️⚫️⚫️|
    🔴⚫️⚪️|

/* tall */
___________
      ⚫️⚫️|
      ⚫️⚫️|
      🔴⚪️|
  • For six items:
/* short */
___________
    ⚫️⚫️⚫️|
    ⚫️⚫️⚫️|

/* tall */
___________
      ⚫️⚫️|
      ⚫️⚫️|
      ⚫️⚫️|
  • For seven items:
/* short */
___________
  ⚫️⚫️⚫️⚫️|
  🔴⚫️⚫️⚪️|

/* tall */
___________
      ⚫️⚫️|
      ⚫️⚫️|
      ⚫️⚫️|
      🔴⚪️|
let items = filterItems()
let iconCount = CGFloat(items.count)
let iconSize: CGFloat = 18
let spacing: CGFloat = 2

// NOTE: `iconCount / 2` because the min-height of the parent 
// only fits two items. The current item count is 7 and it's
// unlikely that it'd ever do more than double.

let cols = CGFloat(iconCount / 2).rounded(.up)
let maxWidth = calcWidth(for: cols, iconSize: iconSize, spacing: spacing)
let minWidth = cols < 2 ? maxWidth : calcWidth(for: cols - 1, iconSize: iconSize, spacing: spacing)

LazyHGrid(
  rows: Array(
    repeating: GridItem(
      .adaptive(minimum: iconSize, maximum: iconSize),
      spacing: spacing,
      alignment: .center
    ),
    count: 1
  ),
  alignment: .center,
  spacing: spacing
) {
  ForEach(0..<items.count, id: \.self) { n in

    ...item views...

  }
}
.frame(minWidth: minWidth, maxWidth: maxWidth)

My brain is stuck on finding a Swift concept similar to CSS's FlexBox justify-content: flex-end; align-items: flex-start but it feels to me there should be a more Flexy/Swifty solution for this that I'm just missing?

(The grid is nested within an HStack currently, and shoved to the top-trailing corner of it's parent with a spacer, effectively accomplishing the align-items: flex-start portion of the Flexbox solution mentioned above asd as shown in the illustrations above)


Solution

  • Edit: Layout direction works for me

    You mentioned this didn't work in your post, but I just tested setting layout direction to right-to-left, and it works. All the end items are aligned to the right. Maybe the specific OS you're testing on has a layout bug. The full playground snippet (tested on Xcode 13):

    import UIKit
    import PlaygroundSupport
    import SwiftUI
    import Foundation
    
    let iconCount: CGFloat = 4
    let iconSize: CGFloat = 18
    let spacing: CGFloat = 2
    let cols: CGFloat = 2
    let maxWidth = calcWidth(for: cols, iconSize: iconSize, spacing: spacing)
    let minWidth = cols < 2 ? maxWidth : calcWidth(for: cols - 1, iconSize: iconSize, spacing: spacing)
    
    func calcWidth(for cols: CGFloat, iconSize: CGFloat, spacing: CGFloat) -> CGFloat {
        return iconSize * cols + spacing * cols
    }
    
    PlaygroundPage.current.liveView = UIHostingController(rootView: {
        TabView {
            HStack {
                LazyHGrid(
                  rows: Array(
                    repeating: GridItem(
                      .adaptive(minimum: iconSize, maximum: iconSize),
                      spacing: spacing,
                      alignment: .center
                    ),
                    count: 1
                  ),
                  alignment: .center,
                  spacing: spacing
                ) {
                  ForEach(0..<Int(iconCount), id: \.self) { n in
                      Text("B")
                          .frame(width: iconSize, height: iconSize)
                          .border(.red)
                  }
                }
                .frame(minWidth: minWidth, maxWidth: maxWidth)
                .background(Color.green)
                .frame(height: iconSize * 3 + spacing * 2)
                .environment(\.layoutDirection, .rightToLeft)
                // I also tried this, but it isn't needed
                //.flipsForRightToLeftLayoutDirection(true)
            }
        }
    }())
    

    enter image description here

    Original answer:

    This is not the ideal solution, but one option is to use 3D rotation to flip the layout on the Y axis. I used a simple text view in Playgrounds as an example:

    LazyHGrid(
      rows: Array(
        repeating: GridItem(
          .adaptive(minimum: iconSize, maximum: iconSize),
          spacing: spacing,
          alignment: .center
        ),
        count: 1
      ),
      alignment: .center,
      spacing: spacing
    ) {
      ForEach(0..<Int(iconCount), id: \.self) { n in
          Text("B")
              .frame(width: iconSize, height: iconSize)
              .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
      }
    }
    .frame(minWidth: minWidth, maxWidth: maxWidth)
    .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
    

    enter image description here