I have two views. The Main View and a Break View. I have a timer running in the Main View which counts down to zero. When the timer reaches zero, I want to be able to switch the screen to Break View. I am using MVVM to keep track of the timers. Using .onReceive to make it look like the timer is running in the background.
I tried using a boolean to check if the timer has reached zero and based on that changed the view, but it's not working and is giving an error saying the result of the view is not used anywhere. I have a navigation view in the Content View if that's of any help.
Thanks in advance.
A snippet of the code :
Main View :
struct MainView: View {
var body: some View {
VStack(alignment: .center, spacing: 50, content: {
Button(action: {
if !fbManager.isTimerStarted {
fbManager.start()
fbManager.isTimerStarted = true
}
else {
fbManager.pause()
fbManager.isTimerStarted = false
}
}, label: {
Image(systemName: fbManager.isTimerStarted == true ? "pause.fill" : "play.fill")
.resizable()
.scaledToFit()
.frame(width: 50, height: 50)
.foregroundColor(Color(red: 1.00, green: 1.00, blue: 1.00))
})
.onReceive(NotificationCenter.default.publisher(
for: UIScene.didEnterBackgroundNotification)) { _ in
if fbManager.isTimerStarted {
movingToBackground()
}
}
.onReceive(NotificationCenter.default.publisher(
for: UIScene.willEnterForegroundNotification)) { _ in
if fbManager.isTimerStarted {
movingToForeground()
}
}
})
}
}
func movingToBackground() {
print("Moving to the background")
notificationDate = Date()
fbManager.pause()
}
func movingToForeground() {
print("Moving to the foreground")
let deltaTime: Int = Int(Date().timeIntervalSince(notificationDate))
fbManager.secondsElapsed -= deltaTime
fbManager.start()
}
}
View Model :
class FocusBreakManager: ObservableObject {
var timer: Timer = Timer()
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [self] _ in
self.secondsElapsed -= 1
self.focusfill += 0.01667
focusTime = String(secondsElapsed)
focusTime = formatCounter()
if secondsElapsed <= 0 {
stop()
}
}
}
func formatCounter() -> String {
let minutes = Int(secondsElapsed) / 60 % 60
let seconds = Int(secondsElapsed) % 60
return String(format : "%02i : %02i", minutes, seconds)
}
}
Hey to keep up with your solution here is an example of how that could work you would need to use #ObservedObject property wrapper in order to monitor updates from your view.
struct ContentView: View {
#ObservedObject private var focusBreakManager = FocusBreakManager()
var body: some View {
VStack {
Text("\(focusBreakManager.elapsedSeconds)")
Text(focusBreakManager.timerRunningMessage)
Button("Start timer", action: focusBreakManager.start)
}
.padding()
}
}
class FocusBreakManager: ObservableObject {
var timer: Timer = Timer()
#Published var elapsedSeconds = 0
var timerRunningMessage: String {
timerRunning
? "Timer is running"
: "Timer paused"
}
private var timerRunning: Bool {
timer.isValid
}
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
self.elapsedSeconds += 1
if self.elapsedSeconds > 5 {
self.timer.invalidate()
}
}
}
}
You can also take a look at the autoconnect api here's a great tutorial:
https://www.hackingwithswift.com/books/ios-swiftui/triggering-events-repeatedly-using-a-timer
Related
I have the following simple SwiftUI setup. A timer that is running and updating a Text. If the timer is not running (stopped or paused) I can easily show an ActionSheet (by tapping on Actions) and dismiss it by choosing either "Cancel" or "Action 1" option. But if the timer is running, I have a really hard time dismissing the ActionSheet by choosing one of the "Cancel" or "Action 1" options. Do you know what's going on?
I am using Xcode 11.5.
import SwiftUI
struct ContentView: View {
#ObservedObject var stopWatch = StopWatch()
#State private var showActionSheet: Bool = false
var body: some View {
VStack {
Text("\(stopWatch.secondsElapsed)")
HStack {
if stopWatch.mode == .stopped {
Button(action: { self.stopWatch.start() }) {
Text("Start")
}
} else if stopWatch.mode == .paused {
Button(action: { self.stopWatch.start() }) {
Text("Resume")
}
} else if stopWatch.mode == .running {
Button(action: { self.stopWatch.pause() }) {
Text("Pause")
}
}
Button(action: { self.stopWatch.stop() }) {
Text("Reset")
}
}
Button(action: { self.showActionSheet = true }) {
Text("Actions")
}
.actionSheet(isPresented: $showActionSheet) {
ActionSheet(title: Text("Actions"), message: nil, buttons: [.default(Text("Action 1")), .cancel()])
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
import SwiftUI
class StopWatch: ObservableObject {
#Published var secondsElapsed: TimeInterval = 0.0
#Published var mode: stopWatchMode = .stopped
var timer = Timer()
func start() {
mode = .running
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
self.secondsElapsed += 0.1
}
}
func stop() {
timer.invalidate()
secondsElapsed = 0
mode = .stopped
}
func pause() {
timer.invalidate()
mode = .paused
}
enum stopWatchMode {
case running
case stopped
case paused
}
}
Works fine with Xcode 12 / iOS 14, but try to separate button with sheet into another subview to avoid recreate it on timer counter refresh.
Tested with Xcode 12 / iOS 14
struct ContentView: View {
#ObservedObject var stopWatch = StopWatch()
// #StateObject var stopWatch = StopWatch() // << used for SwiftUI 2.0
#State private var showActionSheet: Bool = false
var body: some View {
VStack {
Text("\(stopWatch.secondsElapsed)")
HStack {
if stopWatch.mode == .stopped {
Button(action: { self.stopWatch.start() }) {
Text("Start")
}
} else if stopWatch.mode == .paused {
Button(action: { self.stopWatch.start() }) {
Text("Resume")
}
} else if stopWatch.mode == .running {
Button(action: { self.stopWatch.pause() }) {
Text("Pause")
}
}
Button(action: { self.stopWatch.stop() }) {
Text("Reset")
}
}
ActionsSubView(showActionSheet: $showActionSheet)
}
}
}
struct ActionsSubView: View {
#Binding var showActionSheet: Bool
var body: some View {
Button(action: { self.showActionSheet = true }) {
Text("Actions")
}
.actionSheet(isPresented: $showActionSheet) {
ActionSheet(title: Text("Actions"), message: nil, buttons: [.default(Text("Action 1")), .cancel()])
}
}
}
I am curious if there is a way to get the timers to update with the UI (as they are now) even while someone is scrolling. Additionally, I want to make sure that as the UI updates each second the screen does not freeze. This question has been updated in response to the helpful answers I received previously.
struct ContentView: View {
var body: some View {
NavigationView{
NavigationLink(destination: ScrollTest()){
HStack {
Text("Next Screen")
}
}
}
}
}
struct ScrollTest: View {
#ObservedObject var timer = SectionTimer(duration: 60)
var body: some View {
HStack{
List{
Section(header: TimerNavigationView(timer: timer)){
ForEach((1...50).reversed(), id: \.self) {
Text("\($0)…").onAppear(){
self.timer.startTimer()
}
}
}
}.navigationBarItems(trailing: TimerNavigationView(timer: timer))
}
}
}
struct TimerNavigationView: View {
#ObservedObject var timer: SectionTimer
var body: some View{
HStack {
Text("\(timer.timeLeftFormatted) left")
Spacer()
}
}
}
class SectionTimer:ObservableObject {
private var endDate: Date
private var timer: Timer?
var timeRemaining: Double {
didSet {
self.setRemaining()
}
}
#Published var timeLeftFormatted = ""
init(duration: Int) {
self.timeRemaining = Double(duration)
self.endDate = Date().advanced(by: Double(duration))
self.startTimer()
}
func startTimer() {
guard self.timer == nil else {
return
}
self.timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { (timer) in
self.timeRemaining = self.endDate.timeIntervalSince(Date())
if self.timeRemaining < 0 {
timer.invalidate()
self.timer = nil
}
})
}
private func setRemaining() {
let min = max(floor(self.timeRemaining / 60),0)
let sec = max(floor((self.timeRemaining - min*60).truncatingRemainder(dividingBy:60)),0)
self.timeLeftFormatted = "\(Int(min)):\(Int(sec))"
}
func endTimer() {
self.timer?.invalidate()
self.timer = nil
}
}
Although SwiftUI has a timer, I don't think using it is the right approach in this case.
Your model should be handling the timing for you.
It also helps if your view observes its model object directly rather than trying to observe a member of an array in a property of your observable.
You didn't show your SectionTimer, but this is what I created:
class SectionTimer:ObservableObject {
private var endDate: Date
private var timer: Timer?
var timeRemaining: Double {
didSet {
self.setRemaining()
}
}
#Published var timeLeftFormatted = ""
init(duration: Int) {
self.timeRemaining = Double(duration)
self.endDate = Date().advanced(by: Double(duration))
self.startTimer()
}
func startTimer() {
guard self.timer == nil else {
return
}
self.timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { (timer) in
self.timeRemaining = self.endDate.timeIntervalSince(Date())
if self.timeRemaining < 0 {
timer.invalidate()
self.timer = nil
}
})
}
private func setRemaining() {
let min = max(floor(self.timeRemaining / 60),0)
let sec = max(floor((self.timeRemaining - min*60).truncatingRemainder(dividingBy:60)),0)
self.timeLeftFormatted = "\(Int(min)):\(Int(sec))"
}
func endTimer() {
self.timer?.invalidate()
self.timer = nil
}
}
It uses a Date rather than subtracting from a remaining counter; this is more accurate as timer's don't tick at precise intervals. It updates a timeLeftFormatted published property.
To use it I made the following changes to your TimerNavigationView -
struct TimerNavigationView: View {
#ObservedObject var timer: SectionTimer
var body: some View{
HStack {
Text("\(timer.timeLeftFormatted) left")
Spacer()
}
}
}
You can see how putting the timer in the model vastly simplifies your view.
You would use it via .navigationBarItems(trailing: TimerNavigationView(timer: self.test.timers[self.test.currentSection]))
Update
The updated code in the question helped demonstrate the issue, and I found the solution in this answer
When the scrollview is scrolling the mode of the current RunLoop changes and the timer is not triggered.
The solution is to schedule the timer in the common mode yourself rather than relying on the default mode that you get with scheduledTimer -
func startTimer() {
guard self.timer == nil else {
return
}
self.timer = Timer(timeInterval: 0.2, repeats: true) { (timer) in
self.timeRemaining = self.endDate.timeIntervalSince(Date())
if self.timeRemaining < 0 {
timer.invalidate()
self.timer = nil
}
}
RunLoop.current.add(self.timer!, forMode: .common)
}
I'm trying to manage a chat with SwiftUI.
I found a CircularProgressBar to work with SwiftUI, in the example, it's working with Timer.
If I change the timer with an Zip extraction, the UI doesn't update.
struct DetailView: View {
var selectedChat: Chat?
#State var progressBarValue:CGFloat = 0
var body: some View {
Group {
if selectedChat != nil {
VStack {
if progressBarValue < 1 {
CircularProgressBar(value: $progressBarValue)
}
else {
//Text("WELL DONE")
Text("\(UserData().getChat(withID: selectedChat!.id)!.allText.first!.text)")
}
}
} else {
VStack {
CircularProgressBar(value: $progressBarValue)
Text("Detail view content goes here")
}
}
}.navigationBarTitle(Text("\(selectedChat?.name ?? "")"))
.onAppear {
if let chat = self.selectedChat {
if chat.allText.count == 0 {
let exData = ExtractData()
if let path = chat.getUnzipPath()?.relativePath {
DispatchQueue.main.async {//with or without the behavior is the same
exData.manageExtractedZip(unzipPath: path) { progress in
if progress >= 1 {
var newChat = chat
newChat.allText = exData.allTexts
let userD = UserData()
userD.overrideChat(with: newChat)
print(exData.allTexts)
}
self.progressBarValue = CGFloat(progress)
print("progressBarValue: \(self.progressBarValue)") //This is printing well
}
}
}
}
else {
self.progressBarValue = 1
}
}
/* This is working
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
self.progressBarValue += 0.1
print(self.progressBarValue)
if (self.progressBarValue >= 1) {
timer.invalidate()
}
}*/
}
}
}
struct DetailView_Previews: PreviewProvider {
static var previews: some View {
DetailView(selectedChat: UserData().chatData.first!)
}
}
How to make it work?
I'd like to implement a simple view transition through SwiftUI and Timer.
I have a primary View, it's content View. If I call func FireTimer() from in the View, the function fires timer. Then after 5 seconds, I would have a View transition.
I tried NavigationLink, but it has a button. Timer can't push the button so now I'm confused.
I'll show my code below.
TimerFire.swift
import Foundation
import UIKit
import SwiftUI
let TIME_MOVENEXT = 5
var timerCount : Int = 0
class TimerFire : ObservableObject{
var workingTimer = Timer()
#objc func FireTimer() {
print("FireTimer")
workingTimer = Timer.scheduledTimer(timeInterval: 1,
target: self,
selector: #selector(TimerFire.timerUpdate),
userInfo: nil,
repeats: true)
}
#objc func timerUpdate(timeCount: Int) {
timerCount += 1
let timerText = "timerCount:\(timerCount)"
print(timerText)
if timerCount == TIME_MOVENEXT {
print("timerCount == TIME_MOVENEXT")
workingTimer.invalidate()
print("workingTimer.invalidate()")
timerCount = 0
//
//want to have a transition to SecondView here
//
}
}
}
ContentView.swift
import SwiftUI
struct ContentView: View {
var body: some View {
Button(action: {
// What to perform
let timerFire = TimerFire()
timerFire.FireTimer()
}) {
// How the button looks like
Text("Fire timer")
}
}
}
SecondView.swift
import Foundation
import SwiftUI
struct SecondView: View {
var body: some View {
Text("Second World")
}
}
How could I simply show this SecondView?
Ok, if you want to do this w/o NavigationView on first screen (for any reason) here is a possible approach based on transition between two views.
Note: Preview has limited support for transitions, so please test on Simulator & real device
Here is a demo how it looks (initial white screen is just Simulator launch)
Here is single testing module:
import SwiftUI
import UIKit
let TIME_MOVENEXT = 5
var timerCount : Int = 0
class TimerFire : ObservableObject{
var workingTimer = Timer()
#Published var completed = false
#objc func FireTimer() {
print("FireTimer")
workingTimer = Timer.scheduledTimer(timeInterval: 1,
target: self,
selector: #selector(TimerFire.timerUpdate),
userInfo: nil,
repeats: true)
}
#objc func timerUpdate(timeCount: Int) {
timerCount += 1
let timerText = "timerCount:\(timerCount)"
print(timerText)
if timerCount == TIME_MOVENEXT {
print("timerCount == TIME_MOVENEXT")
workingTimer.invalidate()
print("workingTimer.invalidate()")
timerCount = 0
//
//want to have a transition to SecondView here
//
self.completed = true
}
}
}
struct SecondView: View {
var body: some View {
Text("Second World")
}
}
struct TestTransitionByTimer: View {
#ObservedObject var timer = TimerFire()
#State var showDefault = true
var body: some View {
ZStack {
Rectangle().fill(Color.clear) // << to make ZStack full-screen
if showDefault {
Rectangle().fill(Color.blue) // << just for demo
.overlay(Text("Hello, World!"))
.transition(.move(edge: .leading))
}
if !showDefault {
Rectangle().fill(Color.red) // << just for demo
.overlay(SecondView())
.transition(.move(edge: .trailing))
}
}
.onAppear {
self.timer.FireTimer()
}
.onReceive(timer.$completed, perform: { completed in
withAnimation {
self.showDefault = !completed
}
})
}
}
struct TestTransitionByTimer_Previews: PreviewProvider {
static var previews: some View {
TestTransitionByTimer()
}
}
There is no code snippet for ContentView, so I tried to build simple example by myself. You can use NavigationLink(destination: _, isActive: Binding<Bool>, label: () -> _) in your case. Change some State var while receiving changes from Timer.publish and you'll go to SecondView immediately:
struct TransitionWithTimer: View {
#State var goToSecondWorld = false
let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: SecondWorld(), isActive: self.$goToSecondWorld) {
Text("First World")
.onReceive(timer) { _ in
self.goToSecondWorld = true
}
}
}
}
}
}
// you can use ZStack and opacity/offset of view's:
struct TransitionWithTimerAndOffset: View {
#State var goToSecondWorld = false
let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
Text("First world") // here can be your first View
.opacity(self.goToSecondWorld ? 0 : 1)
.offset(x: goToSecondWorld ? 1000 : 0)
Text("Second world") // and here second world View
.opacity(self.goToSecondWorld ? 1 : 0)
.offset(x: goToSecondWorld ? 0 : -1000)
}
.onReceive(timer) { _ in
withAnimation(.spring()) {
self.goToSecondWorld = true
}
}
}
}
struct SecondWorld: View {
var body: some View {
Text("Second World")
}
}
I'm creating a custom stepper control in SwiftUI, and I'm trying to replicate the accelerating value change behavior of the built-in control. In a SwiftUI Stepper, long pressing on "+" or "-" will keep increasing/decreasing the value with the rate of change getting faster the longer you hold the button.
I can create the visual effect of holding down the button with the following:
struct PressBox: View {
#GestureState var pressed = false
#State var value = 0
var body: some View {
ZStack {
Rectangle()
.fill(pressed ? Color.blue : Color.green)
.frame(width: 70, height: 50)
.gesture(LongPressGesture(minimumDuration: .infinity)
.updating($pressed) { value, state, transaction in
state = value
}
.onChanged { _ in
self.value += 1
}
)
Text("\(value)")
.foregroundColor(.white)
}
}
}
This only increments the value once. Adding a timer publisher to the onChanged modifier for the gesture like this:
let timer = Timer.publish(every: 0.5, on: .main, in: .common)
#State var cancellable: AnyCancellable? = nil
...
.onChanged { _ in
self.cancellable = self.timer.connect() as? AnyCancellable
}
will replicate the changing values, but since the gesture never completes successfully (onEnded will never be called), there's no way to stop the timer. Gestures don't have an onCancelled modifier.
I also tried doing this with a TapGesture which would work for detecting the end of the gesture, but I don't see a way to detect the start of the gesture. This code:
.gesture(TapGesture()
.updating($pressed) { value, state, transaction in
state = value
}
)
generates an error on $pressed:
Cannot convert value of type 'GestureState' to expected argument type 'GestureState<_>'
Is there a way to replicate the behavior without falling back to UIKit?
You'd need an onTouchDown event on the view to start a timer and an onTouchUp event to stop it. SwiftUI doesn't provide a touch down event at the moment, so I think the best way to get what you want is to use the DragGesture this way:
import SwiftUI
class ViewModel: ObservableObject {
private static let updateSpeedThresholds = (maxUpdateSpeed: TimeInterval(0.05), minUpdateSpeed: TimeInterval(0.3))
private static let maxSpeedReachedInNumberOfSeconds = TimeInterval(2.5)
#Published var val: Int = 0
#Published var started = false
private var timer: Timer?
private var currentUpdateSpeed = ViewModel.updateSpeedThresholds.minUpdateSpeed
private var lastValueChangingDate: Date?
private var startDate: Date?
func start() {
if !started {
started = true
val = 0
startDate = Date()
startTimer()
}
}
func stop() {
timer?.invalidate()
currentUpdateSpeed = Self.updateSpeedThresholds.minUpdateSpeed
lastValueChangingDate = nil
started = false
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: Self.updateSpeedThresholds.maxUpdateSpeed, repeats: false) {[unowned self] _ in
self.updateVal()
self.updateSpeed()
self.startTimer()
}
}
private func updateVal() {
if self.lastValueChangingDate == nil || Date().timeIntervalSince(self.lastValueChangingDate!) >= self.currentUpdateSpeed {
self.lastValueChangingDate = Date()
self.val += 1
}
}
private func updateSpeed() {
if self.currentUpdateSpeed < Self.updateSpeedThresholds.maxUpdateSpeed {
return
}
let timePassed = Date().timeIntervalSince(self.startDate!)
self.currentUpdateSpeed = timePassed * (Self.updateSpeedThresholds.maxUpdateSpeed - Self.updateSpeedThresholds.minUpdateSpeed)/Self.maxSpeedReachedInNumberOfSeconds + Self.updateSpeedThresholds.minUpdateSpeed
}
}
struct ContentView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
ZStack {
Rectangle()
.fill(viewModel.started ? Color.blue : Color.green)
.frame(width: 70, height: 50)
.gesture(DragGesture(minimumDistance: 0)
.onChanged { _ in
self.viewModel.start()
}
.onEnded { _ in
self.viewModel.stop()
}
)
Text("\(viewModel.val)")
.foregroundColor(.white)
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(viewModel: ViewModel())
}
}
#endif
Let me know if I got what you wanted or whether I can improve my answer somehow.
For anyone attempting something similar, here's a slightly different take on superpuccio's approach. The api for users of the type is a bit more straightforward, and it minimizes the number of timer fires as the speed ramps up.
struct TimerBox: View {
#Binding var value: Int
#State private var isRunning = false
#State private var startDate: Date? = nil
#State private var timer: Timer? = nil
private static let thresholds = (slow: TimeInterval(0.3), fast: TimeInterval(0.05))
private static let timeToMax = TimeInterval(2.5)
var body: some View {
ZStack {
Rectangle()
.fill(isRunning ? Color.blue : Color.green)
.frame(width: 70, height: 50)
.gesture(DragGesture(minimumDistance: 0)
.onChanged { _ in
self.startRunning()
}
.onEnded { _ in
self.stopRunning()
}
)
Text("\(value)")
.foregroundColor(.white)
}
}
private func startRunning() {
guard isRunning == false else { return }
isRunning = true
startDate = Date()
timer = Timer.scheduledTimer(withTimeInterval: Self.thresholds.slow, repeats: true, block: timerFired)
}
private func timerFired(timer: Timer) {
guard let startDate = self.startDate else { return }
self.value += 1
let timePassed = Date().timeIntervalSince(startDate)
let newSpeed = Self.thresholds.slow - timePassed * (Self.thresholds.slow - Self.thresholds.fast)/Self.timeToMax
let nextFire = Date().advanced(by: max(newSpeed, Self.thresholds.fast))
self.timer?.fireDate = nextFire
}
private func stopRunning() {
timer?.invalidate()
isRunning = false
}
}