Skip to content

Instantly share code, notes, and snippets.

@xtabbas
Created May 10, 2020 18:13
Show Gist options
  • Save xtabbas/97b44b854e1315384b7d1d5ccce20623 to your computer and use it in GitHub Desktop.
Save xtabbas/97b44b854e1315384b7d1d5ccce20623 to your computer and use it in GitHub Desktop.
A carousel that snap items in place build on top of SwiftUI
//
// SnapCarousel.swift
// prototype5
//
// Created by xtabbas on 5/7/20.
// Copyright © 2020 xtadevs. All rights reserved.
//
import SwiftUI
struct SnapCarousel: View {
@EnvironmentObject var UIState: UIStateModel
var body: some View {
let spacing: CGFloat = 16
let widthOfHiddenCards: CGFloat = 32 /// UIScreen.main.bounds.width - 10
let cardHeight: CGFloat = 279
let items = [
Card(id: 0, name: "Hey"),
Card(id: 1, name: "Ho"),
Card(id: 2, name: "Lets"),
Card(id: 3, name: "Go")
]
return Canvas {
/// TODO: find a way to avoid passing same arguments to Carousel and Item
Carousel(
numberOfItems: CGFloat(items.count),
spacing: spacing,
widthOfHiddenCards: widthOfHiddenCards
) {
ForEach(items, id: \.self.id) { item in
Item(
_id: Int(item.id),
spacing: spacing,
widthOfHiddenCards: widthOfHiddenCards,
cardHeight: cardHeight
) {
Text("\(item.name)")
}
.foregroundColor(Color.white)
.background(Color("surface"))
.cornerRadius(8)
.shadow(color: Color("shadow1"), radius: 4, x: 0, y: 4)
.transition(AnyTransition.slide)
.animation(.spring())
}
}
}
}
}
struct Card: Decodable, Hashable, Identifiable {
var id: Int
var name: String = ""
}
public class UIStateModel: ObservableObject {
@Published var activeCard: Int = 0
@Published var screenDrag: Float = 0.0
}
struct Carousel<Items : View> : View {
let items: Items
let numberOfItems: CGFloat //= 8
let spacing: CGFloat //= 16
let widthOfHiddenCards: CGFloat //= 32
let totalSpacing: CGFloat
let cardWidth: CGFloat
@GestureState var isDetectingLongPress = false
@EnvironmentObject var UIState: UIStateModel
@inlinable public init(
numberOfItems: CGFloat,
spacing: CGFloat,
widthOfHiddenCards: CGFloat,
@ViewBuilder _ items: () -> Items) {
self.items = items()
self.numberOfItems = numberOfItems
self.spacing = spacing
self.widthOfHiddenCards = widthOfHiddenCards
self.totalSpacing = (numberOfItems - 1) * spacing
self.cardWidth = UIScreen.main.bounds.width - (widthOfHiddenCards*2) - (spacing*2) //279
}
var body: some View {
let totalCanvasWidth: CGFloat = (cardWidth * numberOfItems) + totalSpacing
let xOffsetToShift = (totalCanvasWidth - UIScreen.main.bounds.width) / 2
let leftPadding = widthOfHiddenCards + spacing
let totalMovement = cardWidth + spacing
let activeOffset = xOffsetToShift + (leftPadding) - (totalMovement * CGFloat(UIState.activeCard))
let nextOffset = xOffsetToShift + (leftPadding) - (totalMovement * CGFloat(UIState.activeCard) + 1)
var calcOffset = Float(activeOffset)
if (calcOffset != Float(nextOffset)) {
calcOffset = Float(activeOffset) + UIState.screenDrag
}
return HStack(alignment: .center, spacing: spacing) {
items
}
.offset(x: CGFloat(calcOffset), y: 0)
.gesture(DragGesture().updating($isDetectingLongPress) { currentState, gestureState, transaction in
self.UIState.screenDrag = Float(currentState.translation.width)
}.onEnded { value in
self.UIState.screenDrag = 0
if (value.translation.width < -50) {
self.UIState.activeCard = self.UIState.activeCard + 1
let impactMed = UIImpactFeedbackGenerator(style: .medium)
impactMed.impactOccurred()
}
if (value.translation.width > 50) {
self.UIState.activeCard = self.UIState.activeCard - 1
let impactMed = UIImpactFeedbackGenerator(style: .medium)
impactMed.impactOccurred()
}
})
}
}
struct Canvas<Content : View> : View {
let content: Content
@EnvironmentObject var UIState: UIStateModel
@inlinable init(@ViewBuilder _ content: () -> Content) {
self.content = content()
}
var body: some View {
content
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
.background(Color.white.edgesIgnoringSafeArea(.all))
}
}
struct Item<Content: View>: View {
@EnvironmentObject var UIState: UIStateModel
let cardWidth: CGFloat
let cardHeight: CGFloat
var _id: Int
var content: Content
@inlinable public init(
_id: Int,
spacing: CGFloat,
widthOfHiddenCards: CGFloat,
cardHeight: CGFloat,
@ViewBuilder _ content: () -> Content
) {
self.content = content()
self.cardWidth = UIScreen.main.bounds.width - (widthOfHiddenCards*2) - (spacing*2) //279
self.cardHeight = cardHeight
self._id = _id
}
var body: some View {
content
.frame(width: cardWidth, height: _id == UIState.activeCard ? cardHeight : cardHeight - 60, alignment: .center)
}
}
struct SnapCarousel_Previews: PreviewProvider {
static var previews: some View {
SnapCarousel()
}
}
@hankyungs
Copy link

hankyungs commented Apr 13, 2023

It looks slow when dragging. so I teak little bit

.animation(.spring())
to
.animation(UIState.screenDrag == 0 ? .easeOut : .linear(duration: 0), value: UIState.screenDrag)

@alwacker
Copy link

alwacker commented Jul 3, 2023

Hi @xtabbas, thanks for amazing solution. It really help to find way out!

I would like consider a small improvement in case bouncing at start and the end:

  1. Add translation wrapper property
    @GestureState var translation: CGFloat = 0

  2. In .updating method of gesture you should update this translation property.Like here:
    .updating($translation) { value, out, _ in out = value.translation.width self.UIState.screenDrag = Float(value.translation.width) }

  3. After that, using it for calculation offset
    CGFloat(calcOffset) - (translation / 2)
    This one will create a scroll limit in the beginning of carousel, and at the end!

Cheers!

@andreaagudo3
Copy link

Hi @xtabbas, thanks for amazing solution. It really help to find way out!

I would like consider a small improvement in case bouncing at start and the end:

  1. Add translation wrapper property
    @GestureState var translation: CGFloat = 0
  2. In .updating method of gesture you should update this translation property.Like here:
    .updating($translation) { value, out, _ in out = value.translation.width self.UIState.screenDrag = Float(value.translation.width) }
  3. After that, using it for calculation offset
    CGFloat(calcOffset) - (translation / 2)
    This one will create a scroll limit in the beginning of carousel, and at the end!

Cheers!

Would you mind sharing your approach?

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