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()
}
}
@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?

@noveleven
Copy link

in NavigavionView set .navigationViewStyle(StackNavigationViewStyle()), There will be a bug back.

add clipped() to Canvas view to remove offset part.

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(.black)
            .cornerRadius(8)
            .shadow(color: .gray, radius: 4, x: 0, y: 4)
            .transition(AnyTransition.slide)
            .animation(.spring())
        }
    }
}
.clipped() <- here!

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