How to rearrange views in SwiftUI ZStack by dragging - swift

How can I rearrange a view's position in a ZStack by dragging it above or below another view (e.g. in this instance how can I rearrange the order of the cards in the deck by dragging a card above or below another card, to move the dragged card behind or in front of said card in deck).
I want for the card to change indices when dragged up or down in the stack and fluidly appear behind each and every card in the stack as it is dragged- and stay there on mouse up.
Summary: In other words, the card dragged and the cards above it should switch as I drag up and the card dragged and the cards below it should switch as I drag down.
I figure this has something to do with changing the ZStack order in struct CardView: View and updating the position from inside DragGesture().onChanged by evaluating how much the card has been dragged (perhaps by viewing the self.offset value) but I have not been able to work out how to do this in a reliable way.
Here's what I have right now:
Code:
import SwiftUI
let cardSpace:CGFloat = 10
struct ContentView: View {
#State var cardColors: [Color] = [.orange, .green, .yellow, .purple, .red, .orange, .green, .yellow, .purple]
var body: some View {
HStack {
VStack {
CardView(colors: self.$cardColors)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.position(x: 370, y: 300)
}
}
struct CardView: View {
#State var offset = CGSize.zero
#State var dragging:Bool = false
#State var tapped:Bool = false
#State var tappedLocation:Int = -1
#Binding var colors: [Color]
#State var locationDragged:Int = -1
var body: some View {
GeometryReader { reader in
ZStack {
ForEach(0..<self.colors.count, id: \.self) { i in
ColorCard(reader:reader, i:i, colors: self.$colors, offset: self.$offset, tappedLocation: self.$tappedLocation, locationDragged:self.$locationDragged, tapped: self.$tapped, dragging: self.$dragging)
}
}
}
.animation(.spring())
}
}
struct ColorCard: View {
var reader: GeometryProxy
var i:Int
#State var offsetHeightBeforeDragStarted: Int = 0
#Binding var colors: [Color]
#Binding var offset: CGSize
#Binding var tappedLocation:Int
#Binding var locationDragged:Int
#Binding var tapped:Bool
#Binding var dragging:Bool
var body: some View {
VStack {
Group {
VStack {
self.colors[i]
}
.frame(width: 300, height: 400)
.cornerRadius(20).shadow(radius: 20)
.offset(
x: (self.locationDragged == i) ? CGFloat(i) * self.offset.width / 14
: 0,
y: (self.locationDragged == i) ? CGFloat(i) * self.offset.height / 4
: 0
)
.offset(
x: (self.tapped && self.tappedLocation != i) ? 100 : 0,
y: (self.tapped && self.tappedLocation != i) ? 0 : 0
)
.position(x: reader.size.width / 2, y: (self.tapped && self.tappedLocation == i) ? -(cardSpace * CGFloat(i)) + 0 : reader.size.height / 2)
}
.rotationEffect(
(i % 2 == 0) ? .degrees(-0.2 * Double(arc4random_uniform(15)+1) ) : .degrees(0.2 * Double(arc4random_uniform(15)+1) )
)
.onTapGesture() { //Show the card
self.tapped.toggle()
self.tappedLocation = self.i
}
.gesture(
DragGesture()
.onChanged { gesture in
self.locationDragged = self.i
self.offset = gesture.translation
self.dragging = true
}
.onEnded { _ in
self.locationDragged = -1 //Reset
self.offset = .zero
self.dragging = false
self.tapped = false //enable drag to dismiss
self.offsetHeightBeforeDragStarted = Int(self.offset.height)
}
)
}.offset(y: (cardSpace * CGFloat(i)))
}
}

check this out:
the "trick" is that you just need to reorder the z order of the items. therefore you have to "hold" the cards in an array.
let cardSpace:CGFloat = 10
struct Card : Identifiable, Hashable, Equatable {
static func == (lhs: Card, rhs: Card) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
var id = UUID()
var intID : Int
static let cardColors: [Color] = [.orange, .green, .yellow, .purple, .red, .orange, .green, .yellow, .purple]
var zIndex : Int
var color : Color
}
class Data: ObservableObject {
#Published var cards : [Card] = []
init() {
for i in 0..<Card.cardColors.count {
cards.append(Card(intID: i, zIndex: i, color: Card.cardColors[i]))
}
}
}
struct ContentView: View {
#State var data : Data = Data()
var body: some View {
HStack {
VStack {
CardView().environmentObject(data)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// .position(x: 370, y: 300)
}
}
struct CardView: View {
#EnvironmentObject var data : Data
#State var offset = CGSize.zero
#State var dragging:Bool = false
#State var tapped:Bool = false
#State var tappedLocation:Int = -1
#State var locationDragged:Int = -1
var body: some View {
GeometryReader { reader in
ZStack {
ForEach(self.data.cards, id: \.self) { card in
ColorCard(card: card, reader:reader, offset: self.$offset, tappedLocation: self.$tappedLocation, locationDragged:self.$locationDragged, tapped: self.$tapped, dragging: self.$dragging)
.environmentObject(self.data)
.zIndex(Double(card.zIndex))
}
}
}
.animation(.spring())
}
}
struct ColorCard: View {
#EnvironmentObject var data : Data
var card: Card
var reader: GeometryProxy
#State var offsetHeightBeforeDragStarted: Int = 0
#Binding var offset: CGSize
#Binding var tappedLocation:Int
#Binding var locationDragged:Int
#Binding var tapped:Bool
#Binding var dragging:Bool
var body: some View {
VStack {
Group {
VStack {
card.color
}
.frame(width: 300, height: 400)
.cornerRadius(20).shadow(radius: 20)
.offset(
x: (self.locationDragged == card.intID) ? CGFloat(card.zIndex) * self.offset.width / 14
: 0,
y: (self.locationDragged == card.intID) ? CGFloat(card.zIndex) * self.offset.height / 4
: 0
)
.offset(
x: (self.tapped && self.tappedLocation != card.intID) ? 100 : 0,
y: (self.tapped && self.tappedLocation != card.intID) ? 0 : 0
)
.position(x: reader.size.width / 2, y: (self.tapped && self.tappedLocation == card.intID) ? -(cardSpace * CGFloat(card.zIndex)) + 0 : reader.size.height / 2)
}
.rotationEffect(
(card.zIndex % 2 == 0) ? .degrees(-0.2 * Double(arc4random_uniform(15)+1) ) : .degrees(0.2 * Double(arc4random_uniform(15)+1) )
)
.onTapGesture() { //Show the card
self.tapped.toggle()
self.tappedLocation = self.card.intID
}
.gesture(
DragGesture()
.onChanged { gesture in
self.locationDragged = self.card.intID
self.offset = gesture.translation
if self.offset.height > 60 ||
self.offset.height < -60 {
withAnimation {
if let index = self.data.cards.firstIndex(of: self.card) {
self.data.cards.remove(at: index)
self.data.cards.append(self.card)
for index in 0..<self.data.cards.count {
self.data.cards[index].zIndex = index
}
}
}
}
self.dragging = true
}
.onEnded { _ in
self.locationDragged = -1 //Reset
self.offset = .zero
self.dragging = false
self.tapped = false //enable drag to dismiss
self.offsetHeightBeforeDragStarted = Int(self.offset.height)
}
)
}.offset(y: (cardSpace * CGFloat(card.zIndex)))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(Data())
}
}

Just an idea (cause requires re-think/re-code your solution). Reorder in your case needs usage/modification of card zIndex so it needs to be stored somewhere.
Thus instead of directly use color as model you needed more explicit model object
struct Card {
var color: Color
var deckOrder: Int
}
Note: below is in pseudo-code, you have to adapt it yourself
next, you keep and iterate by cards (and I would separate them info ObsesrvableObject view model)
ForEach(Array(vm.cards.enumerated()), id: \.element) { i, card in
ColorCard(reader:reader, i:i, cards: self.$vm.cards,
offset: self.$offset, tappedLocation: self.$tappedLocation,
locationDragged:self.$locationDragged, tapped: self.$tapped,
dragging: self.$dragging)
.zIndex(card.deckOrder)
}
now changing card.deckOrder on drag you will change zIndex of view/card in deck.

Building on Chris's answer I have come up with this.
Some shortcomings are: it requires one drag per movement versus one drag to move up or down the deck indefinitely.
Demo:
import SwiftUI
let cardSpace:CGFloat = 10 + 20
struct Card : Identifiable, Hashable, Equatable {
static func == (lhs: Card, rhs: Card) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
var id = UUID()
var intID : Int
static let cardColors: [Color] = [.orange, .green, .yellow, .purple, .red, .orange, .green, .yellow, .purple]
var zIndex : Int
var color : Color
}
class Data: ObservableObject {
#Published var cards : [Card] = []
init() {
for i in 0..<Card.cardColors.count {
cards.append(Card(intID: i, zIndex: i, color: Card.cardColors[i]))
}
}
}
struct ContentView: View {
#State var data : Data = Data()
var body: some View {
HStack {
VStack {
CardView().environmentObject(data)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// .position(x: 370, y: 300)
}
}
struct CardView: View {
#EnvironmentObject var data : Data
#State var offset = CGSize.zero
#State var dragging:Bool = false
#State var tapped:Bool = false
#State var tappedLocation:Int = -1
#State var locationDragged:Int = -1
var body: some View {
GeometryReader { reader in
ZStack {
ForEach((0..<self.data.cards.count), id: \.self) { i in
ColorCard(card: self.data.cards[i], reader:reader, offset: self.$offset, tappedLocation: self.$tappedLocation, locationDragged:self.$locationDragged, tapped: self.$tapped, dragging: self.$dragging, i: i)
.environmentObject(self.data)
.zIndex(Double(self.data.cards[i].zIndex))
}
}
}
.animation(.spring())
}
}
struct ColorCard: View {
#EnvironmentObject var data : Data
var card: Card
var reader: GeometryProxy
#State var offsetHeightBeforeDragStarted: Int = 0
#Binding var offset: CGSize
#Binding var tappedLocation:Int
#Binding var locationDragged:Int
#Binding var tapped:Bool
#Binding var dragging:Bool
#State var i: Int
#State var numTimesCalledSinceDragBegan: Int = 0
var body: some View {
VStack {
Group {
VStack {
card.color
}
.frame(width: 300, height: 400)
.cornerRadius(20).shadow(radius: 20)
.offset(
x: (self.numTimesCalledSinceDragBegan <= 1 && self.locationDragged == card.intID) ? CGFloat(card.zIndex) * self.offset.width / 14
: 0,
y: (self.numTimesCalledSinceDragBegan <= 1 && self.locationDragged == card.intID) ? CGFloat(card.zIndex) * self.offset.height / 4
: 0
)
.offset(
x: (self.tapped && self.tappedLocation != card.intID) ? 100 : 0,
y: (self.tapped && self.tappedLocation != card.intID) ? 0 : 0
)
.position(x: reader.size.width / 2, y: (self.tapped && self.tappedLocation == card.intID) ? -(cardSpace * CGFloat(card.zIndex)) + 0 : reader.size.height / 2)
}
// .rotationEffect(
// (card.zIndex % 2 == 0) ? .degrees(-0.2 * Double(arc4random_uniform(15)+1) ) : .degrees(0.2 * Double(arc4random_uniform(15)+1) )
// )
// .onTapGesture() { //Show the card
// self.tapped.toggle()
// self.tappedLocation = self.card.intID
//
// }
.gesture(
DragGesture()
.onChanged { gesture in
self.numTimesCalledSinceDragBegan += 1
self.locationDragged = self.card.intID
self.offset = gesture.translation
if(self.numTimesCalledSinceDragBegan == 1) {
if let index = self.data.cards.firstIndex(of: self.card) {
if(self.offset.height >= 0) {self.i += 1 } else {self.i -= 1}
self.data.cards.remove(at: index)
self.data.cards.insert(self.card, at:
(self.offset.height >= 0) ? self.i : self.i
)
for index in 0..<self.data.cards.count {
self.data.cards[index].zIndex = index
}
}
}
self.dragging = true
}
.onEnded { _ in
self.locationDragged = -1 //Reset
self.offset = .zero
self.dragging = false
self.tapped = false //enable drag to dismiss
self.offsetHeightBeforeDragStarted = Int(self.offset.height)
self.numTimesCalledSinceDragBegan = 0
}
)
}.offset(y: (cardSpace * CGFloat(card.zIndex)))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(Data())
}
}

Related

Slide Carousel cards on Cards tap in SwiftUI

I have created a carousel cards in SwiftUI, it is working on the DragGesture
I want to achieve same experience on the tap of cards i.e. on .onTapGesture, which ever cards is being tapped it should be slide to centre on the screen like shown in the video attached
My current code -
import SwiftUI
struct Item: Identifiable {
var id: Int
var title: String
var color: Color
}
class Store: ObservableObject {
#Published var items: [Item]
let colors: [Color] = [.red, .orange, .blue, .teal, .mint, .green, .gray, .indigo, .black]
// dummy data
init() {
items = []
for i in 0...7 {
let new = Item(id: i, title: "Item \(i)", color: colors[i])
items.append(new)
}
}
}
struct ContentView: View {
#StateObject var store = Store()
#State private var snappedItem = 0.0
#State private var draggingItem = 0.0
#State var activeIndex: Int = 0
var body: some View {
ZStack {
ForEach(store.items) { item in
// article view
ZStack {
RoundedRectangle(cornerRadius: 18)
.fill(item.color)
Text(item.title)
.padding()
}
.frame(width: 200, height: 200)
.scaleEffect(1.0 - abs(distance(item.id)) * 0.2 )
.opacity(1.0 - abs(distance(item.id)) * 0.3 )
.offset(x: myXOffset(item.id), y: 0)
.zIndex(1.0 - abs(distance(item.id)) * 0.1)
}
}
.gesture(getDragGesture())
.onTapGesture {
//move card to centre
}
}
private func getDragGesture() -> some Gesture {
DragGesture()
.onChanged { value in
draggingItem = snappedItem + value.translation.width / 100
}
.onEnded { value in
withAnimation {
draggingItem = snappedItem + value.predictedEndTranslation.width / 100
draggingItem = round(draggingItem).remainder(dividingBy: Double(store.items.count))
snappedItem = draggingItem
//Get the active Item index
self.activeIndex = store.items.count + Int(draggingItem)
if self.activeIndex > store.items.count || Int(draggingItem) >= 0 {
self.activeIndex = Int(draggingItem)
}
}
}
}
func distance(_ item: Int) -> Double {
return (draggingItem - Double(item)).remainder(dividingBy: Double(store.items.count))
}
func myXOffset(_ item: Int) -> Double {
let angle = Double.pi * 2 / Double(store.items.count) * distance(item)
return sin(angle) * 200
}
}
You need to apply the .onTapGesture() modifier to the single item inside the ForEach, not around it.
Then, you just need to handle the different cases, comparing the tapped item with the one currently on the front, and change the value of draggingItem accordingly.
Here's the code inside the view's body:
ZStack {
ForEach(store.items) { item in
// article view
ZStack {
RoundedRectangle(cornerRadius: 18)
.fill(item.color)
Text(item.title)
.padding()
}
.frame(width: 200, height: 200)
.scaleEffect(1.0 - abs(distance(item.id)) * 0.2 )
.opacity(1.0 - abs(distance(item.id)) * 0.3 )
.offset(x: myXOffset(item.id), y: 0)
.zIndex(1.0 - abs(distance(item.id)) * 0.1)
// Here is the modifier - on the item, not on the ForEach
.onTapGesture {
// withAnimation is necessary
withAnimation {
draggingItem = Double(item.id)
}
}
}
}
.gesture(getDragGesture())

How to show an overlay on swipe/drag gesture

I want to show an overlay that displays a different image depending on which side the card is dragged towards. I tried setting a default image but that way the image never updated, so I am trying with a string of the imageName instead. I implemented a tinder like swiping already:
class CardViewModel : ObservableObject {
#Published var offset = CGSize.zero
}
struct CardView: View {
#State var offset = CGSize.zero
#StateObject var cardVM = CardViewModel()
#State var imageName = ""
#State var isDragging = false
func swipeCard(width: CGFloat) {
switch width {
case -500...(-150):
offset = CGSize(width: -500, height: 0)
self.imageName = "nope"
case 150...500:
offset = CGSize(width: 500, height: 0)
self.imageName = "bid"
default:
offset = .zero
}
}
var body: some View {
VStack {
}.overlay(
isDragging ? Image(imageName) : Image("")
)
.offset(x: offset.width, y: offset.height * 0.4)
.rotationEffect(.degrees(Double(offset.width / 40)))
.animation(.spring())
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation
isDragging = true
} .onEnded { _ in
withAnimation {
swipeCard(width: offset.width)
isDragging = false
}
}
).offset(x: cardVM.offset.width, y: cardVM.offset.height * 0.4)
.rotationEffect(.degrees(Double(cardVM.offset.width / 40)))
.animation(.spring())
}
}

How to dynamically change GridItems in LazyVGrid with MagnificationGesture [Zoom In, Out] in SwiftUI?

The idea is to recreate the same photo layout behaviour like in Apple Photo Library when I can zoom in and out with 1, 3 or 5 photos in a row. I'm stack in a half way. For that I use a MagnificationGesture() and based on gesture value I update number of GridItems() in LazyVGrid().
Please let me know how to achieve it. Thanks a lot ๐Ÿ™
Here's code:
import SwiftUI
struct ContentView: View {
let colors: [Color] = [.red, .purple, .yellow, .green, .blue, .mint, .orange]
#State private var colums = Array(repeating: GridItem(), count: 1)
// #GestureState var magnifyBy: CGFloat = 1.0
#State var magnifyBy: CGFloat = 1.0
#State var lastMagnifyBy: CGFloat = 1.0
let minMagnifyBy = 1.0
let maxMagnifyBy = 5.0
var magnification: some Gesture {
MagnificationGesture()
// .updating($magnifyBy) { (currentState, pastState, trans) in
// pastState = currentState.magnitude
// }
.onChanged { state in
adjustMagnification(from: state)
print("Current State \(state)")
}
.onEnded { state in
adjustMagnification(from: state)
// withAnimation(.spring()) {
// validateMagnificationLimits()
// }
lastMagnifyBy = 1.0
}
}
var body: some View {
NavigationView {
ScrollView {
LazyVGrid(columns: colums) {
ForEach(1..<101) { number in
colors[number % colors.count]
.overlay(Text("\(number)").font(.title2.bold()).foregroundColor(.white))
.frame(height: 100)
}
}
.scaleEffect(magnifyBy)
.gesture(magnification)
.navigationTitle("๐Ÿงจ Grid")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
withAnimation(.spring(response: 0.8)) {
colums = Array(repeating: .init(), count: colums.count == 5 ? 1 : colums.count % 5 + 2)
}
} label: {
Image(systemName: "square.grid.3x3")
.font(.title2)
.foregroundColor(.primary)
}
}
}
}
}
}
private func adjustMagnification(from state: MagnificationGesture.Value) {
let stepCount = Int(min(max(1, state), 5))
// let delta = state / lastMagnifyBy
// magnifyBy *= delta
withAnimation(.linear) {
colums = Array(repeating: GridItem(), count: stepCount)
}
lastMagnifyBy = state
}
private func getMinMagnificationAllowed() -> CGFloat {
max(magnifyBy, minMagnifyBy)
}
private func getMaxMagnificationAllowed() -> CGFloat {
min(magnifyBy, maxMagnifyBy)
}
private func validateMagnificationLimits() {
magnifyBy = getMinMagnificationAllowed()
magnifyBy = getMaxMagnificationAllowed()
}
}
Here you go. This uses a TrackableScrollView (git link in the code).
I implemented an array of possible zoomStages (cols per row), to make switching between them easier.
Next to dos would be scrolling back to the magnification center, so the same item stays in focus. And maybe an opacity transition in stead of rearranging the Grid. Have fun ;)
import SwiftUI
// https://github.com/maxnatchanon/trackable-scroll-view.git
import SwiftUITrackableScrollView
struct ContentView: View {
let colors: [Color] = [.red, .purple, .yellow, .green, .blue, .mint, .orange]
let zoomStages = [1, 3, 5, 9, 15]
#State private var zoomStageIndex = 0
var colums: [GridItem] { Array(repeating: GridItem(spacing: 0), count: zoomStages[zoomStageIndex]) }
#State var magnifyBy: CGFloat = 1.0
#State private var scrollViewOffset = CGFloat.zero // SwiftUITrackableScrollView: Content offset available to use
var body: some View {
NavigationView {
TrackableScrollView(.vertical, showIndicators: false, contentOffset: $scrollViewOffset) {
LazyVGrid(columns: colums, spacing: 0) {
ForEach(0..<500) { number in
colors[number % colors.count]
.overlay(
Text("\(number)").font(.title2.bold()).foregroundColor(.white)
.minimumScaleFactor(0.1)
)
.aspectRatio(1, contentMode: .fit) // always squares
.id(number)
}
}
.scaleEffect(magnifyBy, anchor: .top)
// offset to correct magnify "center" point
.offset(x: 0, y: (scrollViewOffset + UIScreen.main.bounds.midY) * (1 - magnifyBy) )
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
withAnimation(.spring(response: 0.8)) {
if zoomStageIndex < zoomStages.count-1 {
zoomStageIndex += 1
} else {
zoomStageIndex = 0
}
}
} label: {
Image(systemName: "square.grid.3x3")
.font(.title2)
.foregroundColor(.primary)
}
}
}
.gesture(magnification)
}
.ignoresSafeArea()
}
}
var magnification: some Gesture {
MagnificationGesture()
.onChanged { state in
magnifyBy = state
}
.onEnded { state in
// find predefined zoom(index) that is closest to actual pinch zoom value
let newZoom = Double(zoomStages[zoomStageIndex]) * 1 / state
let newZoomIndex = findClosestZoomIndex(value: newZoom)
// print("***", zoomStages[zoomStageIndex], state, newZoom, newZoomIndex)
withAnimation(.spring(response: 0.8)) {
magnifyBy = 1 // reset scaleEffect
zoomStageIndex = newZoomIndex // set new zoom level
}
}
}
func findClosestZoomIndex(value: Double) -> Int {
let distanceArray = zoomStages.map { abs(Double($0) - value) } // absolute difference between zoom stages and actual pinch zoom
// print("dist:", distanceArray)
return distanceArray.indices.min(by: {distanceArray[$0] < distanceArray[$1]}) ?? 0 // return index of element that is "closest"
}
}

in SwiftUI, in a ForEach(0 ..< 3), animate the tapped button only (not all 3), animate the other 2 differently

After a full day trying to animate these buttons I give up and ask for help.
I would like to animate the correct button only like this:
.rotation3DEffect(.degrees(self.animationAmount), axis: (x: 0, y: 1, z: 0))
and at the same time make the other two buttons fade out to 25% opacity.
When the player clicks the wrong button I would like to
animate the wrong button like this:
.rotation3DEffect(.degrees(self.animationAmount), axis: (x: 1, y: 1, z: 1)) (or anyway else you can think to indicate disaster) and leave the other two alone.
After that happened I would like the alert to show.
Below is my code. I commented what I would like to do and where if at all possible.
It all works like I want but cannot get the animation going.
Thanks for your help in advance
import SwiftUI
struct ContentView: View {
#State private var countries = ["Estonia", "France", "Germany", "Ireland", "Italy", "Nigeria", "Poland", "Russia", "Spain", "UK", "US"]
#State private var correctAnswer = Int.random(in: 0...2)
#State private var showingScore = false
#State private var scoreTitle = ""
#State private var userScore = 0
#State private var userTapped = ""
#State private var animationAmount = 0.0
var body: some View {
ZStack {
LinearGradient(gradient: Gradient(colors: [.blue, .black]), startPoint: .top, endPoint: .bottom)
.edgesIgnoringSafeArea(.all)
VStack(spacing: 20) {
VStack {
Text("Tap the flag of...")
.foregroundColor(.white).font(.title)
Text(countries[correctAnswer])
.foregroundColor(.white)
.font(.largeTitle).fontWeight(.black)
}
ForEach(0 ..< 3) { number in
Button(action: {
self.flagTapped(number)
if self.correctAnswer == number {
//animate the correct button only like this:
//.rotation3DEffect(.degrees(self.animationAmount), axis: (x: 0, y: 1, z: 0))
// and
// make the other two buttons fade out to 25% opacity
} else {
// animate the wrong button like this:
//.rotation3DEffect(.degrees(self.animationAmount), axis: (x: 1, y: 1, z: 1))
}
}) {
Image(self.countries[number])
.renderingMode(.original)
.clipShape(Capsule())
.overlay(Capsule().stroke(Color .black, lineWidth: 1))
.shadow(color: .black, radius: 2)
}
}
Text ("your score is:\n \(userScore)").foregroundColor(.white).font(.title).multilineTextAlignment(.center)
}
}
.alert(isPresented: $showingScore) {
Alert(title: Text(scoreTitle), message: Text("You chose the flag of \(userTapped)\nYour score is now: \(userScore)"), dismissButton: .default(Text("Continue")) {
self.askQuestion()
})
}
}
func flagTapped(_ number: Int) {
userTapped = countries[number]
if number == correctAnswer {
scoreTitle = "Correct"
userScore += 1
} else {
scoreTitle = "Wrong"
userScore -= 1
}
showingScore = true
}
func askQuestion() {
countries.shuffle()
correctAnswer = Int.random(in: 0...2)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I had a problem similar to yours when solving this challenge. I figured out a solution without using DispatchQueue.main.asyncAfter. I made the following as the final solution:
Spin around 360 degrees the correct chosen flag and fade out the other flags to 25 % of opacity.
Blur the background of the wrong flag chosen with red and fade out the other flags to 25 % of opacity.
Here is the full solution (I comment on the parts that were important to achieve the above solution):
import SwiftUI
// Create a custom view
struct FlagImage: View {
var countryFlags: String
var body: some View {
Image(countryFlags)
.renderingMode(.original)
.clipShape(Capsule())
.overlay(Capsule().stroke(Color.black, lineWidth: 1))
.shadow(color: .black, radius: 2)
}
}
struct ContentView: View {
#State private var countries = ["Estonia", "France", "Germany", "Ireland", "Italy", "Nigeria", "Poland", "Russia", "Spain", "UK", "US"].shuffled()
#State private var correctAnswer = Int.random(in: 0...3)
#State private var showingScore = false
#State private var scoreTitle = ""
#State private var userScore = 0
// Properties for animating the chosen flag
#State private var animateCorrect = 0.0
#State private var animateOpacity = 1.0
#State private var besidesTheCorrect = false
#State private var besidesTheWrong = false
#State private var selectedFlag = 0
var body: some View {
ZStack {
LinearGradient(gradient: Gradient(colors: [.blue, .black]), startPoint: .top, endPoint: .bottom).edgesIgnoringSafeArea(.all)
VStack(spacing: 30) {
VStack {
Text("Tap on the flag!")
.foregroundColor(.white)
.font(.title)
Text(countries[correctAnswer])
.foregroundColor(.white)
.font(.largeTitle)
.fontWeight(.black)
}
ForEach(0 ..< 4) { number in
Button(action: {
self.selectedFlag = number
self.flagTapped(number)
}) {
FlagImage(countryFlags: self.countries[number])
}
// Animate the flag when the user tap the correct one:
// Rotate the correct flag
.rotation3DEffect(.degrees(number == self.correctAnswer ? self.animateCorrect : 0), axis: (x: 0, y: 1, z: 0))
// Reduce opacity of the other flags to 25%
.opacity(number != self.correctAnswer && self.besidesTheCorrect ? self.animateOpacity : 1)
// Animate the flag when the user tap the wrong one:
// Create a red background to the wrong flag
.background(self.besidesTheWrong && self.selectedFlag == number ? Capsule(style: .circular).fill(Color.red).blur(radius: 30) : Capsule(style: .circular).fill(Color.clear).blur(radius: 0))
// Reduce opacity of the other flags to 25% (including the correct one)
.opacity(self.besidesTheWrong && self.selectedFlag != number ? self.animateOpacity : 1)
}
Spacer()
Text("Your total score is: \(userScore)")
.foregroundColor(Color.white)
.font(.title)
.fontWeight(.black)
Spacer()
}
}
.alert(isPresented: $showingScore) {
Alert(title: Text(scoreTitle), dismissButton: .default(Text("Continue")) {
self.askQuestion()
})
}
}
func flagTapped(_ number: Int) {
if number == correctAnswer {
scoreTitle = "Correct!"
userScore += 1
// Create animation for the correct answer
withAnimation {
self.animateCorrect += 360
self.animateOpacity = 0.25
self.besidesTheCorrect = true
}
} else {
scoreTitle = "Wrong!"
// Create animation for the wrong answer
withAnimation {
self.animateOpacity = 0.25
self.besidesTheWrong = true
}
}
showingScore = true
}
func askQuestion() {
// Return the booleans to false
besidesTheCorrect = false
besidesTheWrong = false
countries = countries.shuffled()
correctAnswer = Int.random(in: 0...3)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Basically, my solution is to add ternary operators inside the view modifiers (e.g., .rotation3DEffect(...), .opacity(...) and .background(...)) after the Button view. The tricky part is to correctly combine the checking condition.
I prefer to add the withAnimation modifier to my flagTapped function. In this place I have more control of the animations if the user select a correct or a wrong flag.
I made a small change to the original challenge: just add one more flag to the view.
The final result when a user presses the correct and wrong flag is here:
.
I had the same problem. Here is my solution (only the code that's different from yours):
The ForEach:
ForEach(0 ..< 3, id: \.self){ number in
Button(action: {
withAnimation{
self.tappedFlag = number
self.flagTapped(number)
}
}){
FlagImage(imageName: self.countries[number])
}
.rotation3DEffect(.degrees(self.isCorrect && self.selcectedNumber == number ? 360 : 0), axis: (x: 0, y: 1, z: 0))
.opacity(self.isFadeOutOpacity && self.selcectedNumber != number ? 0.25 : 1)
.rotation3DEffect(.degrees(self.wrongAnswer && number != self.correctAnswer ? 180 : 0), axis: (x: 1, y: 0, z: 0))
.opacity(self.wrongAnswer && number != self.correctAnswer ? 0.25 : 1)
}
The alert:
.alert(isPresented: $showingScore){
if scoreTitle == "Correct"{
return Alert(title: Text(scoreTitle), message: Text("Your score is \(userScore)"), dismissButton: .default(Text("Continue")){
self.askQuestion()
})
}else{
return Alert(title: Text(scoreTitle), message: Text("That is the flag of \(countries[tappedFlag]), you lost one point!"), dismissButton: .default(Text("Continue")){
self.askQuestion()
})
}
}
The two functions:
func flagTapped(_ number: Int){
self.selcectedNumber = number
self.alreadyTapped = true
if number == correctAnswer{
scoreTitle = "Correct"
userScore += 1
self.isCorrect = true
self.isFadeOutOpacity = true
}else{
self.wrongAnswer = true
scoreTitle = "Wrong"
if userScore != 0{
userScore -= 1
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.showingScore = true
}
}
func askQuestion(){
countries = countries.shuffled()
correctAnswer = Int.random(in: 0...2)
self.isCorrect = false
self.isFadeOutOpacity = false
self.wrongAnswer = false
}
You have to declare some new variables :)
I hope I could help.
PS: There is a playlist on Youtube for 100DaysOfSwiftUI with the solutions to almost every own task.
https://www.youtube.com/watch?v=9AUGceRIUSA&list=PL3pUvT0fmHNhb3qcpvuym6KeM12eQK3T1

How to make a swipeable view with SwiftUI

I tried to make a SWIFTUI View that allows card Swipe like action by using gesture() method. But I can't figure out a way to make view swipe one by one. Currently when i swipe all the views are gone
import SwiftUI
struct EventView: View {
#State private var offset: CGSize = .zero
#ObservedObject var randomView: EventViewModel
var body: some View {
ZStack{
ForEach(randomView.randomViews,id:\.id){ view in
view
.background(Color.randomColor)
.cornerRadius(8)
.shadow(radius: 10)
.padding()
.offset(x: self.offset.width, y: self.offset.height)
.gesture(
DragGesture()
.onChanged { self.offset = $0.translation }
.onEnded {
if $0.translation.width < -100 {
self.offset = .init(width: -1000, height: 0)
} else if $0.translation.width > 100 {
self.offset = .init(width: 1000, height: 0)
} else {
self.offset = .zero
}
}
)
.animation(.spring())
}
}
}
}
struct EventView_Previews: PreviewProvider {
static var previews: some View {
EventView(randomView: EventViewModel())
}
}
struct PersonView: View {
var id:Int = Int.random(in: 1...1000)
var body: some View {
VStack(alignment: .center) {
Image("testBtn")
.clipShape(/*#START_MENU_TOKEN#*/Circle()/*#END_MENU_TOKEN#*/)
Text("Majid Jabrayilov")
.font(.title)
.accentColor(.white)
Text("iOS Developer")
.font(.body)
.accentColor(.white)
}.padding()
}
}
With this piece of code, when i swipe the whole thing is gone
Basically your code tells every view to follow offset, while actually you want only the top one move. So firstly I'd add a variable that'd hold current index of the card and a method to calculate it's offset:
#State private var currentCard = 0
func offset(for i: Int) -> CGSize {
return i == currentCard ? offset : .zero
}
Secondly, I found out that if we just leave it like that, on the next touch view would get offset of the last one (-1000, 0) and only then jump to the correct location, so it looks just like previous card decided to return instead of the new one. In order to fix this I added a flag marking that card has just gone, so when we touch it again it gets right location initially. Normally, we'd do that in gesture's .began state, but we don't have an analog for that in swiftUI, so the only place to do it is in .onChanged:
#State private var didJustSwipe = false
DragGesture()
.onChanged {
if self.didJustSwipe {
self.didJustSwipe = false
self.currentCard += 1
self.offset = .zero
} else {
self.offset = $0.translation
}
}
In .onEnded in the case of success we assign didJustSwipe = true
So now it works perfectly. Also I suggest you diving your code into smaller parts. It will not only improve readability, but also save some compile time. You didn't provide an implementation of EventViewModel and those randomViews so I used rectangles instead. Here's your code:
struct EventView: View {
#State private var offset: CGSize = .zero
#State private var currentCard = 0
#State private var didJustSwipe = false
var randomView: some View {
return Rectangle()
.foregroundColor(.green)
.cornerRadius(20)
.frame(width: 300, height: 400)
.shadow(radius: 10)
.padding()
.opacity(0.3)
}
func offset(for i: Int) -> CGSize {
return i == currentCard ? offset : .zero
}
var body: some View {
ZStack{
ForEach(currentCard..<5, id: \.self) { i in
self.randomView
.offset(self.offset(for: i))
.gesture(self.gesture)
.animation(.spring())
}
}
}
var gesture: some Gesture {
DragGesture()
.onChanged {
if self.didJustSwipe {
self.didJustSwipe = false
self.currentCard += 1
self.offset = .zero
} else {
self.offset = $0.translation
}
}
.onEnded {
let w = $0.translation.width
if abs(w) > 100 {
self.didJustSwipe = true
let x = w > 0 ? 1000 : -1000
self.offset = .init(width: x, height: 0)
} else {
self.offset = .zero
}
}
}
}