Snap to Item Scrolling
One of the biggest things I'm missing with SwiftUI is the ability to snap to views as I scroll in a ScrollView
. Below is a technique I use to implement snapping on my HStack
with a custom ViewModifier
.
Before getting started, please consider subscribing using this link, and if you aren't reading this on TrailingClosure.com, please come check us out sometime!
Overview
Instead of using a ScrollView
, this technique uses an HStack
with a dynamically changing x-offset to simulate scrolling. To change the offset I use a DragGesture
to calculate the scroll distance. You'll see below inside the DragGesture
onChanged()
closure that I update the dragOffset
. This makes the HStack
appear to scroll as the user drags across the screen. Finally, when the DragGesture
function onEnded()
is called, I calculate which view item is closest and then apply the final offset. This creates the "snapping" effect.
A potential drawback is that you must provide the view item's width and spacing when creating the ViewModifier
. This means views with dynamically changing widths cannot use this technique. The ViewModifier
must know the width in order to calculate the correct position of view items.
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
}
})
)
}
}
Example Use
Given an array of colors, you can see how this custom ViewModifier
may be applied to an HStack
.
import SwiftUI
struct ContentView: View {
var colors: [Color] = [.blue, .green, .red, .orange]
var body: some View {
HStack(alignment: .center, spacing: 30) {
ForEach(0..<colors.count) { i in
colors[i]
.frame(width: 250, height: 400, alignment: .center)
.cornerRadius(10)
}
}.modifier(ScrollingHStackModifier(items: colors.count, itemWidth: 250, itemSpacing: 30))
}
}
Show us what you've made!
We want to see what you've made using this tutorial! Send us pics! Find us on Twitter @TrailingClosure, on Instagram, or email us at howdy@TrailingClosure.com.