Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
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()
}
}
@MatthewWaller

This comment has been minimized.

Copy link

@MatthewWaller MatthewWaller commented May 15, 2020

Hmmm. I added this to a sample project and I'm unable to get the carousel to appear. It as fails in the preview canvas saying there is a circular reference.

Edit: Nevermind, silly me I forgot to change the colors. As for preview, I ran into a bug where if you name your project the name of a file (I named mine Carousel), then you get the failure in preview.

@MatthewWaller

This comment has been minimized.

Copy link

@MatthewWaller MatthewWaller commented May 15, 2020

This is really great! As an add challenge, I'm trying to make it where if you do a quick gesture, like a quick scroll, it moves past several cards before settling on one and snapping.

@xtabbas

This comment has been minimized.

Copy link
Owner Author

@xtabbas xtabbas commented May 15, 2020

Great! Would love to see the outcome!! Follow me on twitter @xtabbas

@gbrigens

This comment has been minimized.

Copy link

@gbrigens gbrigens commented May 15, 2020

Am the sample code above but am running to this error Fatal error: No ObservableObject of type UIStateModel found. A View.environmentObject(_:) for UIStateModel may be missing as an ancestor of this view.: file SwiftUI, line 0 No ObservableObject of type UIStateModel found. A View.environmentObject(_:) for UIStateModel may be missing as an ancestor of this view.: file SwiftUI, line 0. Looking forward to hearing from you.

@MatthewWaller

This comment has been minimized.

Copy link

@MatthewWaller MatthewWaller commented May 15, 2020

@gbrigens you’ll need to add UIStateModel as an environment object in the preview and in your scenedelegate if you’re running on device

@EricG-Personal

This comment has been minimized.

Copy link

@EricG-Personal EricG-Personal commented May 18, 2020

If you're interested, I created https://github.com/ericg-learn-apple/SnapCarousel implementing this view.

One thing I noticed is that it was possible to go off the end. You should no longer be able to swipe right or left if there is no other card to swipe to. I haven't looked into fixing it yet.

MatthewWaller, would be interested to see your enhancement as well.

@klaus95

This comment has been minimized.

Copy link

@klaus95 klaus95 commented Jun 29, 2020

To restrict swiping at the left or right most cards change:

  • condition at line 116 with (value.translation.width < -50 && CGFloat(self.UIState.activeCard) < numberOfItems - 1)
  • condition at line 122 with (value.translation.width > 50 && CGFloat(self.UIState.activeCard) > 0)
@mckeever02

This comment has been minimized.

Copy link

@mckeever02 mckeever02 commented Jul 12, 2020

Thanks @klaus95!

Note: to get that working I had to amend numberOfItems to self.numberOfItems

@orkenstein

This comment has been minimized.

Copy link

@orkenstein orkenstein commented Sep 22, 2020

Great snippet, thanks!

@IbtihajT

This comment has been minimized.

Copy link

@IbtihajT IbtihajT commented Sep 30, 2020

How to make this work in Landscape orientation?

@redimongo

This comment has been minimized.

Copy link

@redimongo redimongo commented Oct 12, 2020

This does not work for tvOS

@IbtihajT

This comment has been minimized.

Copy link

@IbtihajT IbtihajT commented Oct 12, 2020

This does not work for tvOS

I didn’t say that i wanna make it work on TV os. I just asked for how to make it work in landscape orientation

@jlegeny

This comment has been minimized.

Copy link

@jlegeny jlegeny commented Nov 8, 2020

Hello, I needed to do something similar. I have found your article and after a bit of shuffling around came up with this version. Compared to yours it needs to wrap inner Carousel views in a CarouselCard, but it requires less info sent down the pipes (ids are auto calculated): https://gist.github.com/jlegeny/0781d81834102e2bf2f99423e1fa26ed

I have some issues with code paths that re-create the carousel though, looking into it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.