Problem with position() / offset() swiftUI - swift

I`m trying to move this circle to the edges of the screen then determine if it is at the border of the screen and show alert.
This is what I already tried: Tried offset(), now am playing with position() without luck. Tried using geometryReader didnt help
I can hard code to a value of minus something which is working but want to know how to detect the edges of the screen and also to understand why this logic isnt working.
To know edges of the screen I tried also using UIScreen.main.boundries
I`m doing SwiftUI exersises to get "comfortable" with SwiftUI which is a real pain.
import SwiftUI
struct ContentView: View {
let buttons1 = ["RESET","UP","COLOR"]
let buttons2 = ["<","DOWN",">"]
#State private var colorSelector = 0
let circleColors:[Color] = [.red,.green,.blue,.black]
#State private var ballOffset = CGPoint.init(x: 80, y: 80)
#State private var circleOpacity = 0.5
#State private var alertActive = false // wall problem
var body: some View {
ZStack{
Color.init("Grayish")
VStack{
Circle()
.fill(circleColors[colorSelector]).opacity(circleOpacity)
.position(ballOffset)
.frame(width: 160, height: 160)
}
VStack(spacing:10){
Spacer()
Slider(value: $circleOpacity)
HStack{
ForEach(0..<buttons1.count,id:\.self){ text in
Button(action: {
switch text{
case 0:
colorSelector = 0
ballOffset = .zero
case 1:
ballOffset.y -= 3
case 2:
if colorSelector == circleColors.count - 1{
colorSelector = 0
}
else {
colorSelector += 1
}
default: break
}
}, label: {
Text(buttons1[text])
.foregroundColor(text == 1 ? Color.white:Color.accentColor)
})
.buttonModifier()
.background(RoundedRectangle(cornerRadius: 10).fill(Color.accentColor).opacity(text == 1 ? 1 : 0))
}
}.padding(.horizontal,5)
HStack{
ForEach(0..<buttons2.count,id:\.self){text in
Button(action:{
switch text{
case 0:
ballOffset.x -= 3
case 1:
ballOffset.y += 3
case 2:
ballOffset.x += 3
default:break
}
},
label:{
Text(buttons2[text])
.foregroundColor(Color.white)
})
.buttonModifier()
.background(RoundedRectangle(cornerRadius: 10).fill(Color.blue))
}
}.padding([.bottom,.horizontal],5)
}
.alert(isPresented: $alertActive, content: {
Alert(title: Text("out of bounds"), message: Text("out"), dismissButton: .default(Text("OK")))
}) // alert not working have to figure out wall problem first
}.edgesIgnoringSafeArea(.all)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct ButtonModifier: ViewModifier{
func body(content: Content) -> some View {
content
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, idealHeight: 44, maxHeight: 44)
.padding(.horizontal)
.foregroundColor(Color.accentColor)
.background(RoundedRectangle(cornerRadius: 10)
.stroke(Color.accentColor))
}
}
extension View{
func buttonModifier() -> some View{
modifier(ButtonModifier())
}
}

Here is a solution using GeometryReader, which is dynamic and will change with the device orientation. It checks the future ball position is within the boundaries before moving the ball and shows the alert on failure.
struct ContentView: View {
let buttons1 = ["RESET","UP","COLOR"]
let buttons2 = ["<","DOWN",">"]
#State private var colorSelector = 0
let circleColors:[Color] = [.red,.green,.blue,.black]
let ballSize = CGSize(width: 160, height: 160)
#State private var ballOffset: CGSize = .zero
#State private var circleOpacity = 0.5
#State private var alertActive = false
var body: some View {
ZStack {
Color.green.opacity(0.4)
.edgesIgnoringSafeArea(.all)
Circle()
.fill(circleColors[colorSelector])
.opacity(circleOpacity)
.frame(width: ballSize.width, height: ballSize.height)
.offset(ballOffset)
GeometryReader { geometry in
VStack(spacing:10) {
Spacer()
Slider(value: $circleOpacity)
HStack {
ForEach(0..<buttons1.count,id:\.self){ text in
Button(action: {
switch text{
case 0:
colorSelector = 0
ballOffset = .zero
case 1:
handleMove(incrementY: -3, geometryProxy: geometry)
case 2:
colorSelector = colorSelector == (circleColors.count - 1) ? 0 : colorSelector + 1
default:
break
}
}, label: {
Text(buttons1[text])
.foregroundColor(text == 1 ? Color.white:Color.accentColor)
})
.buttonModifier()
.background(RoundedRectangle(cornerRadius: 10).fill(Color.accentColor).opacity(text == 1 ? 1 : 0))
}
}.padding(.horizontal,5)
HStack{
ForEach(0..<buttons2.count,id:\.self){text in
Button(action:{
switch text{
case 0:
handleMove(incrementX: -3, geometryProxy: geometry)
case 1:
handleMove(incrementY: 3, geometryProxy: geometry)
case 2:
handleMove(incrementX: 3, geometryProxy: geometry)
default:break
}
},
label:{
Text(buttons2[text])
.foregroundColor(Color.white)
})
.buttonModifier()
.background(RoundedRectangle(cornerRadius: 10).fill(Color.blue))
}
}.padding([.bottom,.horizontal],5)
}
.background(Color.orange.opacity(0.5))
}
.alert(isPresented: $alertActive, content: {
Alert(title: Text("out of bounds"), message: Text("out"), dismissButton: .default(Text("OK")))
})
}
}
func handleMove(incrementX: CGFloat = 0, incrementY: CGFloat = 0, geometryProxy: GeometryProxy) {
let newOffset = CGSize(width: ballOffset.width + incrementX, height: ballOffset.height + incrementY)
let standardXOffset =
(geometryProxy.size.width) / 2 // width (excluding safe area)
- (ballSize.width / 2) // subtract 1/2 of ball width (anchor is in the center)
let maxXOffset = standardXOffset + geometryProxy.safeAreaInsets.trailing // + trailing safe area
let minXOffset = -standardXOffset - geometryProxy.safeAreaInsets.leading // + leading safe area
let standardYOffset =
(geometryProxy.size.height / 2) // height (excluding safe area)
- (ballSize.height / 2) // subtract 1/2 of ball height (anchor is in the center)
let maxYOffset = standardYOffset + geometryProxy.safeAreaInsets.bottom // + bottom safe area
let minYOffset = -standardYOffset - geometryProxy.safeAreaInsets.top // + top safe area
if
(newOffset.width >= maxXOffset) ||
(newOffset.width <= minXOffset) ||
(newOffset.height >= maxYOffset) ||
(newOffset.height <= minYOffset) {
alertActive.toggle()
} else {
ballOffset = newOffset
}
}
}

Related

#State Property not accumulating

Users can swipe left or right on a stack of cards to simulate "true" or "false" to a series of quiz questions. I have a #State var called userScore initialized to 0. When dragGesture .onEnded, I compare the "correctAnswer" with the "userAnswer." If they match, add 1 point to the userScore.
The problem: The console prints 1 or 0. The score does not ACCUMULATE.
Please help me calculate the user's final score? Thanks in advance. . .
import SwiftUI
struct CardView: View {
#State var offset: CGSize = .zero
#State var userScore: Int = 0
#State var userAnswer: Bool = false
private var currentQuestion: Question
private var onRemove: (_ user: Question) -> Void
init(user: Question, onRemove: #escaping (_ user: Question) -> Void) {
self.currentQuestion = user
self.onRemove = onRemove
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25, style: .continuous)
.fill(
Color.black
.opacity(1 - Double(abs(offset.width / 50)))
)
.background(
RoundedRectangle(cornerRadius: 25, style: .continuous)
.fill(offset.width > 0 ? Color.green : Color.red)
//.overlay(offset.width > 0 ? likeGraphics() : dislikeGraphics(), alignment: .topLeading)
)
.shadow(color: .red, radius: 5, x: 0.0, y: 0.0)
VStack {
Image(currentQuestion.imageIcon)
.resizable()
.scaledToFit()
.foregroundColor(Color.white)
.frame(width: 75, height: 75)
ScrollView {
Text(currentQuestion.questionText)
.font(.largeTitle)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
}
}
.padding()
}
.frame(width: 300, height: 375) //check: www.ios-resolution.com
.offset(offset)
.rotationEffect(.degrees(offset.width / 400.0 ) * 15, anchor: .bottom)
//.opacity(2 - Double(abs(offset.width / 50.0))) //fade on drag too?
.gesture(
DragGesture()
.onChanged { gesture in
withAnimation(.spring()) {
offset = gesture.translation
}
}
.onEnded { value in
withAnimation(.interactiveSpring()) {
if abs(offset.width) > 100 {
onRemove(currentQuestion)
if (currentQuestion.correctAnswer == determineSwipeStatus()) {
userScore += 1
}
print(userScore)
} else {
offset = .zero
}
}
}
)
}
func determineSwipeStatus() -> Bool {
if(offset.width > 0) {
userAnswer = true
} else if (offset.width < 0) {
userAnswer = false
}
return userAnswer
}
func getAlert() -> Alert {
return Alert(
title: Text("Your group is \(userScore)% cult-like!"),
message: Text("Would you like to test another group?"),
primaryButton: .default(Text("Yes, test another group"),
action: {
//transition back to screen one
//self.showQuizPageScreen.toggle()
}),
secondaryButton: .cancel())
}
}
struct CardView_Previews: PreviewProvider {
static var previews: some View {
CardView(user: Question(id: 1,
imageIcon: "icon1",
questionText: "Dummy placeholder text",
correctAnswer: true),
onRemove: { _ in
//leave blank for preview purposes
})
}
}

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

SwiftUI list animations

I am following Apple's Swift UI Animating Views And Transitions and I noticed a bug in the Hike Graph View. When I click on the graph it does not allow me to switch from Elevation to Heart Rate or Pace. It does not let me and just exits the view. I think this has something to do with the List here:
VStack(alignment: .leading) {
Text("Recent Hikes")
.font(.headline)
HikeView(hike: hikeData[0])
}
Hike View Contains:
import SwiftUI
struct HikeView: View {
var hike: Hike
#State private var showDetail = false
var transition: AnyTransition {
let insertion = AnyTransition.move(edge: .trailing)
.combined(with: .opacity)
let removal = AnyTransition.scale
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
.animation(nil)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}
if showDetail {
HikeDetail(hike: hike)
.transition(transition)
}
}
}
}
Hike Detail Contains:
struct HikeDetail: View {
let hike: Hike
#State var dataToShow = \Hike.Observation.elevation
var buttons = [
("Elevation", \Hike.Observation.elevation),
("Heart Rate", \Hike.Observation.heartRate),
("Pace", \Hike.Observation.pace),
]
var body: some View {
return VStack {
HikeGraph(hike: hike, path: dataToShow)
.frame(height: 200)
HStack(spacing: 25) {
ForEach(buttons, id: \.0) { value in
Button(action: {
self.dataToShow = value.1
}) {
Text(value.0)
.font(.system(size: 15))
.foregroundColor(value.1 == self.dataToShow
? Color.gray
: Color.accentColor)
.animation(nil)
}
}
}
}
}
}
Hike Graoh Contains:
import SwiftUI
func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
where C.Element == Range<Double> {
guard !ranges.isEmpty else { return 0..<0 }
let low = ranges.lazy.map { $0.lowerBound }.min()!
let high = ranges.lazy.map { $0.upperBound }.max()!
return low..<high
}
func magnitude(of range: Range<Double>) -> Double {
return range.upperBound - range.lowerBound
}
extension Animation {
static func ripple(index: Int) -> Animation {
Animation.spring(dampingFraction: 0.5)
.speed(2)
.delay(0.03 * Double(index))
}
}
struct HikeGraph: View {
var hike: Hike
var path: KeyPath<Hike.Observation, Range<Double>>
var color: Color {
switch path {
case \.elevation:
return .gray
case \.heartRate:
return Color(hue: 0, saturation: 0.5, brightness: 0.7)
case \.pace:
return Color(hue: 0.7, saturation: 0.4, brightness: 0.7)
default:
return .black
}
}
var body: some View {
let data = hike.observations
let overallRange = rangeOfRanges(data.lazy.map { $0[keyPath: self.path] })
let maxMagnitude = data.map { magnitude(of: $0[keyPath: path]) }.max()!
let heightRatio = (1 - CGFloat(maxMagnitude / magnitude(of: overallRange))) / 2
return GeometryReader { proxy in
HStack(alignment: .bottom, spacing: proxy.size.width / 120) {
ForEach(data.indices) { index in
GraphCapsule(
index: index,
height: proxy.size.height,
range: data[index][keyPath: self.path],
overallRange: overallRange)
.colorMultiply(self.color)
.transition(.slide)
.animation(.ripple(index: index))
}
.offset(x: 0, y: proxy.size.height * heightRatio)
}
}
}
}
Graph Capsule Contains:
import SwiftUI
struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>
var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}
var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}
var body: some View {
Capsule()
.fill(Color.white)
.frame(height: height * heightRatio)
.offset(x: 0, y: height * -offsetRatio)
}
}
Is there any way to fix this? Thanks
The problem might be deeper in SwiftUI - if you comment out transition(.slide) in HikeGraph (and restart the XCODE), it will start working

SwiftUI create image slider with dots as indicators

I want to create a scroll view/slider for images. See my example code:
ScrollView(.horizontal, showsIndicators: true) {
HStack {
Image(shelter.background)
.resizable()
.frame(width: UIScreen.main.bounds.width, height: 300)
Image("pacific")
.resizable()
.frame(width: UIScreen.main.bounds.width, height: 300)
}
}
Though this enables the user to slide, I want it a little different (similar to a PageViewController in UIKit). I want it to behave like the typical image slider we know from a lot of apps with dots as indicators:
It shall always show a full image, no in between - hence if the user drags and stops in the middle, it shall automatically jump to the full image.
I want dots as indicators.
Since I've seen a lot of apps use such a slider, there must be known method, right?
There is no built-in method for this in SwiftUI this year. I'm sure a system-standard implementation will come along in the future.
In the short term, you have two options. As Asperi noted, Apple's own tutorials have a section on wrapping the PageViewController from UIKit for use in SwiftUI (see Interfacing with UIKit).
The second option is to roll your own. It's entirely possible to make something similar in SwiftUI. Here's a proof of concept, where the index can be changed by swipe or by binding:
struct PagingView<Content>: View where Content: View {
#Binding var index: Int
let maxIndex: Int
let content: () -> Content
#State private var offset = CGFloat.zero
#State private var dragging = false
init(index: Binding<Int>, maxIndex: Int, #ViewBuilder content: #escaping () -> Content) {
self._index = index
self.maxIndex = maxIndex
self.content = content
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
self.content()
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
}
}
.content.offset(x: self.offset(in: geometry), y: 0)
.frame(width: geometry.size.width, alignment: .leading)
.gesture(
DragGesture().onChanged { value in
self.dragging = true
self.offset = -CGFloat(self.index) * geometry.size.width + value.translation.width
}
.onEnded { value in
let predictedEndOffset = -CGFloat(self.index) * geometry.size.width + value.predictedEndTranslation.width
let predictedIndex = Int(round(predictedEndOffset / -geometry.size.width))
self.index = self.clampedIndex(from: predictedIndex)
withAnimation(.easeOut) {
self.dragging = false
}
}
)
}
.clipped()
PageControl(index: $index, maxIndex: maxIndex)
}
}
func offset(in geometry: GeometryProxy) -> CGFloat {
if self.dragging {
return max(min(self.offset, 0), -CGFloat(self.maxIndex) * geometry.size.width)
} else {
return -CGFloat(self.index) * geometry.size.width
}
}
func clampedIndex(from predictedIndex: Int) -> Int {
let newIndex = min(max(predictedIndex, self.index - 1), self.index + 1)
guard newIndex >= 0 else { return 0 }
guard newIndex <= maxIndex else { return maxIndex }
return newIndex
}
}
struct PageControl: View {
#Binding var index: Int
let maxIndex: Int
var body: some View {
HStack(spacing: 8) {
ForEach(0...maxIndex, id: \.self) { index in
Circle()
.fill(index == self.index ? Color.white : Color.gray)
.frame(width: 8, height: 8)
}
}
.padding(15)
}
}
and a demo
struct ContentView: View {
#State var index = 0
var images = ["10-12", "10-13", "10-14", "10-15"]
var body: some View {
VStack(spacing: 20) {
PagingView(index: $index.animation(), maxIndex: images.count - 1) {
ForEach(self.images, id: \.self) { imageName in
Image(imageName)
.resizable()
.scaledToFill()
}
}
.aspectRatio(4/3, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 15))
PagingView(index: $index.animation(), maxIndex: images.count - 1) {
ForEach(self.images, id: \.self) { imageName in
Image(imageName)
.resizable()
.scaledToFill()
}
}
.aspectRatio(3/4, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 15))
Stepper("Index: \(index)", value: $index.animation(.easeInOut), in: 0...images.count-1)
.font(Font.body.monospacedDigit())
}
.padding()
}
}
Two notes:
The GIF animation does a really poor job of showing how smooth the animation is, as I had to drop the framerate and compress heavily due to file size limits. It looks great on simulator or a real device
The drag gesture in the simulator feels clunky, but it works really well on a physical device.
You can easily achieve this by below code
struct ContentView: View {
public let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
#State private var selection = 0
/// images with these names are placed in my assets
let images = ["1","2","3","4","5"]
var body: some View {
ZStack{
Color.black
TabView(selection : $selection){
ForEach(0..<5){ i in
Image("\(images[i])")
.resizable()
.aspectRatio(contentMode: .fit)
}
}.tabViewStyle(PageTabViewStyle())
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
.onReceive(timer, perform: { _ in
withAnimation{
print("selection is",selection)
selection = selection < 5 ? selection + 1 : 0
}
})
}
}
}

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
}
}
}
}