Skip to content

Instantly share code, notes, and snippets.

@jboullianne
Last active March 22, 2023 05:31
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jboullianne/b2e5ae0f131b71dd6dd2347c632109a7 to your computer and use it in GitHub Desktop.
Save jboullianne/b2e5ae0f131b71dd6dd2347c632109a7 to your computer and use it in GitHub Desktop.
Implement Snap to Item scrolling in SwiftUI using this custom ViewModifier technique.
/
// ScrollingStackModifier.swift
// ScrollView_Tests
//
// Created by Jean-Marc Boullianne on 8/7/20.
//
import SwiftUI
struct ScrollingHStackModifier: ViewModifier {
@State private var scrollOffset: CGFloat
@State private var dragOffset: CGFloat
var items: Int
var itemWidth: CGFloat
var itemSpacing: CGFloat
init(items: Int, itemWidth: CGFloat, itemSpacing: CGFloat) {
self.items = items
self.itemWidth = itemWidth
self.itemSpacing = itemSpacing
// Calculate Total Content Width
let contentWidth: CGFloat = CGFloat(items) * itemWidth + CGFloat(items - 1) * itemSpacing
let screenWidth = UIScreen.main.bounds.width
// Set Initial Offset to first Item
let initialOffset = (contentWidth/2.0) - (screenWidth/2.0) + ((screenWidth - itemWidth) / 2.0)
self._scrollOffset = State(initialValue: initialOffset)
self._dragOffset = State(initialValue: 0)
}
func body(content: Content) -> some View {
content
.offset(x: scrollOffset + dragOffset, y: 0)
.gesture(DragGesture()
.onChanged({ event in
dragOffset = event.translation.width
})
.onEnded({ event in
// Scroll to where user dragged
scrollOffset += event.translation.width
dragOffset = 0
// Now calculate which item to snap to
let contentWidth: CGFloat = CGFloat(items) * itemWidth + CGFloat(items - 1) * itemSpacing
let screenWidth = UIScreen.main.bounds.width
// Center position of current offset
let center = scrollOffset + (screenWidth / 2.0) + (contentWidth / 2.0)
// Calculate which item we are closest to using the defined size
var index = (center - (screenWidth / 2.0)) / (itemWidth + itemSpacing)
// Should we stay at current index or are we closer to the next item...
if index.remainder(dividingBy: 1) > 0.5 {
index += 1
} else {
index = CGFloat(Int(index))
}
// Protect from scrolling out of bounds
index = min(index, CGFloat(items) - 1)
index = max(index, 0)
// Set final offset (snapping to item)
let newOffset = index * itemWidth + (index - 1) * itemSpacing - (contentWidth / 2.0) + (screenWidth / 2.0) - ((screenWidth - itemWidth) / 2.0) + itemSpacing
// Animate snapping
withAnimation {
scrollOffset = newOffset
}
})
)
}
}
@js51
Copy link

js51 commented Jan 17, 2022

This is great!!
I'm new to SwiftUI, do you know how I could access the index from the HStack itself, and refresh the view when it changes? I'm trying to increase the size of the 'selected' item.

@gregoryprosper
Copy link

@jboullianne I've been working on solving the same problem. But the issue I'm running into is on sending scroll events only when the user lifts their finger off of the screen. Adding a drag gesture to receive Touch up and Touch down events causes the scrollview to be unable to scroll as the drag gesture steals the touch events. I've tried using simultaneousGesture but that doesn't keep the events from being stolen from the scrollview.

https://gist.github.com/gregoryprosper/7c424479930ca313bd283846058880c3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment