Snap to Item Scrolling
Quick Tip SwiftUI

Snap to Item Scrolling

Jean-Marc Boullianne
Jean-Marc Boullianne

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 [email protected].