I am trying to create a simply game in SwiftUI and im trying to get a swipe back gesture similar to that on navigationView but without putting my view inside a navigationView. Since this is a game, adding naviagationView will look out of place.
This is what I have so far:
struct SwipingView : View {
#State private var dragAmount = CGSize.zero
#GestureState private var position = CGSize.zero
func addToPosition(translation:CGSize) -> CGSize {
return CGSize(width: dragAmount.width + translation.width, height: dragAmount.height + translation.height)
}
var body: some View{
return ZStack(){
Rectangle().fill(Color.red).frame(width: 100, height:400).scaleEffect(x:5,y:1,anchor: .leading)
.offset(x: 190)
.offset(x: addToPosition(translation: position).width )
.gesture(
DragGesture(minimumDistance: 20)
.updating(self.$position){ value, state, translation in
state = value.translation
}
.onEnded{ value in
if value.translation.width > 50 {
guard position.width + self.addToPosition(translation: CGSize(width:330, height:0)).width < 330-1 else {
return print("too far right")
}
self.dragAmount = self.addToPosition(translation: CGSize(width:330, height:0))
} else {
guard position.width + self.addToPosition(translation: CGSize(width:-330, height:0)).width > -330-1 else {
return print("too far left")
}
self.dragAmount = self.addToPosition(translation: CGSize(width:-330, height:0))
}
}
)
}.animation(Animation.linear)
}
}
I'm still new to swift so there is likely something obvious im missing even though I've looked through stack overflow and couldn't find exactly what im looking for.
This is what it looks like. As you can see, I can swipe the view away from anywhere on the view, but I'd like to only swipe away on the left edge or even just the first 5% on the left.
Here is possible solution - the idea is to attach gesture to overlay that is as wide at the left as needed. Tested with Xcode 12 / iOS 14
Note: on demo the active area made Color.blue instead of Color.clear for better visibility
struct SwipingView : View {
#State private var dragAmount = CGSize.zero
#GestureState private var position = CGSize.zero
func addToPosition(translation:CGSize) -> CGSize {
return CGSize(width: dragAmount.width + translation.width, height: dragAmount.height + translation.height)
}
var body: some View{
return ZStack(){
Rectangle().fill(Color.red).frame(width: 100, height:400).scaleEffect(x:5,y:1,anchor: .leading)
.overlay(Color.clear.frame(width: 40) // << make width as needed
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 20)
.updating(self.$position){ value, state, translation in
state = value.translation
}
.onEnded{ value in
if value.translation.width > 50 {
guard position.width + self.addToPosition(translation: CGSize(width:330, height:0)).width < 330-1 else {
return print("too far right")
}
self.dragAmount = self.addToPosition(translation: CGSize(width:330, height:0))
} else {
guard position.width + self.addToPosition(translation: CGSize(width:-330, height:0)).width > -330-1 else {
return print("too far left")
}
self.dragAmount = self.addToPosition(translation: CGSize(width:-330, height:0))
}
}
), alignment: .leading)
.offset(x: 190)
.offset(x: addToPosition(translation: position).width )
}.animation(Animation.linear)
}
}
Related
I am new to SwiftUI and I want to recreate the Contact-Card View from the Contacts App.
I am struggling to resize the Image on the top smoothly when scrolling in the List below.
I have tried using GeometryReader, but ran into issues there.
When scrolling up for example, the picture size just jumps abruptly to the minimumPictureSize I have specified. The opposite happens when scrolling up: It stops resizing abruptly when I stop scrolling.
Wanted behaviour: https://gifyu.com/image/Ai04
Current behaviour: https://gifyu.com/image/AjIc
struct SwiftUIView: View {
#State var startOffset: CGFloat = 0
#State var offset: CGFloat = 0
var minPictureSize: CGFloat = 100
var maxPictureSize: CGFloat = 200
var body: some View {
VStack {
Image("person")
.resizable()
.frame(width: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)),
height: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)))
.mask(Circle())
Text("startOffset: \(startOffset)")
Text("offset: \(offset)")
List {
Section {
Text("Top Section")
}.overlay(
GeometryReader(){ geometry -> Color in
let rect = geometry.frame(in: .global)
if startOffset == 0 {
DispatchQueue.main.async {
startOffset = rect.minY
}
}
DispatchQueue.main.async {
offset = rect.minY - startOffset
}
return Color.clear
}
)
ForEach((0..<10)) { row in
Section {
Text("\(row)")
}
}
}.listStyle(InsetGroupedListStyle())
}.navigationBarHidden(true)
}
}
Not a perfect solution, but you could separate the header and List into 2 layers in a ZStack:
struct SwiftUIView: View {
#State var startOffset: CGFloat!
#State var offset: CGFloat = 0
let minPictureSize: CGFloat = 100
let maxPictureSize: CGFloat = 200
var body: some View {
ZStack(alignment: .top) {
if startOffset != nil {
List {
Section {
Text("Top Section")
} header: {
// Leave extra space for `List` so it won't clip its content
Color.clear.frame(height: 100)
}
.overlay {
GeometryReader { geometry -> Color in
DispatchQueue.main.async {
let frame = geometry.frame(in: .global)
offset = frame.minY - startOffset
}
return Color.clear
}
}
ForEach((0..<10)) { row in
Section {
Text("\(row)")
}
}
}
.listStyle(InsetGroupedListStyle())
.padding(.top, startOffset-100) // Make up extra space
}
VStack {
Circle().fill(.secondary)
.frame(width: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)),
height: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)))
Text("startOffset: \(startOffset ?? -1)")
Text("offset: \(offset)")
}
.frame(maxWidth: .infinity)
.padding(.bottom, 20)
.background(Color(uiColor: UIColor.systemBackground))
.overlay {
if startOffset == nil {
GeometryReader { geometry -> Color in
DispatchQueue.main.async {
let frame = geometry.frame(in: .global)
startOffset = frame.maxY + // Original small one
maxPictureSize - minPictureSize -
frame.minY // Top safe area height
}
return Color.clear
}
}
}
}
.navigationBarHidden(true)
}
}
Notice that Color.clear.frame(height: 100) and .padding(.top, startOffset-100) are intended to leave extra space for List to avoid being clipped, which will cause the scroll bar get clipped. Alternatively, UIScrollView.appearance().clipsToBounds = true will work. However, it'll make element which moves outside the bounds of List disappear. Don't know if it's a bug.
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
}
}
}
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
}
})
}
}
}
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
}
}
}
}
I would like to drag a slider and of course have it slide as well.
I can do one or the other, but I cannot do both.
How can I drag and have a working slider?
I also tried to find a way to remove a gesture, but I could not find a way to do that.
Also tried the "Sequenced Gesture States" code from Apple "Composing SwiftUI Gestures" docs,
and introduce a flag to turn the dragging on/off with same results, drag or slide not both.
I also tried to put the slider in a Container (VStack) and attach the drag gesture to that,
but that did not work either.
import SwiftUI
struct ContentView: View {
#State var pos = CGSize.zero
#State var acc = CGSize.zero
#State var value = 0.0
var body: some View {
let drag = DragGesture()
.onChanged { value in
self.pos = CGSize(width: value.translation.width + self.acc.width, height: value.translation.height + self.acc.height)
}
.onEnded { value in
self.pos = CGSize(width: value.translation.width + self.acc.width, height: value.translation.height + self.acc.height)
self.acc = self.pos
}
return Slider(value: $value, in: 0...100, step: 1)
.frame(width: 250, height: 40, alignment: .center)
.overlay(RoundedRectangle(cornerRadius: 25).stroke(lineWidth: 2).foregroundColor(Color.black))
.offset(x: self.pos.width, y: self.pos.height)
.simultaneousGesture(drag, including: .all) // tried .none .gesture, .subviews
// also tried .gesture(flag ? nil : drag)
}
}
With "simultaneousGesture" I was expecting to have both gestures operating at the same time.
This is working. Basically I needed to set the flag in an outside observable object for it to update the view so that it could take effect. When the value changes the flag is set to false, but then after a tenth of a second it becomes draggable. Working pretty seamlessly.
struct ContentView: View {
#State var pos = CGSize.zero
#State var acc = CGSize.zero
#State var value = 0.0
#ObservedObject var model = Model()
var body: some View {
let drag = DragGesture()
.onChanged { value in
self.pos = CGSize(width: value.translation.width + self.acc.width, height: value.translation.height + self.acc.height)
}
.onEnded { value in
self.pos = CGSize(width: value.translation.width + self.acc.width, height: value.translation.height + self.acc.height)
self.acc = self.pos
}
return VStack {
Slider(value: $value, in: 0...100, step: 1) { _ in
self.model.flag = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.model.flag = true
}
}
}
.frame(width: 250, height: 40, alignment: .center)
.overlay(RoundedRectangle(cornerRadius: 25).stroke(lineWidth: 2).foregroundColor(Color.black))
.offset(x: self.pos.width, y: self.pos.height)
.gesture(model.flag == true ? drag : nil)
}
}
class Model: ObservableObject {
#Published var flag = false
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}