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()
}
}
@EricG-Personal
Copy link

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
Copy link

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
Copy link

Thanks @klaus95!

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

@orkenstein
Copy link

Great snippet, thanks!

@IbtihajT
Copy link

How to make this work in Landscape orientation?

@redimongo
Copy link

This does not work for tvOS

@IbtihajT
Copy link

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
Copy link

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.

@ronanociosoig-200
Copy link

ronanociosoig-200 commented Dec 29, 2020

Here's a fix for preventing going off the end:

.onEnded { value in
self.UIState.screenDrag = 0

        if (value.translation.width < -50) &&  self.UIState.activeCard < Int(numberOfItems) - 1 {
              self.UIState.activeCard = self.UIState.activeCard + 1
              let impactMed = UIImpactFeedbackGenerator(style: .medium)
              impactMed.impactOccurred()
        }
        
        if (value.translation.width > 50) && self.UIState.activeCard > 0 {
              self.UIState.activeCard = self.UIState.activeCard - 1
              let impactMed = UIImpactFeedbackGenerator(style: .medium)
              impactMed.impactOccurred()
        }
    }

@TD540
Copy link

TD540 commented Apr 24, 2021

@xtabbas Wouldn't it have worked out using GeometryReader inside your Card inside a horizontal ScrollView?

I'm trying to do something like this here, but with a vertical ScrollView, where items should grow larger based on the current on-screen position they're getting from a GeometryProxy. (But so far I've not found a solution yet)

@suatkarakusoglu
Copy link

DragGesture has weird default distance to start detection as '10' point.
It is probably better to make minimumDistance 0 for better ux.
You may try this at Carousel body view gesture > DragGesture(minimumDistance: 0)
Thanks. 🤟🏻

@satanworker
Copy link

satanworker commented May 2, 2021

is it ok to calculate animations like that? Is it running on CPU instead of Metal?
Does anyone try to use that with drawingGroup?

@minierparedes
Copy link

@TD540 Wondering if you were able to get it working with GeometryReader?

@TD540
Copy link

TD540 commented May 12, 2021

@minierparedes I'm doing something slightly different than a "snapping" carrousel, but in theory, inside a ScrollView (and inside ForEach), you could wrap a GeometryReader directly around each Item(...), and that way you learn the Item's frame position from the GeometryProxy (either using a .global, or a .named CoordinateSpace on the ScrollView), and with that position you could calculate a value for an .offset modifier on each Item, that elastically snaps to a fixed point.

Check out Understanding frames and coordinates inside GeometryReader, ScrollView effects using GeometryReader and Animating gestures.

@minierparedes
Copy link

@TD540 I really appreciate your taking the time to give me such great advice and especially the links to how to get more info. I am still very new to Swift and especially SwiftUI so I was not successful in implementing this. I still have a very high level of understanding and concept of how it should work but was not able to get it to work. I will have to continue working on it slowly. Thank you again. Interested in seeing what you are working on.

@TD540
Copy link

TD540 commented May 18, 2021

@minierparedes I've just set my personal little SwiftUI project to "public" 🤪. You can check out what I'm doing with GeometryReader in RecordBoxView.swift.

@alienjun
Copy link

alienjun commented Sep 1, 2021

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

@danielaRiesgo
Copy link

In case it's useful for anyone: the id of the items must equal their index in order to correctly set their height when scrolling.
Or what I did was set the _id of Item to the index value but set the real view id to the real one that depends on the data.

@llieusedie
Copy link

Is there a way I can dynamically change the background color (slide transition) depending on a current item in a carousel?

For example, each card represents a topic, like "Nature", "Art", "Sports", etc. How do I change the background color when the user chooses one of the topics?

@GefeiSHEN
Copy link

GefeiSHEN commented Jan 10, 2023

Any one encountered this runtime warning? Is there a way to resolve this warning?

Publishing changes from within view updates is not allowed, this will cause undefined behavior.


update: this should be an Xcode 14 specific issue, please refer to https://developer.apple.com/forums/thread/711899

@MartinLBarron
Copy link

Great snippet and article. I was wondering if anyone else solved the issue GefeiShHEN noted:

Publishing changes from within view updates is not allowed, this will cause undefined behavior.

The forum post he referred to returns 404 and googling around didn't find any obvious fix.

@GefeiSHEN
Copy link

GefeiSHEN commented Mar 16, 2023

Great snippet and article. I was wondering if anyone else solved the issue GefeiShHEN noted:

Publishing changes from within view updates is not allowed, this will cause undefined behavior.

The forum post he referred to returns 404 and googling around didn't find any obvious fix.

Hi @MartinLBarron! Please try to click on that link again, it shall be fixed now. Alternatively, you could try copy and paste the url directly to your browser.

@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?

@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