Offset with animation is breaking buttons SwiftUI - swift

I have some buttons inside a stack with an animated offset. For some reason, with the animated offset buttons, they are not clickable. The buttons seem to be clickable for a second when offset is about 250 or so and then become non-clickable at offsets below that value again...Any help is much appreciated!
struct ContentView: View {
#State var offset: CGFloat = -300
var body: some View {
HStack {
Button(action: {
print("clickable")
}, label: {
Text("Click me")
})
Button(action: {
print("clickable2")
}, label: {
Text("Click me2")
})
Button(action: {
print("clickable3")
}, label: {
Text("Click me3")
})
}.offset(x: offset)
.onAppear(perform: {
withAnimation(.linear(duration: 10).repeatForever()) {
offset = 300
}
})
}
}

How Offsetting works?
First of all, this is an expected behavior. Because when you use offset, SwiftUI shifts the displayed contents. To be brief, that means, SwiftUI shifts the View itself
Since onTapGesture only recognizes the touches on the view that also explains why you can click to an offsetted View
How Animation Works?
In your code, you're offsetting your View First, then you're applying your animation. When you use withAnimation, SwiftUI recomputes the view's body with provided animation, but keep in mind that it does not change anything that is applied to the View beforehand.
Notice how Click Me becomes clickable when entering the red rectangle. That happens because the red rectangle indicates the final offset amount of the Click Me button. (so it is just a placeholder)
So the View itself, and the offset has to match because as you offset your View first, SwiftUI needs your view there to trigger the tap gestures.
Possible solution
Now that we understand the problem, we can solve it. So, the problem happens because we are offsetting our view first, then applying animation.
So if that does not help, one possible solution could be to change the offset in periods (for example, I used 0.1 seconds per period) with an animation, because that would result in SwiftUI repositioning the view every time we change the offset, so our weird bug should not occur.
Code:
struct ContentView: View {
#State private var increment : CGFloat = 1
#State private var offset : CGFloat = 0
var body: some View {
ZStack {
Button("Click Me") {
print("Click")
}
.fontWeight(.black)
}
.tappableOffsetAnimation(offset: $offset, animation: .linear, duration: 5, finalOffsetAmount: 300)
}
}
struct TappableAnimationModifier : ViewModifier {
#Binding var offset : CGFloat
var duration : Double
var finalOffsetAmount : Double
var animation : Animation
var timerPublishInSeconds : TimeInterval = 0.1
let timer : Publishers.Autoconnect<Timer.TimerPublisher>
var autoreverses : Bool = false
#State private var decreasing = false
public init(offset: Binding<CGFloat>, duration: Double, finalOffsetAmount: Double, animation: Animation, autoreverses: Bool) {
self._offset = offset
self.duration = duration
self.finalOffsetAmount = finalOffsetAmount
self.animation = animation
self.autoreverses = autoreverses
self.timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
}
public init(offset: Binding<CGFloat>, duration: Double, finalOffsetAmount: Double, animation: Animation, timerPublishInSeconds: TimeInterval) {
self._offset = offset
self.duration = duration
self.finalOffsetAmount = finalOffsetAmount
self.animation = animation
self.timerPublishInSeconds = timerPublishInSeconds
self.timer = Timer.publish(every: timerPublishInSeconds, on: .main, in: .common).autoconnect()
}
public init(offset: Binding<CGFloat>, duration: Double, finalOffsetAmount: Double, animation: Animation) {
self._offset = offset
self.duration = duration
self.finalOffsetAmount = finalOffsetAmount
self.animation = animation
self.timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
}
func body(content: Content) -> some View {
content
.animation(animation, value: offset)
.offset(x: offset)
.onReceive(timer) { input in
/*
* a simple math here, we're dividing duration by 0.1 because our timer gets triggered
* in every 0.1 seconds, so dividing this result will always produce the
* proper value to finish offset animation in `x` seconds
* example: 300 / (5 / 0.1) = 300 / 50 = 6 increment per 0.1 second
*/
if (offset >= finalOffsetAmount) {
// you could implement autoReverses by not canceling the timer here
// and substracting finalOffsetAmount / (duration / 0.1) until it reaches zero
// then you can again start incrementing it.
if autoreverses {
self.decreasing = true
}
else {
timer.upstream.connect().cancel()
return
}
}
if offset <= 0 {
self.decreasing = false
}
if decreasing {
offset -= finalOffsetAmount / (duration / timerPublishInSeconds)
}
else {
offset += finalOffsetAmount / (duration / timerPublishInSeconds)
}
}
}
}
extension View {
func tappableOffsetAnimation(offset: Binding<CGFloat>, animation: Animation, duration: Double, finalOffsetAmount: Double) -> some View {
modifier(TappableAnimationModifier(offset: offset, duration: duration, finalOffsetAmount: finalOffsetAmount, animation: animation))
}
}
edit: I added a customizable timestamp as well as auto reverses.
Here's how it looks like:
Your view is running, go catch it out x)

Related

Random modify state after animation completion

I'd like to be able to change the state of a variable once an animation is complete and then redraw using the new value.
For example, in the code below, I'd like the Text to rotate a different amount each time the animation cycle completes.
struct RotateView: View {
#State var angle: CGFloat = 0
var body: some View {
Text("Rotate")
.rotationEffect(Angle.degrees(angle))
.onAppear {
withAnimation(.easeInOut(duration: 2).repeatForever()) {
self.angle = CGFloat.random(in: -360 ... 360)
}
}
}
}
struct RotateView_Previews: PreviewProvider {
static var previews: some View {
RotateView()
}
}
I understand that within the withAnimation body closure that I'm only setting the state to animate between and this is only executed once - thus, a random amount is calculated and the text rotates that amount forever.
I've looked at this excellent post that adds an onAnimationCompleted but it seems like overkill for my problem. In addition, while it sort of worked, the callback was called many times after each animation completed and randomly set the variable many times - causing the Text to rotate very quickly until the animation kicked off again?
if you can stick to the defined duration of 2 secs, you can combine the .onAppear animation with additional animations kicked off by a timer that fires every two seconds.
let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
#State var angle: CGFloat = 0
var body: some View {
Text("Rotate")
.rotationEffect(Angle.degrees(angle))
.onAppear {
withAnimation(.easeInOut(duration: 2)) {
self.angle = CGFloat.random(in: -360 ... 360)
}
}
.onReceive(timer) { time in
withAnimation(.easeInOut(duration: 2)) {
self.angle = CGFloat.random(in: -360 ... 360)
}
}
}

How to create own ScrollView using DragGesture?

I'm trying to mimic ScrollView behaviour in my own custom component.
The thing I'm having trouble with is scroll animation. As far I understand I can use predictedEndTranslation object to get the predicted position of the drag gesture. But I don't know which easing function I should use to mimic default ScrollView easing.
Here is my code
struct ViewContent: View {
#State var offsetY: CGFloat = 0
var body: some View {
View {
}
.offset(y: offsetY)
.gesture(
DragGesture()
.onChange { value in
offsetY = value.translation.height
}
.onEnded { value in
let nextScrollPosition = value.predictedEndTranslation.height
withAnimation(???) { // What easing to use?
offsetY = nextScrollPosition
}
}
)
}
}
I would use .spring() as this preset animation will get you the closes to the generic scrollview animation.
withAnimation(.spring()) { // User .spring()
offsetY = nextScrollPosition
}

How to prevent Array from crashing while simultaneously adding and deleting rows?

I have this circles array that results in Circles being created at random positions, and then fading out. Upon a circle faded out I need to completely delete it, which is achieved by removing the corresponding row in the array after a delay equal to the end of the fade-out animation.
It works fine as long as I click the "add circle" button less frequently than the deletion speed. However, when I keep pressing it fast, the view crashes with an index out of range. This appears to be the result of the algorithm getting confused: it identifies the row to be deleted by its index, but in the meantime other rows could have been deleted/added, which means indexes are already different.
Please see:
import SwiftUI
let screenW = UIScreen.main.bounds.size.width
let screenH = UIScreen.main.bounds.size.height
struct PlayGround: View {
struct BlackCircle {
var id = UUID()
var position: CGPoint
var opacity: Double = 1
}
#State var circles: [BlackCircle] = []
var body: some View {
ZStack {
ForEach(Array(circles.enumerated()), id: \.1.id) {
index, item in
Circle()
.frame(width:30, height:30)
.position(item.position)
.opacity(item.opacity)
.onAppear {
let animation = Animation.linear(duration: 1)
withAnimation(animation) {
circles[index].opacity = 0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
circles.remove(at: index)
}
}
}.frame(width: screenW, height:screenH)
Button(action: {
let randx = CGFloat.random(in: 30...screenW-30)
let randy = CGFloat.random(in: 30...screenH-30)
let randpos = CGPoint(x: randx, y: randy)
circles.append(BlackCircle(position: randpos))
}, label: {
Text("Add a Circle")
})
}
}
}
The easiest and no-effort way to solve this is to just not remove the row when the circle has faded out. This is surely both inelegant and inefficient though (would possibly end up with thousands of useless rows in the array). So how would I go about preventing the array from crashing when new elements are being added and delay-deleted all the time?
Thanks!
Just use following delete method without using indexes
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
circles.removeAll { (circle) -> Bool in
circle.id == item.id
}
}
If the problem is due to the index being out of range, try removing the circles based on id instead of index:
circles.removeAll { (existingCircle) -> Bool in
return existingCircle.id == item.id
}

How to make a LongPressGesture that runs repeatedly while the button is still being held down in SwiftUI?

I'd like to run the code in the longPressGesture every 0.5 seconds while the button is being held down. Any ideas on how to implement this?
import SwiftUI
struct ViewName: View {
var body: some View {
VStack {
Button(action: { } ) {
Image(systemName: "chevron.left")
.onTapGesture {
//Run code for tap gesture here
}
.onLongPressGesture (minimumDuration: 0.5) {
//Run this code every 0.5 seconds
}
}
}
}
You can do this by using timer. Make the timer starts when the user long pressed the image, and if the timer reaches 0, you can add two actions: 1. resetting the timer back to 0.5 seconds and 2.code you want to run every 0.5 seconds
struct ContentView: View {
#State var timeRemaining = 0.5
let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
#State var userIsPressing = false //detecting whether user is long pressing the screen
var body: some View {
VStack {
Image(systemName: "chevron.left").onReceive(self.timer) { _ in
if self.userIsPressing == true {
if self.timeRemaining > 0 {
self.timeRemaining -= 0.5
}
//resetting the timer every 0.5 secdonds and executing code whenever //timer reaches 0
if self.timeRemaining == 0 {
print("execute this code")
self.timeRemaining = 0.5
}
}
}.gesture(LongPressGesture(minimumDuration: 0.5)
.onChanged() { _ in
//when longpressGesture started
self.userIsPressing = true
}
.onEnded() { _ in
//when longpressGesture ended
self.userIsPressing = false
}
)
}
}
}
Oh boy, I'm not really an expert but I've had a similar problem (detecting pressing and releasing) recently and the solution I've found is less than elegant. I'd love if someone show a more elegant solution but here's my monstrosity:
import SwiftUI
import Combine
struct ContentView: View {
#State private var ticker = Ticker()
#State private var isPressed: Bool = false
#State private var timePassed: TimeInterval?
var body: some View {
Button(action: {
// Action when tapped
NSLog("Tapped!")
}) {
Text(self.isPressed ? "Pressed for: \(String(format: "%0.1f", timePassed ?? 0))" : "Press and hold")
.padding()
.background(Capsule().fill(Color.yellow))
}
.onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: { (value) in
self.isPressed = value
if value == true {
self.timePassed = 0
self.ticker.start(interval: 0.5)
}
}, perform: {})
.onReceive(ticker.objectWillChange) { (_) in
// Stop timer and reset the start date if the button in not pressed
guard self.isPressed else {
self.ticker.stop()
return
}
// Your code here:
self.timePassed = self.ticker.timeIntervalSinceStarted
}
}
}
/// Helper "ticker" that will publish regular "objectWillChange" messages
class Ticker: ObservableObject {
var startedAt: Date = Date()
var timeIntervalSinceStarted: TimeInterval {
return Date().timeIntervalSince(startedAt)
}
private var timer: Timer?
func start(interval: TimeInterval) {
stop()
startedAt = Date()
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
self.objectWillChange.send()
}
}
func stop() {
timer?.invalidate()
}
deinit {
timer?.invalidate()
}
}
This requires an explanation:
onTapGesture() is not necessary here because that's what Button does by default, so just putting the code you need to run in the action block should be sufficient;
There is a limited number of gestures available in SwiftUI and, as far as I know, the only way to make new gestures is to combine existing ones;
There is no gesture that would continuously execute some code as long as the button is pressed, but LongPressGesture might be the closest to it. However, this gesture is recognized (and ends) when the allotted time expires but you want to detect the touch as long as it lasts, hence the minimumDuration: .infinity parameter;
LongPressGesture would also end when the touch has moved away a long enough distance, however, that's not how the Button works – you can wander away and return back and, as long as you've lifted the touch on top of the button view, the gesture will be recognized as a button press. We should replicate this behavior in our long press as well, hence maximumDistance: .infinity;
With these parameters, the LongPressGesture will never be recognized, but there is a press parameter that now allows us to be notified when presses start and end;
Some sort of a timer could be used to execute a code block every so often; I've copied this "ticker" ObservableObject from somewhere. It has to be an ObservableObject because, that way, we can subscribe to it's updates within the View;
Now, when the button in pressed, we start the ticker;
When ticker ticks, we capture that with the onReceive() subscriber and that allows us to do something on every tick.
Something like that; again, I'd love someone to show me a better way :)
Good luck with your project!
–Baglan
I simply cleaned up #Baglan 's "monstrosity" a bit this morning.
import Foundation
import SwiftUI
struct LongPressButton: View {
#ObservedObject var timer = PressTimer()
enum PressState {
case inactive
case pressing
case finished
}
#State private var pressState = PressState.inactive
var duration: Double = 2.0
var body: some View {
button
.onLongPressGesture(minimumDuration: duration, maximumDistance: 50, pressing: { (value) in
if value == true {
/// Press has started
self.pressState = .pressing
print("start")
self.timer.start(duration)
} else {
/// Press has cancelled
self.pressState = .inactive
print("stop")
self.timer.stop()
}
}, perform: {
/// Press has completed successfully
self.pressState = .finished
print("done")
})
}
var button: some View {
pressState == .pressing ? Text("Pressing - \(String(format: "%.0f", timer.percent))%")
: Text("Start")
}
}
class PressTimer: ObservableObject {
#Published var percent: CGFloat = 0
private var count: CGFloat = 0
private let frameRateHz: CGFloat = 60
private var durationSeconds: CGFloat = 2
var timer: Timer?
func start(_ duration: Double = 2.0) {
self.durationSeconds = CGFloat(duration)
let timerInterval: CGFloat = 1 / frameRateHz
timer = Timer.scheduledTimer(withTimeInterval: Double(timerInterval), repeats: true, block: { _ in
self.count += timerInterval
self.percent = self.count / self.durationSeconds * 100
})
}
func stop() {
self.count = 0
self.percent = 0
self.timer?.invalidate()
self.timer = nil
}
}

SwiftUI: how to play ping-pong animation once? Correct way to play animation forward and backward?

Sample of what I need:
.
As there is absent .onAnimationCompleted { // Some work... } its pretty problematic.
Generally I need the solution that will have a following characteristics:
Most short and elegant way of playing some ping-pong animation ONCE. Not infinite!
Make code reusable. As example - made it as ViewModifier.
To have a way to call animation externally
my code:
import SwiftUI
import Combine
struct ContentView: View {
#State var descr: String = ""
#State var onError = PassthroughSubject<Void, Never>()
var body: some View {
VStack {
BlurredTextField(title: "Description", text: $descr, onError: $onError)
Button("Commit") {
if self.descr.isEmpty {
self.onError.send()
}
}
}
}
}
struct BlurredTextField: View {
let title: String
#Binding var text: String
#Binding var onError: PassthroughSubject<Void, Never>
#State private var anim: Bool = false
#State private var timer: Timer?
#State private var cancellables: Set<AnyCancellable> = Set()
private let animationDiration: Double = 1
var body: some View {
TextField(title, text: $text)
.blur(radius: anim ? 10 : 0)
.animation(.easeInOut(duration: animationDiration))
.onAppear {
self.onError
.sink(receiveValue: self.toggleError)
.store(in: &self.cancellables)
}
}
func toggleError() {
timer?.invalidate()// no blinking hack
anim = true
timer = Timer.scheduledTimer(withTimeInterval: animationDiration, repeats: false) { _ in
self.anim = false
}
}
}
How about this? Nice call site, logic encapsulated away from your main view, optional blink duration. All you need to provide is the PassthroughSubject, and call .send() when you want the blink to happen.
import SwiftUI
import Combine
struct ContentView: View {
let blinkPublisher = PassthroughSubject<Void, Never>()
var body: some View {
VStack(spacing: 10) {
Button("Blink") {
self.blinkPublisher.send()
}
Text("Hi")
.addOpacityBlinker(subscribedTo: blinkPublisher)
Text("Hi")
.addOpacityBlinker(subscribedTo: blinkPublisher, duration: 0.5)
}
}
}
Here's the view extension you would call
extension View {
// the generic constraints here tell the compiler to accept any publisher
// that sends outputs no value and never errors
// this could be a PassthroughSubject like above, or we could even set up a TimerPublisher
// that publishes on an interval, if we wanted a looping animation
// (we'd have to map it's output to Void first)
func addOpacityBlinker<T: Publisher>(subscribedTo publisher: T, duration: Double = 1)
-> some View where T.Output == Void, T.Failure == Never {
// here I take whatever publisher we got and type erase it to AnyPublisher
// that just simplifies the type so I don't have to add extra generics below
self.modifier(OpacityBlinker(subscribedTo: publisher.eraseToAnyPublisher(),
duration: duration))
}
}
Here's the ViewModifier where the magic actually happens
// you could call the .modifier(OpacityBlinker(...)) on your view directly,
// but I like the View extension method, as it just feels cleaner to me
struct OpacityBlinker: ViewModifier {
// this is just here to switch on and off, animating the blur on and off
#State private var isBlurred = false
var publisher: AnyPublisher<Void, Never>
// The total time it takes to blur and unblur
var duration: Double
// this initializer is not necessary, but allows us to specify a default value for duration,
// and the call side looks nicer with the 'subscribedTo' label
init(subscribedTo publisher: AnyPublisher<Void, Never>, duration: Double = 1) {
self.publisher = publisher
self.duration = duration
}
func body(content: Content) -> some View {
content
.blur(radius: isBlurred ? 10 : 0)
// This basically subscribes to the publisher, and triggers the closure
// whenever the publisher fires
.onReceive(publisher) { _ in
// perform the first half of the animation by changing isBlurred to true
// this takes place over half the duration
withAnimation(.linear(duration: self.duration / 2)) {
self.isBlurred = true
// schedule isBlurred to return to false after half the duration
// this means that the end state will return to an unblurred view
DispatchQueue.main.asyncAfter(deadline: .now() + self.duration / 2) {
withAnimation(.linear(duration: self.duration / 2)) {
self.isBlurred = false
}
}
}
}
}
}
John's answer is absolutely great and helped me get to exactly what I was looking for. I extended the answer to allow for any view modification to "flash" once and return.
Example Result:
Example Code:
struct FlashTestView : View {
let flashPublisher1 = PassthroughSubject<Void, Never>()
let flashPublisher2 = PassthroughSubject<Void, Never>()
var body: some View {
VStack {
Text("Scale Out & In")
.padding(20)
.background(Color.white)
.flash(on: flashPublisher1) { (view, isFlashing) in
view
.scaleEffect(isFlashing ? 1.5 : 1)
}
.onTapGesture {
flashPublisher1.send()
}
Divider()
Text("Flash Text & Background")
.padding(20)
// Connivence view extension for background and text color
.flash(
on: flashPublisher2,
originalBackgroundColor: .white,
flashBackgroundColor: .blue,
originalForegroundColor: .primary,
flashForegroundColor: .white)
.onTapGesture {
flashPublisher2.send()
}
}
}
}
Here's the modified code from John's answer.
extension View {
/// Listens to a signal from a publisher and temporarily applies styles via the content callback.
/// - Parameters:
/// - publisher: The publisher that sends a signal to apply the temp styles.
/// - animation: The animation used to change properties.
/// - delayBack: How long, in seconds, after flashing starts should the styles start to revert. Typically this is the same duration as the animation.
/// - content: A closure with two arguments to allow customizing the view when flashing. Should return the modified view back out.
/// - view: The view being modified.
/// - isFlashing: A boolean to indicate if a flash should be applied. Example: `view.scaleEffect(isFlashing ? 1.5 : 1)`
/// - Returns: A view that applies its flash changes when it receives its signal.
func flash<T: Publisher, InnerContent: View>(
on publisher: T,
animation: Animation = .easeInOut(duration: 0.3),
delayBack: Double = 0.3,
#ViewBuilder content: #escaping (_ view: Self, _ isFlashing: Bool) -> InnerContent)
-> some View where T.Output == Void, T.Failure == Never {
// here I take whatever publisher we got and type erase it to AnyPublisher
// that just simplifies the type so I don't have to add extra generics below
self.modifier(
FlashStyleModifier(
publisher: publisher.eraseToAnyPublisher(),
animation: animation,
delayBack: delayBack,
content: { (view, isFlashing) in
return content(self, isFlashing)
}))
}
/// A helper function built on top of the method above.
/// Listens to a signal from a publisher and temporarily animates to a background color and text color.
/// - Parameters:
/// - publisher: The publisher that sends a signal to apply the temp styles.
/// - animation: The animation used to change properties.
/// - delayBack: How long, in seconds, after flashing starts should the styles start to revert. Typically this is the same duration as the animation.
/// - originalBackgroundColor: The normal state background color
/// - flashBackgroundColor: The background color when flashing.
/// - originalForegroundColor: The normal text color.
/// - flashForegroundColor: The text color when flashing.
/// - Returns: A view that flashes it's background and text color.
func flash<T: Publisher>(
on publisher: T,
animation: Animation = .easeInOut(duration: 0.3),
delayBack: Double = 0.3,
originalBackgroundColor: Color,
flashBackgroundColor: Color,
originalForegroundColor: Color,
flashForegroundColor: Color)
-> some View where T.Output == Void, T.Failure == Never {
// here I take whatever publisher we got and type erase it to AnyPublisher
// that just simplifies the type so I don't have to add extra generics below
self.flash(on: publisher, animation: animation) { view, isFlashing in
return view
// Need to apply arbitrary foreground color, but it's not animatable but need for colorMultiply to work.
.foregroundColor(.white)
// colorMultiply is animatable, so make foregroundColor flash happen here
.colorMultiply(isFlashing ? flashForegroundColor : originalForegroundColor)
// Apply background AFTER colorMultiply so that background color is not unexpectedly modified
.background(isFlashing ? flashBackgroundColor : originalBackgroundColor)
}
}
}
/// A view modifier that temporarily applies styles based on a signal from a publisher.
struct FlashStyleModifier<InnerContent: View>: ViewModifier {
#State
private var isFlashing = false
let publisher: AnyPublisher<Void, Never>
let animation: Animation
let delayBack: Double
let content: (_ view: Content, _ isFlashing: Bool) -> InnerContent
func body(content: Content) -> some View {
self.content(content, isFlashing)
.onReceive(publisher) { _ in
withAnimation(animation) {
self.isFlashing = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + delayBack) {
withAnimation(animation) {
self.isFlashing = false
}
}
}
}
}