The issue I'm having is that, even though scrolling with the trackpad and the drag gesture both work to flip through pages in the scrollview, the animation when landing on a page is jerky and not visually appealing. It seems like instead of following the offset to land on a page it waits for that page to reset back to zero and then performs scrollTo. When the scrollTo animates it basically goes back to the old page really quickly and scrolls to the new one.
Any suggestions?
import SwiftUI
import SwiftData
struct HorizontalPagedGridView: View {
//Rest of the code...
let isPageCentered: (CGRect, CGFloat) -> Bool
@State private var dragTranslation: CGSize = .zero
var body: some View {
ScrollViewReader { value in
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(0..<filteredPages, id: \.self) { page in
// Set Page Start and End Indexes
let startIndex = appsPerPage * page
let endIndex = Swift.min(startIndex + appsPerPage, filteredItems.count)
let itemCountOnPage = endIndex - startIndex
let columnsNeeded = rowsPerPage > 0 ? Int(ceil(Double(max(itemCountOnPage, 0)) / Double(rowsPerPage))) : 0
let columnsThisPage = max(0, min(columnsPerPage, columnsNeeded))
ZStack {
// Create LazyVGrid with a dynamic number of columns to preserve item order (row-major)
// Using flexible instead of fixed to prevent crashes when window is resized smaller
let columns: [SwiftUI.GridItem] = Array(
repeating: SwiftUI.GridItem(.flexible(minimum: iconSize, maximum: hoveredIconSize + 20), spacing: 0, alignment: .top),
count: columnsThisPage
)
LazyVGrid(columns: columns, alignment: .center, spacing: 0) {
let pageItems = Array(filteredItems[startIndex..<endIndex])
ForEach(pageItems, id: \.path) { item in
GridItemView()
}
}
.animation(.interactiveSpring(), value: filteredItems.map { 0ドル.path })
.containerRelativeFrame(.vertical)
}
.containerRelativeFrame(.horizontal)
.scrollTargetLayout()
.offset(x: dragTranslation.width)
.id(page)
.background(
GeometryReader { pageGeo in
Color.clear
.onChange(of: pageGeo.frame(in: .named("pager")).minX) {
let frame = pageGeo.frame(in: .named("pager"))
if isPageCentered(frame, geometry.size.width) {
if searchViewModel.currentPageIndex != page {
searchViewModel.currentPageIndex = page
}
}
}
}
)
}
}
.animation(.easeInOut(duration: 0.4), value: filteredPages)
}
.scrollIndicators(.never, axes: .horizontal)
.scrollTargetBehavior(.paging)
.scrollDisabled(dragTranslation != .zero)
.coordinateSpace(name: "pager")
.safeAreaPadding(.horizontal, 40)
.onAppear {
isAnyWindowOpen = true
// Restore the saved page index
searchViewModel.currentPageIndex = currentPageSetting
// Perform initial scan with completion handler to scroll after items are loaded
gridViewModel.performInitialScan() {
// Scroll to the saved page after a brief delay to ensure layout is complete
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
value.scrollTo(currentPageSetting, anchor: .center)
}
}
// Start real-time update timer (every 3 seconds)
gridViewModel.startUpdateTimer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay {
GeometryReader { overlayGeo in
ZStack {
HStack(spacing: 10) {
Button {
withAnimation {
showSidebar.toggle()
}
} label: {
Image(systemName: "sidebar.left")
.resizable()
.frame(width: 18, height: 18)
}
.buttonStyle(.plain)
Button(action: {
withAnimation(.snappy) {
showSettingsPanel.toggle()
}
}) {
Image(systemName: "gear")
.resizable()
.frame(width: 18, height: 18)
}
.clipShape(.circle)
.buttonStyle(.plain)
}
.position(x: 42, y: 46)
// Page dots at bottom center
PageDots(currentPage: $searchViewModel.currentPageIndex, numberOfPages: filteredPages, scrollProxy: value)
.frame(maxHeight: 40)
.position(x: overlayGeo.size.width / 2, y: overlayGeo.size.height - 32)
.zIndex(1)
// Edge auto-scroll drop zones (active only while dragging in edit mode)
HStack {
// Left edge
Color.clear
.frame(width: 80)
.contentShape(Rectangle())
.dropDestination(for: String.self) { _, _ in
// No-op on drop to edge; we only use hover targeting
dragDropViewModel.draggingItem = nil
cancelAutoScroll()
return false
} isTargeted: { targeted in
guard isEditModeOn else { return }
if targeted {
dragDropViewModel.edgeHoverDirection = -1
scheduleAutoScroll(-1, value)
} else {
dragDropViewModel.edgeHoverDirection = nil
cancelAutoScroll()
}
}
Spacer()
// Right edge
Color.clear
.frame(width: 80)
.contentShape(Rectangle())
.dropDestination(for: String.self) { _, _ in
dragDropViewModel.draggingItem = nil
cancelAutoScroll()
return false
} isTargeted: { targeted in
guard isEditModeOn else { return }
if targeted {
dragDropViewModel.edgeHoverDirection = 1
scheduleAutoScroll(1, value)
} else {
dragDropViewModel.edgeHoverDirection = nil
cancelAutoScroll()
}
}
}
.opacity(isEditModeOn && dragDropViewModel.draggingItem != nil ? 1 : 0)
.animation(.easeInOut(duration: 0.15), value: dragDropViewModel.draggingItem != nil)
}
}
}
.onTapGesture(perform: {
if !isEditModeOn {
isAnyWindowOpen = false
dismissWindow(id: "appDeck")
} else {
isEditModeOn = false
}
})
.simultaneousGesture(DragGesture()
.onChanged{ gesture in
self.dragTranslation = gesture.translation
}
.onEnded { gesture in
let translation = gesture.translation.width
let screenWidth = geometry.size.width
withAnimation(.smooth) {
self.dragTranslation = .zero
}
if translation < -screenWidth * 0.3 {
if searchViewModel.currentPageIndex < filteredPages - 1 {
searchViewModel.nextPage(maxPages: filteredPages)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
withAnimation(.smooth) {
value.scrollTo(searchViewModel.currentPageIndex, anchor: .center)
}
}
}
} else if translation > screenWidth * 0.3 {
if searchViewModel.currentPageIndex > 0 {
searchViewModel.previousPage()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
withAnimation(.smooth) {
value.scrollTo(searchViewModel.currentPageIndex, anchor: .center)
}
}
}
}
})
}
.clipShape(RoundedRectangle(cornerRadius: 20))
.clipped()
}
}
jonrsharpe
123k30 gold badges277 silver badges488 bronze badges
New contributor
KaliforniaGator is a new contributor to this site. Take care in asking for clarification, commenting, and answering.
Check out our Code of Conduct.
-
1There are a few basic SwiftUI mistakes, \.self isn’t valid id path, can’t use dispatch async in structs (they have no lifetime), shouldn’t have view models (use binding).malhal– malhal2025年11月15日 23:20:39 +00:00Commented yesterday
lang-swift