SwiftUI handling button/view with onTapGesture and onLongPressGesture with a release action - ios

I have a view that has both onTapGesture and onLongPressGesture simultaneously. The issue is that the implementation of my onLongPressGesture prevents onTapGesture from ever being called.
Here is some code
View()
.onTapGesture {
action_1
}
.onLongPressGesture(minimumDuration: 0.5, maximumDistance: 10, pressing: {
pressing in
self.isPressing = pressing
if (pressing) {action_2}
if !pressing {action_3}
}, perform: {})
The pressing argument in .onLongPressGesture detects if a user is pressing the view/button, and will always perform the .onLongPressGesture, regardless of what the minimumDuration is.

Edit
Snapchat shutter where you can tap to take a picture, hold the button to start recording a video, then release the button to stop recording the video. That is why there are three actions that need to be performed.
This is tricky. Here's what I did:
onTapGesture, for the tap gesture
LongPressGesture, for a 0.5 second delay. Once the 0.5 seconds is over (.onEnded), start the recording.
DragGesture, for observing when the finger lift off the screen. When this happens, stop the recording.
struct ContentView: View {
/// for visual indicators
#State var recording = false
#State var tapped = false
var body: some View {
let longPressDrag = LongPressGesture(minimumDuration: 0.5) /// 2.
.onEnded { _ in /// 0.5 seconds is over, start recording
print("Long press start")
recording = true
}
.sequenced(before: DragGesture(minimumDistance: 0)) /// 3.
.onEnded { _ in /// finger lifted, stop recording
print("Long press release")
recording = false
}
Circle()
.fill(recording ? Color.red : Color.blue)
.opacity(tapped ? 0.5 : 1)
.frame(width: 100, height: 100)
.onTapGesture { /// 1.
print("Tapped")
tapped = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { tapped = false }
}
.gesture(longPressDrag)
}
}
Result:
Old answer: The pressing parameter isn't for performing actions like action_2. You can, but it's more commonly used for changing #State, so for example highlighting the view in green when it's pressed. You can find more information in the community documentation.
Instead, what you probably want is to call your action (action_2/print("long")) in the perform closure.
struct ContentView: View {
var body: some View {
Text("Hi")
.font(.title)
.onTapGesture {
print("tap")
}
.onLongPressGesture(minimumDuration: 0.5, maximumDistance: 10) {
print("long")
}
}
}
Result:

XCode 14. Swift 5 I have added both gesture to Image View. On tap gesture image will be scale and unscale. On LongPressGesture image bg color will change. Provide count: 2 to make it doubleTapGesture.
struct ContentView: View {
// MARK: - Property
#State private var isAnimating: Bool = false
#State private var imageScale: CGFloat = 1
#State private var shadowColor: Color = .black
// MARK: - Content
var body: some View {
NavigationView {
ZStack {
Image("magazine-front-cover")
.resizable()
.aspectRatio(contentMode: .fit)
.cornerRadius(8)
.padding()
.shadow(color: shadowColor, radius: 22, x: 2, y: 2)
.opacity(isAnimating ? 1 : 0)
.animation(.linear(duration: 1), value: isAnimating)
.scaleEffect(imageScale)
// MARK: - 1. Tap Gesture
.onTapGesture(count: 1, perform: {
if imageScale == 1 {
withAnimation(.spring()) {
imageScale = 5
}
} else {
withAnimation(.spring()) {
imageScale = 1
}
}
})
// MARK: - 2. LongPress Gesture
.onLongPressGesture(perform: {
if shadowColor == .black {
shadowColor = .green
} else {
shadowColor = .black
}
})
}
.ignoresSafeArea(edges: .all)
.navigationTitle("Pinch & Zoom")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
isAnimating = true
}
} // END of Navigation View
}
}

Related

In SwiftUI, the ternary operator is ignored for animation duration

This is the code of my app:
struct ContentView: View {
#State private var animationAmount: CGFloat = 1
#State private var animate: Bool = true
var body: some View {
Button("Tap Mee") {
animate = !animate
// self.animationAmount += 1
print("yo, animate is", animate)
}
.padding(40)
.background(Color.red)
.foregroundColor(.white)
.clipShape(Circle())
.overlay(
Circle()
.stroke(Color.red)
.scaleEffect(animationAmount)
.opacity(Double(2 - animationAmount))
.animation(
Animation.easeOut(duration: animate ? 5 : 1)
.repeatForever(autoreverses: false)
)
)
.onAppear {
self.animationAmount = 2
}
}
}
I want to set animation to 1 or 5 depending on the click. On the click a property is changed (true or false) and I want to use that to control the duration, but it is ignored. How can I fix this, so on each click, the duration of the animation changes?

Scaled view unexpectedly moving up and down when pushed from navigation view

I have a view that should scale in and out, starting immediately when the view is shown and repeating forever. However, I find that it's actually animating up and down as well as scaling much like in this post when it's pushed from a navigation view:
struct PlaceholderView: View {
#State private var isAnimating = false
var body: some View {
Circle()
.frame(width: 30, height: 30)
.scaleEffect(self.isAnimating ? 0.8 : 1)
.animation(Animation.easeInOut(duration: 1).repeatForever())
.onAppear {
self.isAnimating = true
}
.frame(width: 50, height: 50)
.contentShape(
Rectangle()
)
}
}
struct SettingsView: View {
#State private var showPlaceholder = false
var body: some View {
NavigationView {
ZStack {
Button(
action: {
showPlaceholder = true
}, label: {
Text("Go to placeholder")
}
)
NavigationLink(
destination: PlaceholderView(),
isActive: $showPlaceholder
) {
EmptyView()
}
.hidden()
}
}
.navigationViewStyle(.stack)
}
}
Why is this and how can I stop this from happening?
UPDATE:
Wrapping self.isAnimating = true in DispatchQueue.main.async {} fixes the issue, but I don't understand why...
I can reproduce the problem, but I wasn't able to reproduce your DispatchQueue.main.async {} fix.
Here is how I fixed it:
Animation is made by comparing a before state and and after state and animating between them. With an implicit animation, the before state is the position of the circle before it is added to the navigation view. By switching to an explicit animation and setting your before state after the view appears, the move animation is avoided and you only get the scaling:
Replace isAnimating with scale. In onAppear {}, first establish the before scale of 0.8, then animate the change to scale 1:
struct PlaceholderView: View {
// To work correctly, this initial value should be different
// than the before value set in .onAppear {}.
#State private var scale = 1.0
var body: some View {
Circle()
.frame(width: 30, height: 30)
.scaleEffect(self.scale)
.onAppear {
self.scale = 0.8 // before
withAnimation(.easeInOut(duration: 1).repeatForever()) {
self.scale = 1 // after
}
}
.frame(width: 50, height: 50)
.contentShape(
Rectangle()
)
}
}

Animation conflict issue with another animation

I have this LoaderView which grow with given value, I want add some highlight effect to it as well, So basically there is 2 deferent and separate animation happening, one for activeValue and one for the highlight. i did not combine them together because they are 2 deferent things, but my output of codes combine these 2 values together and destroys highlight effect. I am looking a solid answer for my issue to having this 2 animation works side by side.
The issue happens when i add value.
I want this view be usable in macOS as well, so there is a possibility user changes the Window size, then my view would be changed as well.
struct ContentView: View {
#State private var value: CGFloat = 50
var body: some View {
LoaderView(activeValue: value, totalValue: 100)
.padding()
Button("add") { value += 10 }
.padding()
}
}
struct LoaderView: View {
let activeValue: CGFloat
let totalValue: CGFloat
let activeColor: Color
let totalColor: Color
init(activeValue: CGFloat, totalValue: CGFloat, activeColor: Color = Color.green, totalColor: Color = Color.secondary) {
if activeValue >= totalValue { self.activeValue = totalValue }
else if activeValue < 0 { self.activeValue = 0 }
else { self.activeValue = activeValue }
self.totalValue = totalValue
self.activeColor = activeColor
self.totalColor = totalColor
}
#State private var startAnimation: Bool = .init()
private let heightOfCapsule: CGFloat = 5.0
var body: some View {
let linearGradient = LinearGradient(colors: [Color.yellow.opacity(0.1), Color.yellow], startPoint: .leading, endPoint: .trailing)
return GeometryReader { proxy in
totalColor
.overlay(
Capsule()
.fill(activeColor)
.frame(width: (activeValue / totalValue) * proxy.size.width)
.overlay(
Capsule()
.fill(linearGradient)
.frame(width: 100)
.offset(x: startAnimation ? 100 : -100),
alignment: startAnimation ? Alignment.trailing : Alignment.leading)
.clipShape(Capsule()),
alignment: Alignment.leading)
.clipShape(Capsule())
}
.frame(height: heightOfCapsule)
.onAppear(perform: { startAnimation.toggle() })
.animation(Animation.default, value: activeValue)
.animation(Animation.linear(duration: 3.0).repeatForever(autoreverses: false), value: startAnimation)
}
}
The issue isn't the .animations it is that .onAppear is only called once, so your animation never restarts. startAnimation needs to be toggled when activeValue changes. The way your animation works, toggling startAnimation reverses the animation direction. Therefore, I set autoreverses: true. It is a compromise either way.
struct LoaderView: View {
//Nothing changed in this portion
.onAppear{
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
startAnimation.toggle()
}
}
// The issue is that .onAppear() is only called once.
.onChange(of: activeValue, perform: { _ in
startAnimation.toggle()
})
.animation(Animation.default, value: activeValue)
// As a side effect, changing startAnimation to opposite values reverses the animation
// you have made, so setting autoreverses: true hides this.
.animation(Animation.linear(duration: 3.0).repeatForever(autoreverses: true), value: startAnimation)
}
}
Edit:
I apologize for not getting back to this sooner. The issue with the rubber band animation is because of the transition when the app firsts launches. To prevent this, you need to wrap your variable change in .onAppear() in a DispatchQueue.main.asyncAfter(deadline:) with enough time for the view to load. Then when you start the animation, it works correctly.

SwiftUI - how to exclude inner `DragGesture` from outer `simultaneousGesture`?

I have a yellow button and a blue circle inside a gray container. I added DragGestures to both the circle and the container — however, the circle never moves.
This is the behavior that I want:
Dragging the gray area
Dragging the button
Dragging the circle
The container moves
The container moves
The circle moves
This is what I get:
Dragging the gray area
Dragging the button
Dragging the circle
The container moves ✅
The container moves ✅
The container moves ❌
Here's my code:
struct ContentView: View {
#GestureState var containerOffset = CGSize.zero
#GestureState var circleOffset = CGSize.zero
var body: some View {
VStack { /// gray container
/// should move the gray container when dragged
Button {
print("Button Pressed")
} label: {
Text("Button")
.padding(50)
.background(.yellow)
}
/// should **NOT** move the gray container when dragged
/// How do I make it move the circle instead?
Circle()
.fill(.blue)
.frame(width: 100, height: 100)
.offset(circleOffset)
.gesture(
DragGesture() /// this never gets called!
.updating($circleOffset) { value, circleOffset, transaction in
circleOffset = value.translation
}
)
.padding(50)
}
.background(Color.gray)
.offset(containerOffset)
.simultaneousGesture( /// `simultaneous` is needed to allow dragging from the yellow button
DragGesture() /// move the gray container
.updating($containerOffset) { value, containerOffset, transaction in
containerOffset = value.translation
}
)
}
}
How do I stop the outer DragGesture from swallowing the inner circle's DragGesture?
One way to do it is to add a state variable in ContentView that tracks whether the circle's gesture is active. When the circle's gesture is active, use a GestureMask of .subviews on the outer gesture to prevent it from activating. You can do it with an #State:
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#GestureState var containerOffset = CGSize.zero
#GestureState var circleOffset = CGSize.zero
#State var subviewDragIsActive = false
var body: some View {
VStack { /// gray container
/// should move the gray container when dragged
Button {
print("Button Pressed")
} label: {
Text("Button")
.padding(50)
.background(Color.yellow)
}
/// should **NOT** move the gray container when dragged
/// How do I make it move the circle instead?
Circle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.offset(circleOffset)
.gesture(
DragGesture() /// this never gets called!
.updating($circleOffset) { value, circleOffset, transaction in
circleOffset = value.translation
}
.onChanged { _ in
subviewDragIsActive = true
}
.onEnded { _ in
subviewDragIsActive = false
}
)
.padding(50)
}
.background(Color.gray)
.offset(containerOffset)
.simultaneousGesture( /// `simultaneous` is needed to allow dragging from the yellow button
DragGesture() /// move the gray container
.updating($containerOffset) { value, containerOffset, transaction in
containerOffset = value.translation
},
including: subviewDragIsActive ? .subviews : .all
)
}
}
PlaygroundPage.current.setLiveView(ContentView())
or you can do it with an #GestureState:
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#GestureState var containerOffset = CGSize.zero
#GestureState var circleOffset = CGSize.zero
#GestureState var subviewDragIsActive = false
var body: some View {
VStack { /// gray container
/// should move the gray container when dragged
Button {
print("Button Pressed")
} label: {
Text("Button")
.padding(50)
.background(Color.yellow)
}
/// should **NOT** move the gray container when dragged
/// How do I make it move the circle instead?
Circle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.offset(circleOffset)
.gesture(
DragGesture() /// this never gets called!
.updating($circleOffset) { value, circleOffset, transaction in
circleOffset = value.translation
}
.updating($subviewDragIsActive) {
_, flag, _ in
flag = true
}
)
.padding(50)
}
.background(Color.gray)
.offset(containerOffset)
.simultaneousGesture( /// `simultaneous` is needed to allow dragging from the yellow button
DragGesture() /// move the gray container
.updating($containerOffset) { value, containerOffset, transaction in
containerOffset = value.translation
},
including: subviewDragIsActive ? .subviews : .all
)
}
}
PlaygroundPage.current.setLiveView(ContentView())
If the circle is in a separately-defined View type, you should use an #State so you can pass down a Binding, like this:
import SwiftUI
import PlaygroundSupport
struct CircleView: View {
#GestureState var circleOffset = CGSize.zero
#Binding var dragIsActive: Bool
var body: some View {
Circle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.offset(circleOffset)
.gesture(
DragGesture() /// this never gets called!
.updating($circleOffset) { value, circleOffset, transaction in
circleOffset = value.translation
}
.onChanged { _ in dragIsActive = true }
.onEnded { _ in dragIsActive = false }
)
}
}
struct ContentView: View {
#GestureState var containerOffset = CGSize.zero
#State var subviewDragIsActive = false
var body: some View {
VStack { /// gray container
/// should move the gray container when dragged
Button {
print("Button Pressed")
} label: {
Text("Button")
.padding(50)
.background(Color.yellow)
}
/// should **NOT** move the gray container when dragged
/// How do I make it move the circle instead?
CircleView(dragIsActive: $subviewDragIsActive)
.padding(50)
}
.background(Color.gray)
.offset(containerOffset)
.simultaneousGesture( /// `simultaneous` is needed to allow dragging from the yellow button
DragGesture() /// move the gray container
.updating($containerOffset) { value, containerOffset, transaction in
containerOffset = value.translation
},
including: subviewDragIsActive ? .subviews : .all
)
}
}
PlaygroundPage.current.setLiveView(ContentView())
If any of several subviews might have a drag going on simultaneously (due to multitouch), you should probably use the preference system to pass their isActive states up to ContentView, but that's a significantly more complex implementation.

How do I fix a custom button has its interaction in SwiftUI(IOS)?

I created a custom button in SwiftUi(ios) like this that has its own interaction
but when I use it with two other animations, the interaction of the button is replaced by the new ones
Here is the custom button code
import SwiftUI
public struct fillCircular: ButtonStyle {
var width: CGFloat
var height: CGFloat = 50
public func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.padding(.horizontal)
.frame(width: width, height: height)
.background(Capsule(style: .circular).fill(Color.primary))
.font(.titleChanga3)
.foregroundColor(Color.white)
.shadow(color: Color.primary
.opacity(configuration.isPressed ? 0.55 : 0.34),
radius: configuration.isPressed ? 1 : 4,
x: 0, y: configuration.isPressed ? 1 : 3)
.scaleEffect(CGSize(width: configuration.isPressed ? 0.99 : 1,
height: configuration.isPressed ? 0.99 : 1))
.animation(.spring())
}
}
Here lies the problem
struct Welcome: View {
#State var start: Bool = false
var body: some View {
GeometryReader { g in
Button(action: {}) {
Text("New Account")
}
.buttonStyle(fillCircular(width: g.size.width - (24 * 2)))
.offset(y: self.start ? 0 : 20)
.opacity(self.start ? 1 : 0)
.animation(Animation.easeOut(duration: 0.5).delay(0.4))
}
}
When this method is added, the animation changes in the custom button (fillCircular)
.animation(Animation.easeOut(duration: 0.5).delay(0.4))
How do I enter Animation and at the same time maintain the interaction of the custom button
Updated answer
Basically, your second .animation() call is overriding the first. Instead of applying the second animation, wrap your start = true in a withAnimation() call, like so:
struct WelcomeView: View {
#State var start: Bool = false
var body: some View {
VStack {
// This is just here to toggle start variable
Button("Toggle", action: {
withAnimation(Animation.easeOut(duration: 0.5).delay(0.4)) {
self.start.toggle()
}
})
GeometryReader { g in
Button(action: {}) {
Text("New Account")
}
.buttonStyle(fillCircular(width: g.size.width - (24 * 2)))
.offset(y: self.start ? 0 : 20)
.opacity(self.start ? 1 : 0)
}
}
}
}
Then the easeOut animation will only apply when the button's action closure is called.
This answer is cleaner, but the original also works as of Swift 5.1 / Xcode 11.0
Original answer
Although it's a little hacky, you could have whatever sets start = true also set the animation to .spring() after a delay.
struct WelcomeView: View {
#State var start: Bool = false
#State var animation: Animation = Animation.easeOut(duration: 0.5).delay(0.4)
var body: some View {
VStack {
// This is just here to toggle start variable
Button("Toggle", action: {
if !self.start {
// Show button, and after combined delay + duration, set animation to .spring()
self.start.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) {
self.animation = .spring()
}
} else {
// Set animation to .easeOut(), then hide button
self.animation = Animation.easeOut(duration: 0.5).delay(0.4)
self.start.toggle()
}
})
GeometryReader { g in
Button(action: {}) {
Text("New Account")
}
.buttonStyle(fillCircular(width: g.size.width - (24 * 2)))
.offset(y: self.start ? 0 : 20)
.opacity(self.start ? 1 : 0)
.animation(self.animation)
}
}
}
}

Resources