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

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

Related

How to allow timer to count down in background on Watch OS

Made a pretty simple timer app, but I've found that Apple Watch documentation is no where near as good as the phone. It's been difficult to find any answers on this.
Here is my content view. The seconds for the timer is kept in '''timeVal'''
struct ContentView: View {
#State var timeVal = 0.0
#State var timerScreenShow:Bool = false
var body: some View {
VStack {
HStack{
Spacer()
Text("\(self.timeVal.hourMinute)")
Spacer()
}
//various buttons to set timer
NavigationLink(
destination: TimerView(timerScreenShow: self.$timerScreenShow, timeVal: Double(Int(self.timeVal)), initialTime: Int(self.timeVal)),
isActive: $timerScreenShow,
label: {
Text("Start")
}).background(Color.green).cornerRadius(22)
}
}
}
Then, here is the bit that keeps time:
struct TimerView: View {
#Binding var timerScreenShow:Bool
#State var timeVal:Double
let initialTime:Int
var body: some View {
if timeVal > -1 {
VStack {
ZStack {
Text("\(self.timeVal.hourMinuteSecond)").font(.system(size: 20))
.onAppear() {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
if self.timeVal > -1 {
self.timeVal -= 1
}
}
}
//Text("\(self.timeVal.hourMinuteSecond)")
ProgressBar(progress: Int(self.timeVal), initial: self.initialTime).frame(width: 90.0, height: 90.0)
}
Button(action: {
self.timerScreenShow = false
}, label: {
Text("Cancel")
.foregroundColor(Color.red)
})
.padding(.top)
}
} else {
Button(action: {
self.timerScreenShow = false
}, label: {
Text("Done!")
.foregroundColor(Color.green)
}).onAppear() {
WKInterfaceDevice.current().play(.notification)
}
}
}
}
What do I need to do to keep the timer running when the watch app is closed?
Don't keep track of how much time is left on the counter. Instead, store the startTime = Date(). Use the timer only as a signal to update the UI. When the timer ticks, compute the time left as startAmount - (Date().timeInterval(since: startTime)). Store startAmount and startTime and Bool isTimerRunning in persistent storage such as #AppStorage. When the app restarts, pick up where you left off.

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

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

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

How to dynamically hide navigation back button in SwiftUI

I need to temporarily hide the Back Button in a view during an asynchronous operation.
I want to prevent user from leaving the view before the operation completes.
It's possible to hide it permanently using .navigationBarBackButtonHidden(true).
But, then obviously user can't go back in this case, so they are stuck.
What am I missing?
Here is a contrived example to demonstrate:
struct TimerTest: View {
#State var isTimerRunning = false
var body: some View {
Button(action:self.startTimer) {
Text("Start Timer")
}
.navigationBarBackButtonHidden(isTimerRunning)
//.navigationBarBackButtonHidden(true) // This does hide it, but then it can't be unhidden.
}
func startTimer()
{
self.isTimerRunning = true
_ = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { timer in
print("Timer fired!")
self.isTimerRunning = false
}
}
}
Here is working solution. Back button cannot be hidden, it is managed by bar and owned by parent view, however it is possible to hide entire navigation bar with below approach.
Tested with Xcode 11.4 / iOS 13.4
struct ParentView: View {
#State var isTimerRunning = false
var body: some View {
NavigationView {
VStack {
NavigationLink("Go", destination: TimerTest(isTimerRunning: $isTimerRunning))
}
.navigationBarHidden(isTimerRunning)
.navigationBarTitle("Main") // << required, at least empty !!
}
}
}
struct TimerTest: View {
#Binding var isTimerRunning: Bool
var body: some View {
Button(action:self.startTimer) {
Text("Start Timer")
}
}
func startTimer()
{
self.isTimerRunning = true
_ = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { timer in
DispatchQueue.main.async { // << required !!
self.isTimerRunning = false
}
}
}
}

View not refreshed when .onAppear runs SwiftUI

I have some code inside .onAppear and it runs, the problem is that I have to go to another view on the menu and then come back for the UI view to refresh. The code is kind of lengthy, but the main components are below, where mealData comes from CoreData and has some objects:
Go to the updated comment at the bottom for a simpler code example
VStack(alignment: .leading) {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 2) {
ForEach(mealData, id: \.self) { meal in
VStack(alignment: .leading) { ... }
.onAppear {
// If not first button and count < total amt of objects
if((self.settingsData.count != 0) && (self.settingsData.count < self.mealData.count)){
let updateSettings = Settings(context: self.managedObjectContext)
// If will_eat time isn't nill and it's time is overdue and meal status isn't done
if ((meal.will_eat != nil) && (IsItOverDue(date: meal.will_eat!) == true) && (meal.status! != "done")){
self.mealData[self.settingsData.count].status = "overdue"
print(self.mealData[self.settingsData.count])
if(self.settingsData.count != self.mealData.count-1) {
// "Breakfast": "done" = active - Add active to next meal
self.mealData[self.settingsData.count+1].status = "active"
}
updateSettings.count += 1
if self.managedObjectContext.hasChanges {
// Save the context whenever is appropriate
do {
try self.managedObjectContext.save()
} catch let error as NSError {
print("Error loading: \(error.localizedDescription), \(error.userInfo)")
}
}
}
}
}
}
}
}
}
Most likely since the UI is not refreshing automatically I'm doing something wrong, but what?
UPDATE:
I made a little example replicating what's going on, if you run it, and click on set future date, and wit 5 seconds, you'll see that the box hasn't changed color, after that, click on Go to view 2 and go back to view 1 and you'll see how the box color changes... that's what's happening above too:
import SwiftUI
struct ContentView: View {
#State var past = Date()
#State var futuredate = Date()
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView())
{ Text("Go to view 2") }
Button("set future date") {
self.futuredate = self.past.addingTimeInterval(5)
}
VStack {
if (past < futuredate) {
Button(action: {
}) {
Text("")
}
.padding()
.background(Color.blue)
} else {
Button(action: {
}) {
Text("")
}
.padding()
.background(Color.black)
}
}
}
.onAppear {
self.past = Date()
}
}
}
}
struct DetailView: View {
#Environment(\.presentationMode) var presentationMode: Binding
var body: some View {
Text("View 2")
}
}
You need to understand that all you put in body{...} is just an instruction how to display this view. In runtime system creates this View using body closure, gets it in memory like picture and pin this picture to struct with all the stored properties you define in it. Body is not a stored property. Its only instruction how to create that picture.
In your code, there goes init first. You didn't write it but it runs and sets all the structs property to default values = Date(). After that runs .onAppear closure which changes past value. And only then system runs body closure to make an image to display with new past value. And that's all. It is not recreating itself every second. You need to trigger it to check if the condition past < futuredate changed.
When you go to another view and back you do exactly that - force system to recreate view and that's why it checks condition again.
If you want View to change automatically after some fixed time spend, use
DispatchQueue.main.asyncAfter(deadline: .now() + Double(2)) {
//change #State variable to force View to update
}
for more complex cases you can use Timer like this:
class MyTimer: ObservableObject {
#Published var currentTimePublisher: Timer.TimerPublisher
var cancellable: Cancellable?
let interval: Double
var isOn: Bool{
get{if self.cancellable == nil{
return false
}else{
return true
}
}
set{if newValue == false{
self.stop()
}else{
self.start()
}
}
}
init(interval: Double, autoStart: Bool = true) {
self.interval = interval
let publisher = Timer.TimerPublisher(interval: interval, runLoop: .main, mode: .default)
self.currentTimePublisher = publisher
if autoStart{
self.start()
}
}
func start(){
if self.cancellable == nil{
self.currentTimePublisher = Timer.TimerPublisher(interval: self.interval, runLoop: .main, mode: .default)
self.cancellable = self.currentTimePublisher.connect()
}else{
print("timer is already started")
}
}
func stop(){
if self.cancellable != nil{
self.cancellable!.cancel()
self.cancellable = nil
}else{
print("timer is not started (tried to stop)")
}
}
deinit {
if self.cancellable != nil{
self.cancellable!.cancel()
self.cancellable = nil
}
}
}
struct TimerView: View {
#State var counter: Double
let timerStep: Double
#ObservedObject var timer: TypeTimer
init(timerStep: Double = 0.1, counter: Double = 10.0){
self.timerStep = timerStep
self._counter = State<Double>(initialValue: counter)
self.timer = MyTimer(interval: timerStep, autoStart: false)
}
var body: some View {
Text("\(counter)")
.onReceive(timer.currentTimePublisher) {newTime in
print(newTime.description)
if self.counter > self.timerStep {
self.counter -= self.timerStep
}else{
self.timer.stop()
}
}
.onTapGesture {
if self.timer.isOn {
self.timer.stop()
}else{
self.timer.start()
}
}
}
}
See the idea? You start timer and it will force View to update by sending notification with the Publisher. View gets that notifications with .onReceive and changes #State variable counter and that cause View to update itself. You need to do something like that.