Following is the animation i want to achieve, when the view appears until the scrollview is scrolled the views are stacked one behind the other, and when they are scrolled it appears side by side.
struct ContentView: View {
private let colors: [Color] = [.red, .green, .blue, .yellow, .purple, .black, .brown, .cyan, .gray, .indigo, .orange, .pink, .mint, .teal]
var body: some View {
VStack {
ScrollView(.horizontal) {
LazyHStack(spacing: 10.0) {
ForEach(colors, id: \.self) { color in
RoundedRectangle(cornerRadius: 25.0)
.foregroundStyle(color)
.containerRelativeFrame(.horizontal)
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1: 0.2)
.scaleEffect(y: phase.isIdentity ? 1.0 : 0.8)
}
}
}
.scrollTargetLayout()
}
.contentMargins(60.0, for: .scrollContent)
.scrollTargetBehavior(.viewAligned)
.scrollIndicators(.hidden)
.frame(maxHeight: 400.0)
Spacer()
}
}
}
I am able to get the views side by side, but how to get the views arranged one behind and then smoothly transition to side by side when scrolled.
Thanks to Mat, have got until here, need help to bring both the two looks together:
struct ColorViewModel: Identifiable {
let id: Int
let color: Color
}
struct ContentView: View {
private let colors: [ColorViewModel] = [.init(id: 0, color: .red), .init(id: 1, color: .yellow), .init(id: 2, color: .green), .init(id: 03, color: .blue), .init(id: 04, color: .purple), .init(id: 05, color: .pink), .init(id: 06, color: .brown), .init(id: 07, color: .indigo), .init(id: 08, color: .black), .init(id: 09, color: .teal), .init(id: 10, color: .gray), .init(id: 011, color: .white), .init(id: 012, color: .cyan)]
@State private var hasScrolled: Bool = false
var body: some View {
VStack {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10.0) {
ForEach(0 ..< colors.count, id: \.self) { number in
RoundedRectangle(cornerRadius: /*@START_MENU_TOKEN@*/25.0/*@END_MENU_TOKEN@*/)
.frame(maxHeight: 300.0)
.containerRelativeFrame(.horizontal)
.foregroundStyle(colors[number].color)
.if(!hasScrolled) { view in
view
.padding(.trailing, colors[number].id == colors.last?.id ? 0 : -220)
.zIndex(CGFloat(-number))
}
// .scrollTransition { content, phase in
// content
// .opacity(phase.isIdentity ? 1 : 0.2)
// .scaleEffect(y: phase.isIdentity ? 1.0 : 0.8)
// }
}
}
.scrollTargetLayout()
}
.contentMargins(60.0, for: .scrollContent)
.scrollTargetBehavior(.viewAligned)
Spacer()
}
}
}
extension View {
@ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
To achieve that you can play with rotation values and spacings between items. You can do that by doing something like this:
struct CoverFlowView<Content: View, Item: RandomAccessCollection>: View where Item.Element: Identifiable {
//MARK: - PROPERTIES
/// Customization
var itemWidth: CGFloat
var spacing: CGFloat = .zero
var rotation: CGFloat = .zero
var items: Item
var content: (Item.Element) -> Content
var body: some View {
GeometryReader {
let size = $0.size
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 0) {
ForEach(items) { item in
content(item)
.frame(width: itemWidth)
.visualEffect { content, geometryProxy in
content
.rotation3DEffect(.degrees(rotation(geometryProxy)),
axis: (x: 0, y: 1, z: 0),
anchor: .center)
}
.padding(.trailing, item.id == items.last?.id ? 0 : spacing)
} //: LOOP ITEMS
} //: LAZY HSTACK
.padding(.horizontal, (size.width - itemWidth) / 2)
.scrollTargetLayout()
} //: SCROLL
.scrollTargetBehavior(.viewAligned)
.scrollClipDisabled()
} //: GEOMETRY
}
//MARK: - Functions
func rotation(_ proxy: GeometryProxy) -> Double {
let scrollViewWidth = proxy.bounds(of: .scrollView(axis: .horizontal))?.width ?? 0
let midX = proxy.frame(in: .scrollView(axis: .horizontal)).midX
/// Converting into progress
let progress = midX / scrollViewWidth
/// Capping progress 0-1
let cappedProgress = max(min(progress, 1), 0)
/// Limit rotation between 0-90°
let cappedRotation = max(min(rotation, 90), 0)
let degree = cappedProgress * (cappedRotation * 2)
return cappedRotation - degree
}
}
And you use it like this:
/// State variables
@State private var spacing: CGFloat = 0
@State private var rotation: CGFloat = 0
@State private var mirrorCards = false
CoverFlowView(itemWidth: 250,
spacing: spacing,
rotation: rotation,
items: items) { item in
RoundedRectangle(cornerRadius: 20)
.fill(item.color.gradient)
}
.frame(height: 180)
.rotation3DEffect(
.degrees(mirrorCards ? 180 : 0),
axis: (x: 0.0, y: 1.0, z: 0.0)
)
///Gesture
.gesture(
DragGesture()
.onChanged({ value in
let predictedTranslationX = value.predictedEndTranslation.width
print("Predicted end: \(predictedTranslationX)")
if predictedTranslationX >= 60 {
/// Close Cards
withAnimation {
spacing = -250
}
} else if predictedTranslationX <= 150 {
withAnimation {
spacing = -100
}
}
})
)
/// Or if you want it to work with tap gesture
.onTapGesture {
if spacing == -250 {
withAnimation {
spacing = -100
}
} else if spacing == -100 {
withAnimation {
spacing = -220
}
}
}
The item I'm using in this simple case is just an Identifiable struct with a color property, but you can use anything you like really. If you use a spacing of around -220 and the mirror effect (by using the rotation3DEffect) you can achieve a similar result to the one you are looking for:
Let me know how did you find this solution!