Skip to content

Instantly share code, notes, and snippets.

@gregoryprosper
Last active April 18, 2022 03:27
Show Gist options
  • Save gregoryprosper/7c424479930ca313bd283846058880c3 to your computer and use it in GitHub Desktop.
Save gregoryprosper/7c424479930ca313bd283846058880c3 to your computer and use it in GitHub Desktop.
Implement Snap to Item in horizontal scrolling scrollview in SwiftUI using custom scrollview.
//
// LazyHScrollView.swift
// Nail Library
//
// Created by Gregory Prosper on 4/14/22.
// Copyright © 2022 Greg Prosper. All rights reserved.
//
import SwiftUI
import Combine
struct HSnapScrollView<Content: View, T, ID : Hashable>: View, Equatable {
let content: Content
let itemCount: Int
let itemWidth: Int
let spaceWidth: Int
let items: [T]
let id: KeyPath<T, ID>
private let detector: CurrentValueSubject<CGFloat, Never>
private let publisher: AnyPublisher<CGFloat, Never>
init(
itemCount: Int,
itemWidth: Int,
spaceWidth: Int,
items: [T],
id: KeyPath<T, ID>,
@ViewBuilder content: () -> Content
) {
self.content = content()
self.itemCount = itemCount
self.itemWidth = itemWidth
self.spaceWidth = spaceWidth
self.items = items
self.id = id
let detector = CurrentValueSubject<CGFloat, Never>(0)
self.publisher = detector
.debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
self.detector = detector
}
var body: some View {
ScrollViewReader { scrollView in
ScrollView(.horizontal, showsIndicators: false) {
ZStack {
content
GeometryReader { proxy in
let offset = proxy.frame(in: .named("scrollView")).minX
Color.clear.preference(key: ScrollViewOffsetPreferenceKey.self, value: abs(offset))
}
}
}
.coordinateSpace(name: "scrollView")
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) {
detector.send($0)
}
.onReceive(publisher) { scrollOffset in
// Now calculate which item to snap to
let screenWidth = UIScreen.main.bounds.width
// Center position of current offset
let center = scrollOffset + (screenWidth / 2.0)
// Calculate which item we are closest to using the defined size
var index = (center) / (CGFloat(itemWidth) + CGFloat(spaceWidth))
// 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(itemCount) - 1)
index = max(index, 0)
withAnimation {
if Int(index) == itemCount - 1 {
scrollView.scrollTo(items[Int(index)][keyPath: id], anchor: .trailing)
} else {
scrollView.scrollTo(items[Int(index)][keyPath: id], anchor: .leading)
}
}
}
}
}
static func == (lhs: HSnapScrollView<Content, T, ID>, rhs: HSnapScrollView<Content, T, ID>) -> Bool {
return lhs.items.map { $0[keyPath: lhs.id] } == rhs.items.map { $0[keyPath: rhs.id] } &&
lhs.itemCount == rhs.itemCount &&
lhs.itemWidth == rhs.itemWidth &&
lhs.spaceWidth == rhs.spaceWidth &&
lhs.id == rhs.id
}
}
fileprivate struct ScrollViewOffsetPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
HSnapScrollView(
itemCount: items.count,
itemWidth: 200,
spaceWidth: 8,
items: nails,
id: \.id
) {
LazyHStack {
ForEach(items, id: \.id) { item in
CustomView(url: item.url)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment