Delay a repeating animation in SwiftUI with between full autoreverse repeat cycles - swift

I’m building an Apple Watch app in SwiftUI that reads the user’s heart rate and displays it next to a heart symbol.
I have an animation that makes the heart symbol beat repeatedly. Since I know the actual user’s heart rate, I’d like to make it beat at the same rate as the user’s heart rate, updating the animation every time the rate changes.
I can determine how long many seconds should be between beats by dividing the heart rate by 60. For example, if the user’s heart rate is 80 BPM, the animation should happen every 0.75 seconds (60/80).
Here is example code of what I have now, where currentBPM is a constant, but normally that will be updated.
struct SimpleBeatingView: View {
// Once I get it working, this will come from a #Published Int that gets updated any time a new reading is avaliable.
let currentBPM: Int = 80
#State private var isBeating = false
private let maxScale: CGFloat = 0.8
var beatingAnimation: Animation {
// The length of one beat
let beatLength = 60 / Double(currentBPM)
return Animation
.easeInOut(duration: beatLength)
.repeatForever()
}
var body: some View {
Image(systemName: "heart.fill")
.font(.largeTitle)
.foregroundColor(.red)
.scaleEffect(isBeating ? 1 : maxScale)
.animation(beatingAnimation)
.onAppear {
self.isBeating = true
}
}
}
I'm looking to make this animation behave more like Apple's built-in Heart Rate app. Instead of the heart constantly getting bigger or smaller, I'd like to have it beat (the animation in BOTH directions) then pause for a moment before beating again (the animation in both directions) then pause again, and so on.
When I add a one second delay, for example, with .delay(1) before .repeatForever(), the animation pauses half way through each beat. For example, it gets smaller, pauses, then gets bigger, then pauses, etc.
I understand why this happens, but how can I insert the delay between each autoreversed repeat instead of at both ends of the autoreversed repeat?
I'm confident I can figure out the math for how long the delay should be and the length of each beat to make everything work out correctly, so the delay length can be arbitrary, but what I'm looking for is help on how I can achieve a pause between loops of the animation.
One approach I played with was to flatMap the currentBPM into repeating published Timers every time I get a new heart rate BPM so I can try to drive animations from those, but I wasn't sure how I can actually turn that into an animation in SwiftUI and I'm not sure if manually driving values that way is the right approach when the timing seems like it should be handled by the animation, based on my current understanding of SwiftUI.

A possible solution is to chain single pieces of animation using DispatchQueue.main.asyncAfter. This gives you control when to delay specific parts.
Here is a demo:
struct SimpleBeatingView: View {
#State private var isBeating = false
#State private var heartState: HeartState = .normal
#State private var beatLength: TimeInterval = 1
#State private var beatDelay: TimeInterval = 3
var body: some View {
VStack {
Image(systemName: "heart.fill")
.imageScale(.large)
.font(.largeTitle)
.foregroundColor(.red)
.scaleEffect(heartState.scale)
Button("isBeating: \(String(isBeating))") {
isBeating.toggle()
}
HStack {
Text("beatLength")
Slider(value: $beatLength, in: 0.25...2)
}
HStack {
Text("beatDelay")
Slider(value: $beatDelay, in: 0...5)
}
}
.onChange(of: isBeating) { isBeating in
if isBeating {
startAnimation()
} else {
stopAnimation()
}
}
}
}
private extension SimpleBeatingView {
func startAnimation() {
isBeating = true
withAnimation(Animation.linear(duration: beatLength * 0.25)) {
heartState = .large
}
DispatchQueue.main.asyncAfter(deadline: .now() + beatLength * 0.25) {
withAnimation(Animation.linear(duration: beatLength * 0.5)) {
heartState = .small
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + beatLength * 0.75) {
withAnimation(Animation.linear(duration: beatLength * 0.25)) {
heartState = .normal
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + beatLength + beatDelay) {
withAnimation {
if isBeating {
startAnimation()
}
}
}
}
func stopAnimation() {
isBeating = false
}
}
enum HeartState {
case small, normal, large
var scale: CGFloat {
switch self {
case .small: return 0.5
case .normal: return 0.75
case .large: return 1
}
}
}

Before pawello2222 answered, I was still experimenting with trying to get this working and I came up with a solution that uses Timer publishers and the Combine framework.
I've included the code below, but it is not a good solution because every time currentBPM changes a new delay is added before the next animation starts. pawello2222's answer was better because it always allows the current beating animation to finish then starts the next one at the updated rate for the next cycle.
Also, I think my answer here is not as good because a lot of the animation work is done in the data store object rather than being encapsulated in the view, where it probably makes more sense.
import SwiftUI
import Combine
class DataStore: ObservableObject {
#Published var shouldBeSmall: Bool = false
#Published var currentBPM: Int = 0
private var cancellables = Set<AnyCancellable>()
init() {
let newLengthPublisher =
$currentBPM
.map { 60 / Double($0) }
.share()
newLengthPublisher
.delay(for: .seconds(0.2),
scheduler: RunLoop.main)
.map { beatLength in
return Timer.publish(every: beatLength,
on: .main,
in: .common)
.autoconnect()
}
.switchToLatest()
.sink { timer in
self.shouldBeSmall = false
}
.store(in: &cancellables)
newLengthPublisher
.map { beatLength in
return Timer.publish(every: beatLength,
on: .main,
in: .common)
.autoconnect()
}
.switchToLatest()
.sink { timer in
self.shouldBeSmall = true
}
.store(in: &cancellables)
currentBPM = 75
}
}
struct ContentView: View {
#ObservedObject var store = DataStore()
private let minScale: CGFloat = 0.8
var body: some View {
HStack {
Image(systemName: "heart.fill")
.font(.largeTitle)
.foregroundColor(.red)
.scaleEffect(store.shouldBeSmall ? 1 : minScale)
.animation(.easeIn)
Text("\(store.currentBPM)")
.font(.largeTitle)
.fontWeight(.bold)
}
}
}

Related

NSTimer interfering with user clicks

I am trying to update a SwiftUI Image very frequently. That image view is clickable and gets a highlight if selected.
When using an NSTimer with a short interval (0.25 seconds) for the image update, my SwiftUI view does not respond properly to user clicks anymore - clicks are only caught intermittently. If I set the timer interval to 1 second, things would work fine, however, that's not possible in my specific situation.
How can I ensure that my SwiftUI Image's onTapGesture works smoothly even with a high timer frequency?
The timer is declared as such:
let timer = Timer(timeInterval: 0.5, repeats: true, block: { [weak self] timer in
guard let strongSelf = self else {
timer.invalidate()
return
}
// updating an observable object here which will be propagated to the ScreenElement view below
})
timer.tolerance = 0.2
RunLoop.current.add(timer, forMode: .common)
Then I have the SwiftUI view declared as such:
struct ScreenElement: View {
var body: some View {
VStack(alignment: .center, spacing: 12)
{
Image(nsImage: screen.imageData)
.resizable()
.aspectRatio(174/105, contentMode: .fit)
.background(Asset.gray900.swiftUIColor)
.cornerRadius(12)
Text(screen.name)
}
.padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8))
.onTapGesture {
// modify data source and mark this current element as the highlighted (selected) one
}
}
}
What I have tried:
I tried to move the timer to a background thread which didn't really work and/or caused more problems than it solved. Additionally, I tried to increase the timer interval, which, however, is not feasible in my use case since it has to be a very high refresh rate.
Some further considerations I had but couldn't answer:
Is it maybe possible that SwiftUI just doesn't support a frequent refresh of 4x per second? Or did I maybe use the wrong UI Element to handle the tap gesture in my particular case? Or is it just not possible to have a timer with such frequent updates since it overloads the main thread?
Any help will be greatly appreciated!
The following works fine on an iPhone and Mac, all taps are recognised and it's updating at 10 times per second:
struct ContentView: View {
let imageNames = ["eraser", "lasso.and.sparkles", "folder.badge.minus", "externaldrive.badge.xmark", "calendar.badge.plus", "arrowshape.zigzag.forward", "newspaper.circle", "shareplay", "person.crop.square.filled.and.at.rectangle.fill"]
#State private var currentImageNames: [String] = ["", "", ""]
#State private var selected: Int?
var body: some View {
VStack {
HStack {
ForEach(0..<3, id: \.self) { index in
Image(systemName: currentImageNames[index])
.imageScale(.large)
.frame(width: 80, height: 60)
.contentShape(Rectangle())
.onTapGesture {
selected = index
}
.background(
Rectangle()
.fill(selected == index ? .red : .blue)
)
}
}
}
.onAppear {
addTimer()
}
.padding()
}
func addTimer() {
let timer = Timer(timeInterval: 0.1, repeats: true) { timer in
currentImageNames = [imageNames.randomElement()!, imageNames.randomElement()!, imageNames.randomElement()!]
}
RunLoop.current.add(timer, forMode: .common)
}
}
Is there something else you're possibly doing that is blocking the main queue?

Animate bubble with SwiftUI

I want to make a mini game for kids that have Alphabet characters floating inside a bubble so I used text with overlay image and it's working but the problem is when I tap on the bubble I don't know how to make it disappear with animation and make the text stop on current location
struct ExtractedView: View {
#State var BubbleAnimate = false
#State var AlphbetArray: [String] = ["A", "B","C","D","E","F","G"]
#State var randomNumber = Int.random(in: 0...6)
var body: some View {
Button {
BubbleAnimate = true
print("Hi")
} label: {
Text("Test Me")
}
.offset(x:100)
Text(AlphbetArray[randomNumber])
.font(.system(size: 120))
.fontWeight(.bold)
.foregroundColor(.purple)
.padding()
.overlay {
Image("bubble").resizable().frame(width: 130.0, height: 130.0).scaledToFit()
}
.animation(.linear(duration: 5).repeatForever(autoreverses: false), value: BubbleAnimate)
.offset(y:BubbleAnimate ? -300 : 300)
.onTapGesture {
print("Hello")
BubbleAnimate.toggle()
// I tried toggle but it won't work probably
}
}
}
This is the result how can I make the bubble disappear with animation and make the letter stay for 1 second period to make more animation on it like scaling etc...
There are a few considerations:
The letter will never stop in a specific place, given that it has only two positions: offset 300, or offset -300. To make it stop somewhere, the offset must have intermediate values. Solution: use a timer that modifies the offset every n milliseconds.
The bubble is always shown. To make it disappear, you must have a condition and a specific variable to trigger its visibility. Solution: create a variable that conditionally shows the bubble, and animate it using the .transition() modifier.
Variables must start with lower case letter. Just use the convention, it makes it easier to understand the code.
Here's the code:
// Don't use binary values for the offset, use absolute values
#State private var bubbleOffset = 0.0
#State private var alphabetArray: [String] = ["A", "B","C","D","E","F","G"]
#State private var randomNumber = Int.random(in: 0...6)
// Trigger the visibility of the bubble separately from the animation
#State private var showBubble = false
let maxOffset = 300.0
let minOffset = -300.0
// Use a timer to change the offset
let timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
Button {
// The button starts the animation; in this case,
// the animation happens only when the bubble is
// visible, but it does not have to be like that
showBubble = true
print("Hi")
} label: {
Text("Test Me")
}
.offset(x:100)
Text(alphabetArray[randomNumber])
.font(.system(size: 120))
.fontWeight(.bold)
.foregroundColor(.purple)
.padding()
.overlay {
// Show the bubble conditionally
if showBubble {
Image(systemName: "bubble.left").resizable().frame(width: 130.0, height: 130.0).scaledToFit()
// This transition will animate the disappearance of the bubble
.transition(.opacity)
}
}
// Absolute offset
.offset(y: bubbleOffset)
.onTapGesture {
withAnimation {
print("Hello")
// Trigger the disappearance of the bubble
showBubble = false
}
}
.onReceive(timer) { _ in
// In this case,
// the animation happens only when the bubble is
// visible, but it does not have to be like that
if showBubble {
// Change the offset at every emission of a new time
bubbleOffset -= 1
// Handle out-of-bounds
if bubbleOffset < minOffset {
bubbleOffset = maxOffset
}
}
}
}
}

How to stop animation in the current state in SwiftUI

Hello i wrote a simple code where i created an animation with a Circle which changes its position. I want the animation to stop when i tap on that Circle, but it doesn't stop, no matter that i set the animation to nil. Maybe my approach is wrong in some way. I will be happy if someone helps.
struct ContentView: View {
#State private var enabled = true
#State private var offset = 0.0
var body: some View {
Circle()
.frame(width: 100, height: 100)
.offset(y: CGFloat(self.offset))
.onAppear {
withAnimation(self.enabled ? .easeIn(duration: 5) : nil) {
self.offset = Double(-UIScreen.main.bounds.size.height + 300)
}
}
.onTapGesture {
self.enabled = false
}
}
}
As #Schottky stated above, the offset value has already been set, SwiftUI is just animating the changes.
The problem is that when you tap the circle, onAppear is not called again which means enabled is not checked at all and even if it was it wouldn’t stop the offset from animating.
To solve this, we can introduce a Timer and do some small calculations, iOS comes with a built-in Timer class that lets us run code on a regular basis. So the code would look like this:
struct ContentView: View {
#State private var offset = 0.0
// 1
let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
#State var animationDuration: Double = 5.0
var body: some View {
Circle()
.frame(width: 100, height: 100)
.offset(y: CGFloat(self.offset))
.onTapGesture {
// 2
timer.upstream.connect().cancel()
}
.onReceive(timer) { _ in
// 3
animationDuration -= 0.1
if animationDuration <= 0.0 {
timer.upstream.connect().cancel()
} else {
withAnimation(.easeIn) {
self.offset += Double(-UIScreen.main.bounds.size.height + 300)/50
}
}
}
}
}
This creates a timer publisher which asks the timer to fire every 0.1 seconds. It says the timer should run on the main thread, It should run on the common run loop, which is the one you’ll want to use most of the time. (Run loops lets iOS handle running code while the user is actively doing something, such as scrolling in a list or tapping a button.)
When you tap the circle, the timer is automatically cancelled.
We need to catch the announcements (publications) by hand using a new modifier called onReceive(). This accepts a publisher as its first parameter (in this came it's our timer) and a function to run as its second, and it will make sure that function is called whenever the publisher sends its change notification (in this case, every 0.1 seconds).
After every 0.1 seconds, we reduce the duration of our animation, when the duration is over (duration = 0.0), we stop the timer otherwise we keep decreasing the offset.
Check out triggering-events-repeatedly-using-a-timer to learn more about Timer
After going through many things, I found out something that works for me. At the least for the time being and till I have time to figure out a better way.
struct WiggleAnimation<Content: View>: View {
var content: Content
#Binding var animate: Bool
#State private var wave = true
var body: some View {
ZStack {
content
if animate {
Image(systemName: "minus.circle.fill")
.foregroundColor(Color(.systemGray))
.offset(x: -25, y: -25)
}
}
.id(animate) //THIS IS THE MAGIC
.onChange(of: animate) { newValue in
if newValue {
let baseAnimation = Animation.linear(duration: 0.15)
withAnimation(baseAnimation.repeatForever(autoreverses: true)) {
wave.toggle()
}
}
}
.rotationEffect(.degrees(animate ? (wave ? 2.5 : -2.5) : 0.0),
anchor: .center)
}
init(animate: Binding<Bool>,
#ViewBuilder content: #escaping () -> Content) {
self.content = content()
self._animate = animate
}
}
Use
#State private var editMode = false
WiggleAnimation(animate: $editMode) {
VStack {
Image(systemName: image)
.resizable()
.frame(width: UIScreen.screenWidth * 0.1,
height: UIScreen.screenWidth * 0.1)
.padding()
.foregroundColor(.white)
.background(.gray)
Text(text)
.multilineTextAlignment(.center)
.font(KMFont.tiny)
.foregroundColor(.black)
}
}
What do you have to change?
make an offset as #Binding, provide from outside
How does it work?
.id(animate) modifier here does not refresh the view but just replaces it with a new one, and since you have offset as binding property, replaced view will have the correct offset.
Again this might not be the best solution but it works for my case.
What you need to understand is that only something that has already happened is being animated. It just takes longer to show. So in other words, when you set your offset to some value, it is going to animate towards that value, no matter what. What you, therefore, need to do is to set the offset to zero when tapping on the circle. The animation system is smart enough to deal with the 'conflicting' animations and cancel the previous one.
Also, a quick tip: You can do var offset: CGFloat = 0.0 so you don't have to cast it in the onAppear modifier

Get offset of element while moving using animation

How can i get the Y offset of a a moving element at the same time while it's moving?
This is the code that I'm tring the run:
import SwiftUI
struct testView: View {
#State var showPopup: Bool = false
var body: some View {
ZStack {
VStack {
Button(action: {
self.showPopup.toggle()
}) {
Text("show popup")
}
Color.black
.frame(width: 200, height: 200)
.offset(y: showPopup ? 0 : UIScreen.main.bounds.height)
.animation(.easeInOut)
}
}
}
}
struct testView_Previews: PreviewProvider {
static var previews: some View {
testView()
}
}
I want to get the Y value of the black squar when the button is clicked the squar will move to a 0 position however I want to detect when the squar did reach the 0 value how can i do that?
Default animation duration (for those animations which do not have explicit duration parameter) is usually 0.25-0.35 (independently of where it is started & platform), so in your case it is completely safe (tested with Xcode 11.4 / iOS 13.4) to use the following approach:
withAnimation(.spring()){
self.offset = .zero
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.animationRunning = false
}
}
SwiftUI doesn't provide a callback with an animation completion, so there are two methods to accomplish detecting when the square has finished animating.
Method 1: Using AnimatableModifier. Here's a well-written Stack Overflow post on how to set that up.
Method 2: Using a Timer to run after the animation has completed. The idea here is to create a scheduled timer that runs after the timer has finished.
struct ContentView: View {
#State var showPopup: Bool = false
var body: some View {
ZStack {
VStack {
Button(action: {
// add addition here with specified duration
withAnimation(.easeInOut(duration: 1), {
self.showPopup.toggle()
})
// set timer to run after the animation's specified duration
_ = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { timer in
// run your completion code here eg.
withAnimation(.easeInOut(duration: 1)) {
self.showPopup.toggle()
}
timer.invalidate()
}
}) {
Text("show popup")
}
Color.black
.frame(width: 200, height: 200)
.offset(y: showPopup ? 0 : UIScreen.main.bounds.height)
}
}
}
}

SwiftUI run code periodically while button is being held down; run different code when it is just tapped?

What I'm trying to do is implement a button that runs a certain line of code every 0.5 seconds while it is being held down (it can be held down indefinitely and thus run the print statement indefinitely). I'd like it to have a different behavior when it is tapped. Here is the code:
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
}
)
}
}
}
At the moment, it's sort of reverse of what I need it to do; the code above runs the print statement indefinitely when I click the button once but when I hold it down, it only does the execution once...how can I fix this?
Here is a solution - to get continuous pressing it needs to combine long press gesture with sequenced drag and add timer in handlers.
Updated: Tested with Xcode 11.4 / iOS 13.4 (in Preview & Simulator)
struct TimeEventGeneratorView: View {
var callback: () -> Void
private let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
var body: some View {
Color.clear
.onReceive(self.timer) { _ in
self.callback()
}
}
}
struct TestContinuousPress: View {
#GestureState var pressingState = false // will be true till tap hold
var pressingGesture: some Gesture {
LongPressGesture(minimumDuration: 0.5).sequenced(before:
DragGesture(minimumDistance: 0, coordinateSpace:
.local)).updating($pressingState) { value, state, transaction in
switch value {
case .second(true, nil):
state = true
default:
break
}
}.onEnded { _ in
}
}
var body: some View {
VStack {
Image(systemName: "chevron.left")
.background(Group { if self.pressingState { TimeEventGeneratorView {
print(">>>> pressing: \(Date())")
}}})
.gesture(TapGesture().onEnded {
print("> just tap ")
})
.gesture(pressingGesture)
}
}
}