2

I was hoping to make a paginated grid view in SwiftUI with continues dividers both horizontal and vertical but have been having trouble doing so. This is what I want: enter image description here

This is the best that I came up with:

struct ContentView: View { let rows = [ GridItem(.fixed(90)), GridItem(.fixed(74)) ] var body: some View { TabView { // Page 1 pageView(startIndex: 0) // Page 2 pageView(startIndex: 8) } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .always)) } func pageView(startIndex: Int) -> some View { ScrollView(.horizontal, showsIndicators: false) { LazyHGrid(rows: rows, spacing: 0) { ForEach(startIndex..<startIndex+8, id: \.self) { index in VStack(spacing: 0) { if !index.isMultiple(of: 2) { Divider() .frame(height: 2) .overlay(Color.gray) } HStack(spacing: 0) { Spacer() Divider() .frame(width: 2) .overlay(Color.gray) Spacer() RoundedRectangle(cornerRadius: 10) .fill(Color.blue) .frame(width: 80, height: 80) } } } } } } } 

This doesn't really work as is and the parts that make it look like it does are hacky. For example, the rows should really be the same height but I found making the second one smaller makes the vertical dividers touch. What is the best way to accomplish this effect in SwiftUI?

2
  • Your example happens to fill the full width of an iPhone 16, so when you swipe the first page, it moves to the grid on the second page and it looks like the two grids are joined. They are actually two distinct grids, which is clearer to see in landscape orientation. So the ScrollView is currently redundant. Do you actually want paged scrolling? If so, .scrollTargetBehavior could be used, without the TabView. Commented Dec 18, 2024 at 22:35
  • Ah, thank you @BenzyNeez, I do want paged scrolling Commented Dec 18, 2024 at 23:40

1 Answer 1

0

Paging

An easy way to get paged scrolling for the grid is to use .scrollTargetBehavior(.paging). The structure for this is as follows:

ScrollView(.horizontal, showsIndicators: false) { LazyHGrid(rows: rows) { // ... } .scrollTargetLayout() } .scrollTargetBehavior(.paging) 

So I would suggest removing the TabView and creating a single grid for displaying all items.

Grid lines

One way to show the grid lines is to show them in the background behind the cells, using negative padding to extend into the spacing between the cells.

Whether or not negative padding is needed and a divider line should be shown depends on whether the cell is in the first or last row of the grid, or in the first or last column. A cell's grid position can be determined from the index of the associated item in the array of all items. This just requires knowing the number of rows in the grid and the overall number of items.


Working example

Here is the updated example to show it working this way. More notes follow below.

struct ContentView: View { static private let spacing: CGFloat = 18 private let cellWidth: CGFloat = 80.25 private let lineWidth: CGFloat = 2 private let rows = [ GridItem(.fixed(90), spacing: Self.spacing), GridItem(.fixed(74), spacing: Self.spacing) ] var body: some View { let indices = Array(0..<100) return ScrollView(.horizontal, showsIndicators: false) { LazyHGrid(rows: rows, spacing: Self.spacing) { ForEach(Array(indices.enumerated()), id: \.offset) { offset, index in RoundedRectangle(cornerRadius: 10) .fill(.blue) .frame(width: cellWidth) .overlay { Text("\(index)") .font(.title) .foregroundStyle(.white) } .background { gridLines(cellIndex: offset, nItems: indices.count) } } } .scrollTargetLayout() .padding(.horizontal, Self.spacing / 2) } .scrollTargetBehavior(.paging) } private func gridLines(cellIndex: Int, nItems: Int) -> some View { let nRows = rows.count let nCols = Int((Double(nItems) / Double(nRows)).rounded(.up)) let isFirstRow = cellIndex % nRows == 0 let isLastRow = cellIndex % nRows == nRows - 1 let isFirstCol = cellIndex < nRows let isLastCol = cellIndex / nRows == nCols - 1 let negativePadding = -((Self.spacing + lineWidth) / 2) return ZStack { if !isLastRow { Color.gray .frame(height: lineWidth) .frame(maxHeight: .infinity, alignment: .bottom) } if !isLastCol { Color.gray .frame(width: lineWidth) .frame(maxWidth: .infinity, alignment: .trailing) } } .padding(.leading, isFirstCol ? 0 : negativePadding) .padding(.trailing, isLastCol ? 0 : negativePadding) .padding(.top, isFirstRow ? 0 : negativePadding) .padding(.bottom, isLastRow ? 0 : negativePadding) } } 

Animation

Notes

  • If you want to prevent the pages from "drifting" as they are scrolled, the size of (cell width + spacing) needs to divide exactly into the screen width. For example, on an iPhone 16, the screen has a width of 393 points, so a cell width of 80.25 and spacing of 18 gives exact pages (as used above).

  • The parameter spacing that is supplied to LazyHGrid is only used for horizontal spacing. To control the vertical spacing, add spacing as a parameter to the GridItem too.

  • The widths of the cells in your example are fixed, which works well for the gridlines. However, if the widths should be dynamic, such as if the cells contained just Text, the grid lines would not be in the right places. A workaround might be to determine the size of the widest cell in a column using the technique shown in this answer.

  • For the grid lines to work, it is also important that the cell content fits inside the cell. In your original example, you were setting a height of 80 on the content. This was overflowing the available height for the cells in the second row, because you have given the GridItem for the second row a fixed height of 74. In the updated example above, the height of the cells is determined by the grid instead.


Page indicators

When you were using a TabView, you also had visible page indicators. The indicators are missing from the solution above.

  • A way to implement page indicators would be to add a state variable to track the .scrollPosition.

  • If the width of the screen is known, the current page index can be derived from the current scroll position.

  • The width of the screen can be found by attaching an .onGeometryChange modifier to the ScrollView. Although not apparent from its name, this modifier also reports the initial size on first load.

  • Page dots can then be shown as an overlay, like it is being done in this answer (it was my answer).

These are the changes needed:

@State private var scrollPosition: Int? @State private var cellsPerPage = 0.0 @State private var nPages = 0 
return ScrollView(.horizontal, showsIndicators: false) { // ...as before } .scrollTargetBehavior(.paging) .scrollPosition(id: $scrollPosition, anchor: .topLeading) .overlay(alignment: .bottom) { // See https://stackoverflow.com/a/78472742/20386264 PageDots(nPages: nPages, currentIndex: currentPageIndex) } .onGeometryChange(for: CGFloat.self) { proxy in proxy.size.width } action: { screenWidth in cellsPerPage = (Double(rows.count) * screenWidth) / (cellWidth + Self.spacing) if cellsPerPage > 0 { nPages = Int((Double(indices.count) / cellsPerPage).rounded(.up)) } } 
private var currentPageIndex: Int { if let scrollPosition, cellsPerPage > 0 { let exactIndex = Double(scrollPosition) / cellsPerPage let roundedIndex = exactIndex.rounded() // 0.1 used as precision tolerance let index = Int(roundedIndex) + (exactIndex - roundedIndex < 0.1 ? 0 : 1) // Enforce limits return max(0, min(index, nPages - 1)) } else { return 0 } } 

Screenshot

Sign up to request clarification or add additional context in comments.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.