Why does my animation only work in a preview? - swift

I have a view representing a timer. It looks like this...
Video with animation...
Video without animation...
As you can see, it has a play/pause button, text for the name, and text for the remaining time. Most importantly, the background represents the percentage of the timer that has elapsed.
When run in a preview, the animation runs smoothly. When run anywhere else, the background jumps to each interval point with no animation.
Here is the background shape…
struct Meter: Shape, Animatable {
init(widthPercentage: CGFloat) {
self.widthPercentage = max(0, widthPercentage)
}
var widthPercentage: CGFloat
var animatableData: CGFloat {
get { widthPercentage }
set { self.widthPercentage = newValue }
}
func path(in rect: CGRect) -> Path {
// create the shape…
}
}
Here is the timer view....
struct TimerRow: View {
#ObservedObject var timer: ReusableTimer
var body: some View {
ZStack(alignment: .leading) {
Meter(widthPercentage: CGFloat(timer.progress))
.foregroundColor(.gray)
.opacity(0.20)
.animation(.linear(duration: 0.01))
HStack {
// Other views...
}
}
}
}
Here is the preview...
struct TimerRow_Previews: PreviewProvider {
static var previews: some View {
TimerRow(timer: ReusableTimer(name: "Timer1",
duration: 10,
timeInterval: 0.01))
.previewLayout(.fixed(width: 400, height: 50))
}
}
Notice that the animation duration in TimerRow is set to match the interval of the timer in the preview.
Here is only relevant parts of ReusableTimer...
class ReusableTimer: ObservableObject, Identifiable {
let id: Int
let name: String
#Published private(set) var counter: TimeInterval = 0
var isRunning: Bool { timer != nil }
private var timer: Timer?
let duration: TimeInterval
private let timeInterval: TimeInterval
func start() {
// ...do some other stuff
timer = Timer(timeInterval: timeInterval, repeats: true) { [weak self] _ in
guard let this = self else { return }
this.counter += this.timeInterval // increment the counter
if this.counter >= this.duration { this.removeTimer() }
}
RunLoop.current.add(timer!, forMode: RunLoop.Mode.default)
}
func pause() {
removeTimer()
}
private func removeTimer() {
// Remove the timer
}
}

90% of the time, I ask the wrong question. My duration gave the appearance that there was no animation taking place, but there was... It was just really fast. So I corrected it to .animation(.linear(duration: 1)).
Why did it look perfect in the preview with the incorrect value? That's still a mystery, but it now works.

Related

Timer within EnvironmentObject view model not updating the View

I have a view model, that has multiple child view models. I am fairly new to watchOS, SwiftUI and Combine - taking this opportunity to learn.
I have a watchUI where it has
Play Button (View) - SetTimerPlayPauseButton
Text to show Time (View) - TimerText
View Model - that has 1 WatchDayProgramViewModel - N: ExerciseTestClass - N: SetInformationTestClass. For each ExerciseSets, there is a watchTimer & watchTimerSubscription and I have managed to run the timer to update remaining rest time.
ContentView - that has been injected the ViewModel as EnvironmentObject
If I tap SetTimerPlayPauseButton to start the timer, timer is running, working and changing the remainingRestTime(property within the child view model SetInformationTestClass) correctly, but the updates/changes are not being "published" to the TimerText View.
I have done most, if not all, the recommendation in other SO answers, I even made all my WatchDayProgramViewModel and ExerciseTestClass,SetInformationTestClass properties #Published, but they are still not updating the View, when the view model properties are updated as shown in the Xcode debugger below.
Please review my code and give me some advice on how to improve it.
ContentView
struct ContentView: View {
#State var selectedTab = 0
#StateObject var watchDayProgramVM = WatchDayProgramViewModel()
var body: some View {
TabView(selection: $selectedTab) {
SetRestDetailView().id(2)
}
.environmentObject(watchDayProgramVM)
.tabViewStyle(PageTabViewStyle())
.indexViewStyle(.page(backgroundDisplayMode: .automatic))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView(watchDayProgramVM: WatchDayProgramViewModel())
}
}
}
SetRestDetailView
import Foundation
import SwiftUI
import Combine
struct SetRestDetailView: View {
#EnvironmentObject var watchDayProgramVM: WatchDayProgramViewModel
var setCurrentHeartRate: Int = 120
#State var showingLog = false
var body: some View {
HStack {
let elapsedRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].elapsedRestTime
let totalRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].totalRestTime
TimerText(elapsedRestTime: elapsedRestTime, totalRestTime: totalRestTime, rect: rect)
.border(Color.yellow)
}
HStack {
SetTimerPlayPauseButton(isSetTimerRunningFlag: false,
playImage: "play.fill",
pauseImage: "pause.fill",
bgColor: Color.clear,
fgColor: Color.white.opacity(0.5),
rect: rect) {
print("playtimer button tapped")
self.watchDayProgramVM.exerciseVMList[0].sets[2].startTimer()
let elapsedRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].elapsedRestTime
let totalRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].totalRestTime
print("printing elapsedRestTime from SetRestDetailView \(elapsedRestTime)")
print("printing elapsedRestTime from SetRestDetailView \(totalRestTime)")
}
.border(Color.yellow)
}
}
}
TimerText
struct TimerText: View {
var elapsedRestTime: Int
var totalRestTime: Int
var rect: CGRect
var body: some View {
VStack {
Text(counterToMinutes())
.font(.system(size: 100, weight: .semibold, design: .rounded))
.kerning(0)
.fontWeight(.semibold)
.minimumScaleFactor(0.25)
.padding(-1)
}
}
func counterToMinutes() -> String {
let currentTime = totalRestTime - elapsedRestTime
let seconds = currentTime % 60
let minutes = Int(currentTime / 60)
if currentTime > 0 {
return String(format: "%02d:%02d", minutes, seconds)
}
else {
return ""
}
}
}
ViewModel
import Combine
final class WatchDayProgramViewModel: ObservableObject {
#Published var exerciseVMList: [ExerciseTestClass] = [
(static/hard-coded values for testing)
]
class ExerciseTestClass: ObservableObject {
init(exercise: String, sets: [SetInformationTestClass]) {
self.exercise = exercise
self.sets = sets
}
var exercise: String
#Published var sets: [SetInformationTestClass]
}
class SetInformationTestClass: ObservableObject {
init(totalRestTime: Int, elapsedRestTime: Int, remainingRestTime: Int, isTimerRunning: Bool) {
self.totalRestTime = totalRestTime
self.elapsedRestTime = elapsedRestTime
self.remainingRestTime = remainingRestTime
self.isTimerRunning = isTimerRunning
}
#Published var totalRestTime: Int
#Published var elapsedRestTime: Int
#Published var remainingRestTime: Int
#Published var isTimerRunning = false
#Published var watchTimer = Timer.publish(every: 1.0, on: .main, in: .default)
#Published var watchTimerSubscription: AnyCancellable? = nil
#Published private var startTime: Date? = nil
func startTimer() {
print("startTimer initiated")
self.watchTimerSubscription?.cancel()
if startTime == nil {
startTime = Date()
}
self.isTimerRunning = true
self.watchTimerSubscription = watchTimer
.autoconnect()
.sink(receiveValue: { [weak self] _ in
guard let self = self, let startTime = self.startTime else { return }
let now = Date()
let elapsedTime = now.timeIntervalSince(startTime)
self.remainingRestTime = self.totalRestTime - Int(elapsedTime)
self.elapsedRestTime = self.totalRestTime - self.remainingRestTime
guard self.remainingRestTime > 0 else {
self.pauseTimer()
return
}
self.objectWillChange.send()
print("printing elapsedRest Time \(self.elapsedRestTime) sec")
print("printing remaining Rest time\(self.remainingRestTime)sec ")
})
}
func pauseTimer() {
//stop timer and retain elapsed rest time
print("pauseTimer initiated")
self.watchTimerSubscription?.cancel()
self.watchTimerSubscription = nil
self.isTimerRunning = false
self.startTime = nil
}
Managed to resolve the issue with help of #lorem ipsum and his feedback. As per his comment, the problem lied with the fact that
it is more than likely not working because you are chaining ObservableObjects #Published will only detect a change when the object is changed as a whole now when variables change. One way to test is to wrap each SetInformationTestClass in an #ObservbleObject by using a subview that takes the object as a parameter.
After which, I managed to find similar SO answers on changes in nested view model (esp child), and made the child view model an ObservedObject. The changes in child view model got populated to the view. Please see the changed code below.
SetRestDetailView
import Foundation
import SwiftUI
import Combine
struct SetRestDetailView: View {
#EnvironmentObject var watchDayProgramVM: WatchDayProgramViewModel
var setCurrentHeartRate: Int = 120
#State var showingLog = false
var body: some View {
HStack {
let elapsedRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].elapsedRestTime
let totalRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].totalRestTime
let setInformatationVM = self.watchDayProgramVM.exerciseVMList[0].sets[2]
TimerText(setInformationVM: setInformatationVM, rect: rect)
.border(Color.yellow)
}
HStack {
SetTimerPlayPauseButton(isSetTimerRunningFlag: false,
playImage: "play.fill",
pauseImage: "pause.fill",
bgColor: Color.clear,
fgColor: Color.white.opacity(0.5),
rect: rect) {
print("playtimer button tapped")
self.watchDayProgramVM.exerciseVMList[0].sets[2].startTimer()
let elapsedRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].elapsedRestTime
let totalRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].totalRestTime
print("printing elapsedRestTime from SetRestDetailView \(elapsedRestTime)")
print("printing elapsedRestTime from SetRestDetailView \(totalRestTime)")
}
.border(Color.yellow)
}
}
}
TimerText
struct TimerText: View {
#ObservedObject var setInformationVM: SetInformationTestClass
// #State var elapsedRestTime: Int
// #State var totalRestTime: Int
var rect: CGRect
var body: some View {
VStack {
Text(counterToMinutes())
.font(.system(size: 100, weight: .semibold, design: .rounded))
.kerning(0)
.fontWeight(.semibold)
.minimumScaleFactor(0.25)
.padding(-1)
}
}
func counterToMinutes() -> String {
let currentTime = setInformationVM.totalRestTime - setInformationVM.elapsedRestTime
let seconds = currentTime % 60
let minutes = Int(currentTime / 60)
if currentTime > 0 {
return String(format: "%02d:%02d", minutes, seconds)
}
else {
return ""
}
}
}

Sound waves visualization for voice recognition ios SwiftUI (Speech framework)

I am using SwiftSpeech framework (convenience framework for ios Speech) to listen to user's voice input and parse speech. I would like to add wave style visualization for what the user is speaking. Not just abstract gif indicating the app is listening, but an actual real time representation of sound waves made by user. In my current implementation, if I turn on the visualization, recognition works with HUGE lags - I guess recognition and visualization both work on same AVAudioSession (shared one for microphone) and they mess with each other. Is there a way to make speech recognition work fast while also visualizing user's input?
Following is all the relevant code. Note that this is just a test project, and in an actual app it works much much worse. But still you can see the difference if you comment out the visualization part.
import SwiftUI
import Speech
import SwiftSpeech
import Combine
struct ContentView: View {
#State private var searchQuery = ""
#State private var speechSession: SwiftSpeech.Session?
#State private var cancelBag = Set<AnyCancellable>()
var body: some View {
VStack {
Text(searchQuery)
Spacer()
MicrophoneVisualization()
Button("Listen") {
if SFSpeechRecognizer.authorizationStatus() == .authorized {
startListening()
} else {
SFSpeechRecognizer.requestAuthorization { _ in
startListening()
}
}
}
}
.padding()
}
func startListening() {
speechSession = SwiftSpeech.Session(configuration: SwiftSpeech.Session.Configuration(locale: Locale(identifier: "en-US"), audioSessionConfiguration: .playAndRecord))
speechSession?.stringPublisher?.sink(receiveCompletion: { _ in }, receiveValue: { value in
self.searchQuery = value
})
.store(in: &cancelBag)
speechSession?.startRecording()
}
}
Here is my visualization code:
fileprivate let numberOfSamples: Int = 25
struct MicrophoneVisualization: View {
#ObservedObject private var mic = MicrophoneMonitor(numberOfSamples: numberOfSamples)
private func normalizeSoundLevel(level: Float, maxHeight: CGFloat) -> CGFloat {
let level = max(0.2, CGFloat(level) + 50) / 2 // between 0.1 and 25
return CGFloat(level * (maxHeight / 25))
}
var body: some View {
GeometryReader { g in
main(spacing: g.size.width / (2 * CGFloat(numberOfSamples)),
height: g.size.height)
}
}
func main(spacing: CGFloat, height: CGFloat) -> some View {
VStack {
HStack(spacing: spacing) {
ForEach(mic.soundSamples.indices, id: \.self) { i in
BarView(value: self.normalizeSoundLevel(level: mic.soundSamples[i], maxHeight: height),
width: spacing)
}
}
}
.frame(maxHeight: .infinity)
}
}
struct BarView: View {
var value: CGFloat
var width: CGFloat
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: width / 2)
.foregroundColor(.red)
.frame(width: width, height: max(width, value))
}
}
}
class MicrophoneMonitor: ObservableObject {
private var audioRecorder: AVAudioRecorder
private var timer: Timer?
private var currentSample: Int
private let numberOfSamples: Int
#Published public var soundSamples: [Float]
init(numberOfSamples: Int) {
self.numberOfSamples = numberOfSamples
self.soundSamples = [Float](repeating: .zero, count: numberOfSamples)
self.currentSample = 0
let audioSession = AVAudioSession.sharedInstance()
if audioSession.recordPermission != .granted {
audioSession.requestRecordPermission { (isGranted) in
if !isGranted {
fatalError("You must allow audio recording for this demo to work")
}
}
}
let url = URL(fileURLWithPath: "/dev/null", isDirectory: true)
let recorderSettings: [String:Any] = [
AVFormatIDKey: NSNumber(value: kAudioFormatAppleLossless),
AVSampleRateKey: 44100.0,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.min.rawValue
]
do {
audioRecorder = try AVAudioRecorder(url: url, settings: recorderSettings)
try audioSession.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
startMonitoring()
} catch {
fatalError(error.localizedDescription)
}
}
private func startMonitoring() {
audioRecorder.isMeteringEnabled = true
audioRecorder.record()
self.timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true, block: { (timer) in
self.audioRecorder.updateMeters()
DispatchQueue.main.async {
self.soundSamples[self.currentSample] = self.audioRecorder.averagePower(forChannel: 0)
self.currentSample = (self.currentSample + 1) % self.numberOfSamples
}
})
}
deinit {
timer?.invalidate()
audioRecorder.stop()
}
}

How can I know inside or outside tap release on a view/Shape in SwiftUI?

I want to know if user did release the drag gesture inside View or out side, for this reason I just worked for local and it is working, I wanted finish for global, but I saw that I would be need to read the parent Size, the location and the size of child also some math work to know if the tap release was inside or out side the view, So I was not sure if there is a simpler way for this, that is why asked to know, the current view is just a simple Rec, but it would needed more math work if it was Circle or what I should do with a custom path Shape? I cannot hard coded multiple if for a custom path, which that condition would not usable for deferent custom path! So what is the logical and best way for this job?
PS: My focus is not finding answer for global coordinateSpace, I can do it by myself, but that would not useful if my view was Circle, or a custom path! I want find out a basic and general way for using to all cases, instead finding answer just for special condition.
struct ContentView: View {
#State private var isPressing: Bool = Bool()
let frameOfView: CGSize = CGSize(width: 300.0, height: 300.0)
var body: some View {
Color.red
.overlay(Color.yellow.frame(width: frameOfView.width, height: frameOfView.height).gesture(gesture))
}
private var gesture: some Gesture {
DragGesture(minimumDistance: 0.0, coordinateSpace: .local)
.onEnded() { value in
print("isInside =", isInside(frame: frameOfView, location: value.location, coordinateSpace: .local))
}
}
func isInside(frame: CGSize, location: CGPoint, coordinateSpace: CoordinateSpace) -> Bool {
if (coordinateSpace.isLocal) {
return (location.x >= 0.0) && (location.y >= 0.0) && (location.x <= frame.width) && (location.y <= frame.height)
}
else if (coordinateSpace.isGlobal) {
return false // under edit!
}
else {
return false // under edit!
}
}
}
You could pass in the Shape of the view you are using, and use that to determine the path for the shape of the view. You can then test if the last point dragged was inside or outside of this shape.
This is usually just a Rectangle(), aka a rectangular view, so in my example there is even a convenience initializer if you don't want to provide this every time.
Code:
struct TapReleaseDetector<ContentShape: Shape, Content: View>: View {
typealias TapAction = (Bool) -> Void
private let shape: ContentShape
private let content: () -> Content
private let action: TapAction
#State private var path: Path?
init(shape: ContentShape, #ViewBuilder content: #escaping () -> Content, action: #escaping TapAction) {
self.shape = shape
self.content = content
self.action = action
}
init(#ViewBuilder content: #escaping () -> Content, action: #escaping TapAction) where ContentShape == Rectangle {
self.init(shape: Rectangle(), content: content, action: action)
}
var body: some View {
content()
.background(
GeometryReader { geo in
Color.clear.onAppear {
path = shape.path(in: geo.frame(in: .local))
}
}
)
.gesture(gesture)
}
private var gesture: some Gesture {
DragGesture(minimumDistance: 0.0, coordinateSpace: .local)
.onEnded { drag in
guard let path = path else { return }
action(path.contains(drag.location))
}
}
}
Example usage:
struct ContentView: View {
#State private var result: Bool?
#State private var opacity: Double = 0
#State private var currentId = UUID()
private var resultText: String? {
if let result = result {
return result ? "Inside" : "Outside"
} else {
return nil
}
}
var body: some View {
VStack(spacing: 30) {
Text(resultText ?? " ")
.font(.title)
.opacity(opacity)
TapReleaseDetector(shape: Circle()) {
Circle()
.fill(Color.red)
.frame(width: 300, height: 300)
} action: { isInside in
result = isInside
opacity = 1
withAnimation(.easeOut(duration: 1)) {
opacity = 0
}
let tempId = UUID()
currentId = tempId
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
guard tempId == currentId else { return }
result = nil
}
}
Text("Recent: \(resultText ?? "None")")
}
}
}
Result:

SwiftUI instanced #State variable

I am quite new to SwiftUI. I have a following "Counter" view that counts up every second. I want to "reset" the counter when the colour is changed:
struct MyCounter : View {
let color: Color
#State private var count = 0
init(color:Color) {
self.color = color
_count = State(initialValue: 0)
}
var body: some View {
Text("\(count)").foregroundColor(color)
.onAppear(){
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}
}
}
Here is my main view that uses counter:
struct ContentView: View {
#State var black = true
var body: some View {
VStack {
MyCounter(color: black ? Color.black : Color.yellow)
Button(action:{self.black.toggle()}) { Text("Toggle") }
}
}
}
When i click "Toggle" button, i see MyCounter constructor being called, but #State counter persists and never resets. So my question is how do I reset this #State value? Please note that I do not wish to use counter as #Binding and manage that in the parent view, but rather MyCounter be a self-contained widget. (this is a simplified example. the real widget I am creating is a sprite animator that performs sprite animations, and when I swap the image, i want the animator to start from frame 0). Thanks!
There are two way you can solve this issue. One is to use a binding, like E.Coms explained, which is the easiest way to solve your problem.
Alternatively, you could try using an ObservableObject as a view model for your timer. This is the more flexible solution. The timer can be passed around and it could also be injected as an environment object if you so desire.
class TimerModel: ObservableObject {
// The #Published property wrapper ensures that objectWillChange signals are automatically emitted.
#Published var count: Int = 0
init() {}
func start() {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}
func reset() {
count = 0
}
}
Your timer view then becomes
struct MyCounter : View {
let color: Color
#ObservedObject var timer: TimerModel
init(color: Color, timer: TimerModel) {
self.color = color
self.timer = timer
}
var body: some View {
Text("\(timer.count)").foregroundColor(color)
.onAppear(){
self.timer.start()
}
}
}
Your content view becomes
struct ContentView: View {
#State var black = true
#ObservedObject var timer = TimerModel()
var body: some View {
VStack {
MyCounter(color: black ? Color.black : Color.yellow, timer: self.timer)
Button(action: {
self.black.toggle()
self.timer.reset()
}) {
Text("Toggle")
}
}
}
}
The advantage of using an observable object is that you can then keep track of your timer better. You could add a stop() method to your model, which invalidates the timer and you can call it in a onDisappear block of your view.
One thing that you have to be careful about this approach is that when you're using the timer in a standalone fashion, where you create it in a view builder closure with MyCounter(color: ..., timer: TimerModel()), every time the view is rerendered, the timer model is replaced, so you have to make sure to keep the model around somehow.
You need a binding var:
struct MyCounter : View {
let color: Color
#Binding var count: Int
var body: some View {
Text("\(count)").foregroundColor(color)
.onAppear(){
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}
}
}
struct ContentView: View {
#State var black = true
#State var count : Int = 0
var body: some View {
VStack {
MyCounter(color: black ? Color.black : Color.yellow , count: $count)
Button(action:{self.black.toggle()
self.count = 0
}) { Text("Toggle") }
}
}
}
Also you can just add one State Value innerColor to help you if you don't like binding.
struct MyCounter : View {
let color: Color
#State private var count: Int = 0
#State private var innerColor: Color?
init(color: Color) {
self.color = color
}
var body: some View {
return Text("\(self.count)")
.onAppear(){
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}.foregroundColor(color).onReceive(Just(color), perform: { color in
if self.innerColor != self.color {
self.count = 0
self.innerColor = color}
})
}
}

getting unresolved identifier 'self' in SwiftUI code trying to use Timer.scheduledTimer

In SwiftUI I'm noting to use a Timer that:
Try 1 - This doesn't work as get "Use of unresolved identifier 'self'"
var timer2: Timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) {
self.angle = self.angle + .degrees(1)
}
Try 2 - Works, but have to put in an "_ = self.timer" to start it later
var timer: Timer {
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) {_ in
self.angle = self.angle + .degrees(1)
}
}
// then after need to use " .onAppear(perform: {_ = self.timer}) "
Is there a way to get my Try1 working? That is where in a SwiftUI file I can create the timer up front? Or actually where in SwiftUI would one normally start and stop the timer? i.e. where are lifecycle methods
Whole file:
import SwiftUI
struct ContentView : View {
#State var angle: Angle = .degrees(55)
// var timer2: Timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) {
// self.angle = self.angle + .degrees(1)
// }
var timer: Timer {
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) {_ in
self.angle = self.angle + .degrees(1)
}
}
private func buttonAction() {
print("test")
self.angle = self.angle + .degrees(5)
}
var body: some View {
VStack{
Text("Start")
ZStack {
Circle()
.fill(Color.blue)
.frame(
width: .init(integerLiteral: 100),
height: .init(integerLiteral: 100)
)
Rectangle()
.fill(Color.green)
.frame(width: 20, height: 100)
// .rotationEffect(Angle(degrees: 25.0))
.rotationEffect(self.angle)
}
Button(action: self.buttonAction) {
Text("CLICK HERE")
}
Text("End")
}
.onAppear(perform: {_ = self.timer})
}
}
It isn't clear to me that you need a timer for your example, but since there is a great deal of misinformation out there about how to include a Timer in a SwiftUI app, I'll demonstrate.
The key is to put the timer elsewhere and publish each time it fires. We can easily do this by adding a class that holds the timer as a bindable object to our environment (note that you will need to import Combine):
class TimerHolder : BindableObject {
var timer : Timer!
let didChange = PassthroughSubject<TimerHolder,Never>()
var count = 0 {
didSet {
self.didChange.send(self)
}
}
func start() {
self.timer?.invalidate()
self.count = 0
self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) {
[weak self] _ in
guard let self = self else { return }
self.count += 1
}
}
}
We will need to pass an instance of this class down through the environment, so we modify the scene delegate:
window.rootViewController =
UIHostingController(rootView: ContentView())
becomes
window.rootViewController =
UIHostingController(rootView: ContentView()
.environmentObject(TimerHolder()))
Finally, lets put up some UI that starts the timer and displays the count, to prove that it is working:
struct ContentView : View {
#EnvironmentObject var timerHolder : TimerHolder
var body: some View {
VStack {
Button("Start Timer") { self.timerHolder.start() }
Text(String(self.timerHolder.count))
}
}
}
EDIT Update for those who have not been following the plot: BindableObject has migrated into ObservableObject, and there is no need any more for manual signalling. So:
class TimerHolder : ObservableObject {
var timer : Timer!
#Published var count = 0
// ... and the rest is as before ...
Based on matt's answer above, I think there is a less complicated version possible.
class TimerHolder : ObservableObject {
var timer : Timer!
#Published var count = 0
func start() {
self.timer?.invalidate()
self.count = 0
self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) {
_ in
self.count += 1
print(self.count)
}
}
}
With : ObservableObjectand #Publish var count
and in the View
#ObservedObject var durationTimer = TimerHolder()
...
Text("\(durationTimer.count) Seconds").onAppear {
self.durationTimer.start()
}
you can observe any change to the count variable and update the Text in the view accordingly.
As mentioned in other comments, make sure though, that the timer is not restarted unexpectedly due to an action recreating the view.