-1

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
asked yesterday
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.
1
  • 1
    There 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). Commented yesterday

0

Know someone who can answer? Share a link to this question via email, Twitter, or Facebook.

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.