-
-
Save xtabbas/97b44b854e1315384b7d1d5ccce20623 to your computer and use it in GitHub Desktop.
// | |
// 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() | |
} | |
} |
@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.
@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.
in NavigavionView set .navigationViewStyle(StackNavigationViewStyle()), There will be a bug back.
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.
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?
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
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.
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.
It looks slow when dragging. so I teak little bit
.animation(.spring())
to
.animation(UIState.screenDrag == 0 ? .easeOut : .linear(duration: 0), value: UIState.screenDrag)
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:
-
Add translation wrapper property
@GestureState var translation: CGFloat = 0
-
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) }
-
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!
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:
- Add translation wrapper property
@GestureState var translation: CGFloat = 0
- 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) }
- 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?
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!
@minierparedes I'm doing something slightly different than a "snapping" carrousel, but in theory, inside a
ScrollView
(and insideForEach
), you could wrap aGeometryReader
directly around eachItem(...)
, and that way you learn the Item's frame position from theGeometryProxy
(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.