Reputation: 1574
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:
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?
Upvotes: 1
Views: 205
Reputation: 21870
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)
}
}
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
}
}
Upvotes: 0