I want to have something similar to what the timer app on iOS is doing - it somehow synchronises with apple watch and runs the timer on both devices. Couldn't figure out how they do that - any ideas?
Synchronizing an application, like the timer, between the Apple Watch and iPhone requires the use of multiple communications technologies. For example:
Your main focus will be on the WatchConnectivity framework as it is most
important in this scenario
Bluetooth: This technology allows devices to connect and communicate with each other wirelessly. By using Bluetooth, the iPhone and the Apple Watch can establish a connection and transfer data between each other.
iCloud: This is Apple's cloud storage and synchronization service, which allows users to store and access their data on multiple devices. By using iCloud, the app can store data in the cloud and access it on both the iPhone and the Apple Watch, even if the devices are not connected via Bluetooth.
WatchConnectivity framework: This framework provides the necessary tools and APIs for the iPhone and the Apple Watch to communicate and transfer data between each other. By using the WatchConnectivity framework, the app can establish a connection between the two devices and transfer data as needed.
You gave the Timer app as an example. The Timer app requires the use of the WatchConnectivity framework as stated above. Here it is in action for both devices (this will require a user interface to be created, but this is the essence of the app):
iPhone
import UIKit
import WatchConnectivity
class ViewController: UIViewController, WCSessionDelegate {
// MARK: Properties
var timer: Timer?
var session: WCSession?
var startTime: Date?
// MARK: Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
// Configure Watch Connectivity
if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
}
}
// MARK: Action
#IBAction func startTapped(_ sender: Any) {
startTime = Date()
timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(updateTimer), userInfo: nil, repeats: true)
// Send start time to Watch
sendStartTimeToWatch()
}
#IBAction func stopTapped(_ sender: Any) {
// Stop timer
timer?.invalidate()
timer = nil
}
// MARK: Helper
#objc func updateTimer() {
// Calculate time elapsed
guard let startTime = startTime else { return }
let timeElapsed = Date().timeIntervalSince(startTime)
// Format time
let minutes = Int(timeElapsed) / 60 % 60
let seconds = Int(timeElapsed) % 60
// Update label
let timerString = String(format:"%02i:%02i", minutes, seconds)
// Update label
// Your label here
}
func sendStartTimeToWatch() {
guard let startTime = startTime, let session = session else { return }
do {
let data = try NSKeyedArchiver.archivedData(withRootObject: startTime, requiringSecureCoding: true)
try session.updateApplicationContext(["startTime": data])
} catch {
print("Error: \(error)")
}
}
// MARK: WCSessionDelegate
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
// no-op
}
}
Apple Watch
import WatchKit
import Foundation
import WatchConnectivity
class InterfaceController: WKInterfaceController, WCSessionDelegate {
// MARK: Properties
var timer: Timer?
var session: WCSession?
var startTime: Date?
// MARK: Lifecycle
override func awake(withContext context: Any?) {
super.awake(withContext: context)
// Configure Watch Connectivity
if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
}
}
// MARK: Action
#IBAction func startTapped() {
// Receive start time from iPhone
receiveStartTimeFromiPhone()
}
#IBAction func stopTapped() {
// Stop timer
timer?.invalidate()
timer = nil
}
// MARK: Helper
func startTimer() {
guard let startTime = startTime else { return }
// Calculate time elapsed
let timeElapsed = Date().timeIntervalSince(startTime)
// Format time
let minutes = Int(timeElapsed) / 60 % 60
let seconds = Int(timeElapsed) % 60
// Update label
let timerString = String(format:"%02i:%02i", minutes, seconds)
// Update label
// Your label here
timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(updateTimer), userInfo: nil, repeats: true)
}
#objc func updateTimer() {
guard let startTime = startTime else { return }
// Calculate time elapsed
let timeElapsed = Date().timeIntervalSince(startTime)
// Format time
let minutes = Int(timeElapsed) / 60 % 60
let seconds = Int(timeElapsed) % 60
// Update label
let timerString = String(format:"%02i:%02i", minutes, seconds)
// Update label
// Your label here
}
func receiveStartTimeFromiPhone() {
guard let session = session else { return }
session.sendMessage(["request": "startTime"], replyHandler: { (response) in
if let data = response["startTime"] as? Data, let startTime = try? NSKeyedUnarchiver.unarchivedObject(ofClass: Date.self, from: data) {
self.startTime = startTime
self.startTimer()
}
}, errorHandler: { (error) in
print("Error: \(error)")
})
}
// MARK: WCSessionDelegate
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
// no-op
}
}
Related
I'm trying to create an metronome app by implementing the sample code provided by apple. Everything works fine but i'm seeing an delay in the beat visuals its not properly synchronised with the player time. Here is the sample code provided by apple
let secondsPerBeat = 60.0 / tempoBPM
let samplesPerBeat = Float(secondsPerBeat * Float(bufferSampleRate))
let beatSampleTime: AVAudioFramePosition = AVAudioFramePosition(nextBeatSampleTime)
let playerBeatTime: AVAudioTime = AVAudioTime(sampleTime: AVAudioFramePosition(beatSampleTime), atRate: bufferSampleRate)
// This time is relative to the player's start time.
player.scheduleBuffer(soundBuffer[bufferNumber]!, at: playerBeatTime, options: AVAudioPlayerNodeBufferOptions(rawValue: 0), completionHandler: {
self.syncQueue!.sync() {
self.beatsScheduled -= 1
self.bufferNumber ^= 1
self.scheduleBeats()
}
})
beatsScheduled += 1
if (!playerStarted) {
// We defer the starting of the player so that the first beat will play precisely
// at player time 0. Having scheduled the first beat, we need the player to be running
// in order for nodeTimeForPlayerTime to return a non-nil value.
player.play()
playerStarted = true
}
let callbackBeat = beatNumber
beatNumber += 1
// calculate the beattime for animating the UI based on the playerbeattime.
let nodeBeatTime: AVAudioTime = player.nodeTime(forPlayerTime: playerBeatTime)!
let output: AVAudioIONode = engine.outputNode
let latencyHostTicks: UInt64 = AVAudioTime.hostTime(forSeconds: output.presentationLatency)
//calcualte the final dispatch time which will update the UI in particualr intervals
let dispatchTime = DispatchTime(uptimeNanoseconds: nodeBeatTime.hostTime + latencyHostTicks)**
// Visuals.
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: dispatchTime) {
if (self.isPlaying) {
// send current call back beat.
self.delegate!.metronomeTicking!(self, bar: (callbackBeat / 4) + 1, beat: (callbackBeat % 4) + 1)
}
}
}
// my view controller class where i'm showing the beat number
class ViewController: UIViewController ,UIGestureRecognizerDelegate,Metronomedelegate{
#IBOutlet var rhythmlabel: UILabel!
//view did load method
override func viewDidLoad() {
}
//delegate method for getting the beat value from metronome engine and showing in the UI label.
func metronomeTicking(_ metronome: Metronome, bar: Int, beat: Int) {
DispatchQueue.main.async {
print("Playing Beat \(beat)")
//show beat in label
self.rhythmlabel.text = "\(beat)"
}
}
}
I think you are approaching this a bit too complex for no reason. All you really need is to set a DispatchTime when you start the metronome, and fire a function call whenever the DispatchTime is up, update the dispatch time based on the desired frequency, and loop as long as the metronome is enabled.
I prepared a project for you which implements this method so you can play with and use as you see fit: https://github.com/ekscrypto/Swift-Tutorial-Metronome
Good luck!
Metronome.swift
import Foundation
import AVFoundation
class Metronome {
var bpm: Float = 60.0 { didSet {
bpm = min(300.0,max(30.0,bpm))
}}
var enabled: Bool = false { didSet {
if enabled {
start()
} else {
stop()
}
}}
var onTick: ((_ nextTick: DispatchTime) -> Void)?
var nextTick: DispatchTime = DispatchTime.distantFuture
let player: AVAudioPlayer = {
do {
let soundURL = Bundle.main.url(forResource: "metronome", withExtension: "wav")!
let soundFile = try AVAudioFile(forReading: soundURL)
let player = try AVAudioPlayer(contentsOf: soundURL)
return player
} catch {
print("Oops, unable to initialize metronome audio buffer: \(error)")
return AVAudioPlayer()
}
}()
private func start() {
print("Starting metronome, BPM: \(bpm)")
player.prepareToPlay()
nextTick = DispatchTime.now()
tick()
}
private func stop() {
player.stop()
print("Stoping metronome")
}
private func tick() {
guard
enabled,
nextTick <= DispatchTime.now()
else { return }
let interval: TimeInterval = 60.0 / TimeInterval(bpm)
nextTick = nextTick + interval
DispatchQueue.main.asyncAfter(deadline: nextTick) { [weak self] in
self?.tick()
}
player.play(atTime: interval)
onTick?(nextTick)
}
}
ViewController.swift
import UIKit
class ViewController: UIViewController {
#IBOutlet weak var bpmLabel: UILabel!
#IBOutlet weak var tickLabel: UILabel!
let myMetronome = Metronome()
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
myMetronome.onTick = { (nextTick) in
self.animateTick()
}
updateBpm()
}
private func animateTick() {
tickLabel.alpha = 1.0
UIView.animate(withDuration: 0.35) {
self.tickLabel.alpha = 0.0
}
}
#IBAction func startMetronome(_: Any?) {
myMetronome.enabled = true
}
#IBAction func stopMetronome(_: Any?) {
myMetronome.enabled = false
}
#IBAction func increaseBpm(_: Any?) {
myMetronome.bpm += 1.0
updateBpm()
}
#IBAction func decreaseBpm(_: Any?) {
myMetronome.bpm -= 1.0
updateBpm()
}
private func updateBpm() {
let metronomeBpm = Int(myMetronome.bpm)
bpmLabel.text = "\(metronomeBpm)"
}
}
Note: There seems to be a pre-loading issue, the prepareToPlay() doesn't fully load the audio file before playing and it causes some timing issue with the first playback of the tick audio file. This issue will be left to the reader to figure out. The original question being synchronization, this should be demonstrated in the code above.
The Apple ARKitVision example has the following declaration in the ViewController.swift file:
// The view controller that displays the status and "restart experience" UI.
private lazy var statusViewController: StatusViewController = {
return children.lazy.compactMap({ $0 as? StatusViewController }).first!
}()
However, if I copy the same views and source files and incorporate them into another test storyboard/project I get the error message "Instance member 'children' cannot be used on type 'StatusViewController'"
So, why does this work on the ARKitVision example but it does not work if I set it up myself from scratch? What else is the ARKitVision example doing to get this working? Thanks 😊
The complete class definition for StatusViewController is:
/*
See LICENSE folder for this sample’s licensing information.
Abstract:
Utility class for showing messages above the AR view.
*/
import Foundation
import ARKit
/**
Displayed at the top of the main interface of the app that allows users to see
the status of the AR experience, as well as the ability to control restarting
the experience altogether.
- Tag: StatusViewController
*/
class StatusViewController: UIViewController {
// MARK: - Types
enum MessageType {
case trackingStateEscalation
case planeEstimation
case contentPlacement
case focusSquare
static var all: [MessageType] = [
.trackingStateEscalation,
.planeEstimation,
.contentPlacement,
.focusSquare
]
}
// MARK: - IBOutlets
#IBOutlet weak private var messagePanel: UIVisualEffectView!
#IBOutlet weak private var messageLabel: UILabel!
#IBOutlet weak private var restartExperienceButton: UIButton!
// MARK: - Properties
/// Trigerred when the "Restart Experience" button is tapped.
var restartExperienceHandler: () -> Void = {}
/// Seconds before the timer message should fade out. Adjust if the app needs longer transient messages.
private let displayDuration: TimeInterval = 6
// Timer for hiding messages.
private var messageHideTimer: Timer?
private var timers: [MessageType: Timer] = [:]
// MARK: - Message Handling
func showMessage(_ text: String, autoHide: Bool = true) {
// Cancel any previous hide timer.
messageHideTimer?.invalidate()
messageLabel.text = text
// Make sure status is showing.
setMessageHidden(false, animated: true)
if autoHide {
messageHideTimer = Timer.scheduledTimer(withTimeInterval: displayDuration, repeats: false, block: { [weak self] _ in
self?.setMessageHidden(true, animated: true)
})
}
}
func scheduleMessage(_ text: String, inSeconds seconds: TimeInterval, messageType: MessageType) {
cancelScheduledMessage(for: messageType)
let timer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false, block: { [weak self] timer in
self?.showMessage(text)
timer.invalidate()
})
timers[messageType] = timer
}
func cancelScheduledMessage(`for` messageType: MessageType) {
timers[messageType]?.invalidate()
timers[messageType] = nil
}
func cancelAllScheduledMessages() {
for messageType in MessageType.all {
cancelScheduledMessage(for: messageType)
}
}
// MARK: - ARKit
func showTrackingQualityInfo(for trackingState: ARCamera.TrackingState, autoHide: Bool) {
showMessage(trackingState.presentationString, autoHide: autoHide)
}
func escalateFeedback(for trackingState: ARCamera.TrackingState, inSeconds seconds: TimeInterval) {
cancelScheduledMessage(for: .trackingStateEscalation)
let timer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false, block: { [unowned self] _ in
self.cancelScheduledMessage(for: .trackingStateEscalation)
var message = trackingState.presentationString
if let recommendation = trackingState.recommendation {
message.append(": \(recommendation)")
}
self.showMessage(message, autoHide: false)
})
timers[.trackingStateEscalation] = timer
}
// MARK: - IBActions
#IBAction private func restartExperience(_ sender: UIButton) {
restartExperienceHandler()
}
// MARK: - Panel Visibility
private func setMessageHidden(_ hide: Bool, animated: Bool) {
// The panel starts out hidden, so show it before animating opacity.
messagePanel.isHidden = false
guard animated else {
messagePanel.alpha = hide ? 0 : 1
return
}
UIView.animate(withDuration: 0.2, delay: 0, options: [.beginFromCurrentState], animations: {
self.messagePanel.alpha = hide ? 0 : 1
}, completion: nil)
}
}
extension ARCamera.TrackingState {
var presentationString: String {
switch self {
case .notAvailable:
return "TRACKING UNAVAILABLE"
case .normal:
return "TRACKING NORMAL"
case .limited(.excessiveMotion):
return "TRACKING LIMITED\nExcessive motion"
case .limited(.insufficientFeatures):
return "TRACKING LIMITED\nLow detail"
case .limited(.initializing):
return "Initializing"
case .limited(.relocalizing):
return "Recovering from interruption"
}
}
var recommendation: String? {
switch self {
case .limited(.excessiveMotion):
return "Try slowing down your movement, or reset the session."
case .limited(.insufficientFeatures):
return "Try pointing at a flat surface, or reset the session."
case .limited(.relocalizing):
return "Return to the location where you left off or try resetting the session."
default:
return nil
}
}
}
The definition of the ViewController class is:
/*
See LICENSE folder for this sample’s licensing information.
Abstract:
Main view controller for the ARKitVision sample.
*/
import UIKit
import SpriteKit
import ARKit
import Vision
class ViewController: UIViewController, UIGestureRecognizerDelegate, ARSKViewDelegate, ARSessionDelegate {
#IBOutlet weak var sceneView: ARSKView!
// The view controller that displays the status and "restart experience" UI.
private lazy var statusViewController: StatusViewController = {
return children.lazy.compactMap({ $0 as? StatusViewController }).first!
}()
// MARK: - View controller lifecycle
override func viewDidLoad() {
super.viewDidLoad()
// Configure and present the SpriteKit scene that draws overlay content.
let overlayScene = SKScene()
overlayScene.scaleMode = .aspectFill
sceneView.delegate = self
sceneView.presentScene(overlayScene)
sceneView.session.delegate = self
// Hook up status view controller callback.
statusViewController.restartExperienceHandler = { [unowned self] in
self.restartSession()
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Create a session configuration
let configuration = ARWorldTrackingConfiguration()
// Run the view's session
sceneView.session.run(configuration)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Pause the view's session
sceneView.session.pause()
}
// MARK: - ARSessionDelegate
// Pass camera frames received from ARKit to Vision (when not already processing one)
/// - Tag: ConsumeARFrames
func session(_ session: ARSession, didUpdate frame: ARFrame) {
// Do not enqueue other buffers for processing while another Vision task is still running.
// The camera stream has only a finite amount of buffers available; holding too many buffers for analysis would starve the camera.
guard currentBuffer == nil, case .normal = frame.camera.trackingState else {
return
}
// Retain the image buffer for Vision processing.
self.currentBuffer = frame.capturedImage
classifyCurrentImage()
}
// MARK: - Vision classification
// Vision classification request and model
/// - Tag: ClassificationRequest
private lazy var classificationRequest: VNCoreMLRequest = {
do {
// Instantiate the model from its generated Swift class.
let model = try VNCoreMLModel(for: Inceptionv3().model)
let request = VNCoreMLRequest(model: model, completionHandler: { [weak self] request, error in
self?.processClassifications(for: request, error: error)
})
// Crop input images to square area at center, matching the way the ML model was trained.
request.imageCropAndScaleOption = .centerCrop
// Use CPU for Vision processing to ensure that there are adequate GPU resources for rendering.
request.usesCPUOnly = true
return request
} catch {
fatalError("Failed to load Vision ML model: \(error)")
}
}()
// The pixel buffer being held for analysis; used to serialize Vision requests.
private var currentBuffer: CVPixelBuffer?
// Queue for dispatching vision classification requests
private let visionQueue = DispatchQueue(label: "com.example.apple-samplecode.ARKitVision.serialVisionQueue")
// Run the Vision+ML classifier on the current image buffer.
/// - Tag: ClassifyCurrentImage
private func classifyCurrentImage() {
// Most computer vision tasks are not rotation agnostic so it is important to pass in the orientation of the image with respect to device.
let orientation = CGImagePropertyOrientation(UIDevice.current.orientation)
let requestHandler = VNImageRequestHandler(cvPixelBuffer: currentBuffer!, orientation: orientation)
visionQueue.async {
do {
// Release the pixel buffer when done, allowing the next buffer to be processed.
defer { self.currentBuffer = nil }
try requestHandler.perform([self.classificationRequest])
} catch {
print("Error: Vision request failed with error \"\(error)\"")
}
}
}
// Classification results
private var identifierString = ""
private var confidence: VNConfidence = 0.0
// Handle completion of the Vision request and choose results to display.
/// - Tag: ProcessClassifications
func processClassifications(for request: VNRequest, error: Error?) {
guard let results = request.results else {
print("Unable to classify image.\n\(error!.localizedDescription)")
return
}
// The `results` will always be `VNClassificationObservation`s, as specified by the Core ML model in this project.
let classifications = results as! [VNClassificationObservation]
// Show a label for the highest-confidence result (but only above a minimum confidence threshold).
if let bestResult = classifications.first(where: { result in result.confidence > 0.5 }),
let label = bestResult.identifier.split(separator: ",").first {
identifierString = String(label)
confidence = bestResult.confidence
} else {
identifierString = ""
confidence = 0
}
DispatchQueue.main.async { [weak self] in
self?.displayClassifierResults()
}
}
// Show the classification results in the UI.
private func displayClassifierResults() {
guard !self.identifierString.isEmpty else {
return // No object was classified.
}
let message = String(format: "Detected \(self.identifierString) with %.2f", self.confidence * 100) + "% confidence"
statusViewController.showMessage(message)
}
// MARK: - Tap gesture handler & ARSKViewDelegate
// Labels for classified objects by ARAnchor UUID
private var anchorLabels = [UUID: String]()
// When the user taps, add an anchor associated with the current classification result.
/// - Tag: PlaceLabelAtLocation
#IBAction func placeLabelAtLocation(sender: UITapGestureRecognizer) {
let hitLocationInView = sender.location(in: sceneView)
let hitTestResults = sceneView.hitTest(hitLocationInView, types: [.featurePoint, .estimatedHorizontalPlane])
if let result = hitTestResults.first {
// Add a new anchor at the tap location.
let anchor = ARAnchor(transform: result.worldTransform)
sceneView.session.add(anchor: anchor)
// Track anchor ID to associate text with the anchor after ARKit creates a corresponding SKNode.
anchorLabels[anchor.identifier] = identifierString
}
}
// When an anchor is added, provide a SpriteKit node for it and set its text to the classification label.
/// - Tag: UpdateARContent
func view(_ view: ARSKView, didAdd node: SKNode, for anchor: ARAnchor) {
guard let labelText = anchorLabels[anchor.identifier] else {
fatalError("missing expected associated label for anchor")
}
let label = TemplateLabelNode(text: labelText)
node.addChild(label)
}
// MARK: - AR Session Handling
func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
statusViewController.showTrackingQualityInfo(for: camera.trackingState, autoHide: true)
switch camera.trackingState {
case .notAvailable, .limited:
statusViewController.escalateFeedback(for: camera.trackingState, inSeconds: 3.0)
case .normal:
statusViewController.cancelScheduledMessage(for: .trackingStateEscalation)
// Unhide content after successful relocalization.
setOverlaysHidden(false)
}
}
func session(_ session: ARSession, didFailWithError error: Error) {
guard error is ARError else { return }
let errorWithInfo = error as NSError
let messages = [
errorWithInfo.localizedDescription,
errorWithInfo.localizedFailureReason,
errorWithInfo.localizedRecoverySuggestion
]
// Filter out optional error messages.
let errorMessage = messages.compactMap({ $0 }).joined(separator: "\n")
DispatchQueue.main.async {
self.displayErrorMessage(title: "The AR session failed.", message: errorMessage)
}
}
func sessionWasInterrupted(_ session: ARSession) {
setOverlaysHidden(true)
}
func sessionShouldAttemptRelocalization(_ session: ARSession) -> Bool {
/*
Allow the session to attempt to resume after an interruption.
This process may not succeed, so the app must be prepared
to reset the session if the relocalizing status continues
for a long time -- see `escalateFeedback` in `StatusViewController`.
*/
return true
}
private func setOverlaysHidden(_ shouldHide: Bool) {
sceneView.scene!.children.forEach { node in
if shouldHide {
// Hide overlay content immediately during relocalization.
node.alpha = 0
} else {
// Fade overlay content in after relocalization succeeds.
node.run(.fadeIn(withDuration: 0.5))
}
}
}
private func restartSession() {
statusViewController.cancelAllScheduledMessages()
statusViewController.showMessage("RESTARTING SESSION")
anchorLabels = [UUID: String]()
let configuration = ARWorldTrackingConfiguration()
sceneView.session.run(configuration, options: [.resetTracking, .removeExistingAnchors])
}
// MARK: - Error handling
private func displayErrorMessage(title: String, message: String) {
// Present an alert informing about the error that has occurred.
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
let restartAction = UIAlertAction(title: "Restart Session", style: .default) { _ in
alertController.dismiss(animated: true, completion: nil)
self.restartSession()
}
alertController.addAction(restartAction)
present(alertController, animated: true, completion: nil)
}
}
What this would indicate is that your class StatusViewController doesn't inherit from UIViewController, as the property of children has been available to a subclass of UIViewController for quite some time.
Are you able to share how you have composed your StatusViewController?
I am trying to make a pomodoro app on the Apple Watch (OS2). I want to trigger a notification after the countdown is finished, in the first step I am trying to print some word in console, but it still does not works. How to use NSTimeInterval to get the remaining time to do that?
import WatchKit
import Foundation
class InterfaceController: WKInterfaceController {
let countdown:NSTimeInterval = 1501
var timerRunning = false
#IBOutlet var pauseButton: WKInterfaceButton!
#IBOutlet var timer: WKInterfaceTimer!
override func awakeWithContext(context: AnyObject?) {
super.awakeWithContext(context)
}
override func willActivate() {
super.willActivate()
}
override func didDeactivate() {
super.didDeactivate()
}
#IBAction func startPomodoro() {
let date = NSDate(timeIntervalSinceNow: countdown)
timer.setDate(date)
timer.start();
WKInterfaceDevice.currentDevice().playHaptic(.Start)
WKInterfaceDevice.currentDevice().playHaptic(.Start)
}
#IBAction func resetPomodoroTimer() {
timer.stop()
let resetCountdown:NSTimeInterval = 1501
let date = NSDate(timeIntervalSinceNow: resetCountdown)
timer.setDate(date)
WKInterfaceDevice.currentDevice().playHaptic(.Retry)
WKInterfaceDevice.currentDevice().playHaptic(.Retry)
}
#IBAction func pausePomodoro() {
timer.stop()
if !timerRunning{
pauseButton.setTitle("Restart")
}
WKInterfaceDevice.currentDevice().playHaptic(.Stop)
WKInterfaceDevice.currentDevice().playHaptic(.Stop)
}
func showNotification(){
if countdown < 1490
{
print("Notification")
WKInterfaceDevice.currentDevice().playHaptic(.Success)
WKInterfaceDevice.currentDevice().playHaptic(.Success)
}
}
}
To trigger a notification after the WKInterfaceTimer has finished you have to add a NSTimer with the same time interval. You start both at the same time and when the NSTimer fires you know that the WKInterfaceTimer also finished. IMHO that is not a very elegant solution, but it is suggested in Apple's documentation, so there's apparently is no other way to do this.
If you want to add a pause / restart functionality you have to track the remaining time when the user hits "pause" and stop both timers. When the user starts the timer again, you set both timers to the remaining time and start them.
Here is a working example with pause / restart functionality (it has a WKInterfaceButton that is connected to the button outlet and the didPressButton: action:
enum TimerState {
case Idle, Running, Paused, Finished
}
class InterfaceController: WKInterfaceController {
let countdownDuration: NSTimeInterval = 10
var remainingDuration: NSTimeInterval = 10
var timer: NSTimer?
var timerState = TimerState.Idle
#IBOutlet var interfaceTimer: WKInterfaceTimer!
#IBOutlet var button: WKInterfaceButton!
#IBAction func didPressButton() {
switch timerState {
case .Idle:
startTimer(remainingDuration: countdownDuration)
case .Running:
let fireDate = timer!.fireDate
remainingDuration = fireDate.timeIntervalSinceDate(NSDate())
interfaceTimer.stop()
timer?.invalidate()
button.setTitle("Continue")
timerState = .Paused
case .Paused:
startTimer(remainingDuration: remainingDuration)
case .Finished:
break
}
}
func startTimer(remainingDuration duration:NSTimeInterval) {
interfaceTimer.setDate(NSDate(timeIntervalSinceNow: duration))
interfaceTimer.start()
timer = NSTimer.scheduledTimerWithTimeInterval(duration, target: self, selector: Selector("timerDidFire:"), userInfo: nil, repeats: false)
button.setTitle("Pause")
timerState = .Running
}
func timerDidFire(timer: NSTimer) {
interfaceTimer.stop()
timerState = .Finished
WKInterfaceDevice.currentDevice().playHaptic(.Success)
}
}
I created a timer app that runs on the iphone.
I wish we could control it iPhone and Watch
The controls (Play, Stop, Restart) with the iPhone works fine, my meter is displayed on the Watch.
Watch on the Stop works well, for against the Start does not work, the meter does not turn on iPhone or the Watch.
Restart the works too.
My Label on the iPhone is very slow to change if the information comes from the Watch, but works well in the other direction, toward the iPhone Watch
Have you noticed this problem, it is a problem related to WatchConnectivity
Thanks for your help
Below is my code:
ViewController.swift
import UIKit
import WatchConnectivity
class ViewController: UIViewController, WCSessionDelegate {
#IBOutlet weak var timerLabel: UILabel!
#IBOutlet weak var watchLabel: UILabel!
var session: WCSession!
var timerCount = 0
var timerRunning = false
var timer = NSTimer()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
if (WCSession.isSupported()) {
let session = WCSession.defaultSession()
session.delegate = self
session.activateSession()
if session.paired != true {
print("Apple Watch is not paired")
}
if session.watchAppInstalled != true {
print("WatchKit app is not installed")
}
} else {
print("WatchConnectivity is not supported on this device")
}
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
#IBAction func startButton(sender: UIButton) {
startPlay()
}
#IBAction func stopButton(sender: UIButton) {
stopPlay()
}
#IBAction func restartButton(sender: UIButton) {
restartPlay()
}
//Receive messages from watch
func session(session: WCSession, didReceiveMessage message: [String : AnyObject], replyHandler: ([String : AnyObject]) -> Void) {
var replyValues = Dictionary<String, AnyObject>()
//let viewController = self.window!.rootViewController as! ViewController
switch message["command"] as! String {
case "start" :
startPlay()
replyValues["status"] = "Playing"
case "stop" :
stopPlay()
replyValues["status"] = "Stopped"
case "restart" :
restartPlay()
replyValues["status"] = "Stopped"
default:
break
}
replyHandler(replyValues)
}
//Counter Timer
func counting(timer:NSTimer) {
self.timerCount++
self.timerLabel.text = String(timerCount)
let requestValues = ["timer" : String(timerCount)]
let session = WCSession.defaultSession()
session.sendMessage(requestValues, replyHandler: nil, errorHandler: { error in print("error: \(error)")})
}
//Fonction Play
func startPlay() {
if timerRunning == false {
self.timer = NSTimer.scheduledTimerWithTimeInterval(1, target: self, selector: Selector("counting:"), userInfo: nil, repeats: true)
self.timerRunning = true
self.watchLabel.text = "START"
}
}
//Fonction Stop
func stopPlay() {
if timerRunning == true {
self.timer.invalidate()
self.timerRunning = false
self.watchLabel.text = "STOP"
}
}
//Fonction Restart
func restartPlay() {
self.timerCount = 0
self.timerLabel.text = "0";
let requestValues = ["timer" : "0"]
let session = WCSession.defaultSession()
session.sendMessage(requestValues, replyHandler: nil, errorHandler: { error in print("error: \(error)")})
}
}
InterfaceController.swift
import WatchKit
import Foundation
import WatchConnectivity
class InterfaceController: WKInterfaceController, WCSessionDelegate {
#IBOutlet var watchLabel: WKInterfaceLabel!
#IBOutlet var statusLabel: WKInterfaceLabel!
//Receiving message from iphone
func session(session: WCSession, didReceiveMessage message: [String : AnyObject]) {
self.watchLabel.setText(message["timer"]! as? String)
// self.statusLabel.setText(message["command"]! as? String)
}
override func awakeWithContext(context: AnyObject?) {
super.awakeWithContext(context)
// Configure interface objects here.
}
override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
if (WCSession.isSupported()) {
let session = WCSession.defaultSession()
session.delegate = self
session.activateSession()
}
}
override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}
#IBAction func startButtonWatch() {
if WCSession.defaultSession().reachable == true {
let requestValues = ["command" : "start"]
let session = WCSession.defaultSession()
session.sendMessage(requestValues, replyHandler: { reply in
self.statusLabel.setText(reply["status"] as? String)
}, errorHandler: { error in
print("error: \(error)")
})
}
}
#IBAction func stopButtonWatch() {
if WCSession.defaultSession().reachable == true {
let requestValues = ["command" : "stop"]
let session = WCSession.defaultSession()
session.sendMessage(requestValues, replyHandler: { reply in
self.statusLabel.setText(reply["status"] as? String)
}, errorHandler: { error in
print("error: \(error)")
})
}
}
#IBAction func restartButtonWatch() {
if WCSession.defaultSession().reachable == true {
let requestValues = ["command" : "restart"]
let session = WCSession.defaultSession()
session.sendMessage(requestValues, replyHandler: { reply in
self.statusLabel.setText(reply["status"] as? String)
}, errorHandler: { error in
print("error: \(error)")
})
}
}
}
You should use this:
func startPlay() {
if timerRunning == false {
//self.timer = NSTimer.scheduledTimerWithTimeInterval(1, target: self, selector: Selector("counting:"), userInfo: nil, repeats: true)
self.timer = NSTimer(timeInterval: 1, target: self, selector: "counting:", userInfo: nil, repeats: true)
NSRunLoop.mainRunLoop().addTimer(self.timer, forMode: NSRunLoopCommonModes)
self.timerRunning = true
self.watchLabel.text = "Start"
}
}
I cant explain you why we need use NSRunLoop explicitly. I stuck with same timer issue when develop an app with data transfer. Some answers you can find in google by query "nstimer run loop" or here.
And i pref use this for restart:
func restartPlay() {
self.timerCount = 0
self.timerLabel.text = "0";
stopPlay()
startPlay()
self.watchLabel.text = "Restarted"
}
Cheers.
This functional and optimized code :
//Fonction Play
func startPlay() {
if timerRunning == false {
self.mytimer = NSTimer(timeInterval: 1, target: self, selector: "counting:", userInfo: nil, repeats: true)
NSRunLoop.mainRunLoop().addTimer(self.mytimer, forMode: NSRunLoopCommonModes)
timerRunning = true
dispatch_async(dispatch_get_main_queue()) {
self.watchLabel.text = "PLAYING"
}
}
}
I'm building an app and I need a timer to run if the user sends the screen to the background, or if they put the phone in sleep and open it again. I need the timer to still be going.
I tried recording the time when I exit the and enter it again, subtracting the two and adding that to the running count, and it seems to work fine on the Xcode simulator but when I run it on my phone it doesn't work. Any ideas?
Here is the code for reference.
And the timer starts with a button I didn't include that part but it's just a simple IBAction that calls the timer.fire() function.
var time = 0.0
var timer = Timer()
var exitTime : Double = 0
var resumeTime : Double = 0
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(true)
exitTime = Date().timeIntervalSinceNow
}
override func awakeFromNib() {
super.awakeFromNib()
resumeTime = Date().timeIntervalSinceNow
time += (resumeTime-exitTime)
timer.fire()
}
func startTimer() {
if !isTimeRunning {
timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector:
#selector(WorkoutStartedViewController.action), userInfo: nil, repeats: true)
isTimeRunning = true
}
}
func pauseTimer() {
timer.invalidate()
isTimeRunning = false
}
#objc func action()
{
time += 0.1
timerLabel.text = String(time)
let floorCounter = Int(floor(time))
let hour = floorCounter/3600
let minute = (floorCounter % 3600)/60
var minuteString = "\(minute)"
if minute < 10 {
minuteString = "0\(minute)"
}
let second = (floorCounter % 3600) % 60
var secondString = "\(second)"
if second < 10 {
secondString = "0\(second)"
}
if time < 3600.0 {
timerLabel.text = "\(minuteString):\(secondString)"
} else {
timerLabel.text = "\(hour):\(minuteString):\(secondString)"
}
}
You do have the right idea but the first problem I see is that viewWillDissapear is only called when you leave a view controller to go to a new viewController - It is not called when the app leaves the view to enter background (home button press)
I believe the callback functions you are looking for are UIApplication.willResignActive (going to background) and UIApplication.didBecomeActive (app re-opened)
You can access these methods in the AppDelegate or you can set them up on a view controller heres a mix of your code and some changes to produce a working sample on one initial VC:
import UIKit
import CoreData
class ViewController: UIViewController {
#IBOutlet weak var timerLabel: UILabel!
var time = 0.0
var timer = Timer()
var exitTime : Date? // Change to Date
var resumeTime : Date? // Change to Date
var isTimeRunning = false
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
startTimer()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
NotificationCenter.default.addObserver(self,
selector: #selector(applicationDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil)
// Add willResign observer
NotificationCenter.default.addObserver(self,
selector: #selector(applicationWillResign),
name: UIApplication.willResignActiveNotification,
object: nil)
}
override func viewWillDisappear(_ animated: Bool) {
// Remove becomeActive observer
NotificationCenter.default.removeObserver(self,
name: UIApplication.didBecomeActiveNotification,
object: nil)
// Remove becomeActive observer
NotificationCenter.default.removeObserver(self,
name: UIApplication.willResignActiveNotification,
object: nil)
}
func startTimer() {
if !isTimeRunning {
timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector:
#selector(self.action), userInfo: nil, repeats: true)
isTimeRunning = true
}
}
#objc func action() {
time += 0.1
timerLabel.text = String(time)
let floorCounter = Int(floor(time))
let hour = floorCounter/3600
let minute = (floorCounter % 3600)/60
var minuteString = "\(minute)"
if minute < 10 {
minuteString = "0\(minute)"
}
let second = (floorCounter % 3600) % 60
var secondString = "\(second)"
if second < 10 {
secondString = "0\(second)"
}
if time < 3600.0 {
timerLabel.text = "\(minuteString):\(secondString)"
} else {
timerLabel.text = "\(hour):\(minuteString):\(secondString)"
}
}
#objc func applicationDidBecomeActive() {
// handle event
lookForActiveTimers()
}
func lookForActiveTimers() {
var timers = [NSManagedObject]()
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
return
}
let managedContext = appDelegate.persistentContainer.viewContext
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "Timers")
//3
do {
timers = try managedContext.fetch(fetchRequest)
print("timers: \(timers)")
var activeTimer: NSManagedObject?
for timer in timers {
if let active = timer.value(forKey: "active") as? Bool {
if active {
activeTimer = timer
}
}
}
if let activeTimer = activeTimer {
// Handle active timer (may need to go to a new view)
if let closeDate = activeTimer.value(forKey: "appCloseTime") as? Date {
if let alreadyTimed = activeTimer.value(forKey: "alreadyTimed") as? Double {
let now = Date()
let difference = now.timeIntervalSince(closeDate)
// Handle set up again here
print("App opened with a difference of \(difference) and already ran for a total of \(alreadyTimed) seconds before close")
time = alreadyTimed + difference
startTimer()
}
}
} else {
print("We dont have any active timers")
}
// Remove active timers because we reset them up
for timer in timers {
managedContext.delete(timer)
}
do {
print("deleted")
try managedContext.save() // <- remember to put this :)
} catch {
// Do something... fatalerror
}
} catch let error as NSError {
print("Could not fetch. \(error), \(error.userInfo)")
}
}
#objc func applicationWillResign() {
// handle event
saveActiveTimer()
}
func saveActiveTimer() {
if isTimeRunning {
// Create a new alarm object
guard let appDelegate =
UIApplication.shared.delegate as? AppDelegate else {
return
}
let context = appDelegate.persistentContainer.viewContext
if let entity = NSEntityDescription.entity(forEntityName: "Timers", in: context) {
let newTimer = NSManagedObject(entity: entity, insertInto: context)
newTimer.setValue(true, forKey: "active")
let now = Date()
newTimer.setValue(now, forKey: "appCloseTime")
newTimer.setValue(self.time, forKey: "alreadyTimed")
do {
try context.save()
print("object saved success")
} catch {
print("Failed saving")
}
}
}
}
}
EDIT - Here is the full tested and working code on xCode 11.3 and a physical device iOS 13.2 - You have to figure out how to start and stop the timer according to your buttons - but this example simply starts the timer when the app is first opened and never stops or resets it.
You can reproduce this by creating a new single-view xCode project and replacing the code in the first view controller that it creates for you with the code above. Then create a label to attach to the outlet timerLabel on the VC
Also make sure to enable CoreData in your project while creating your new project * Then set up the entities and attributes in the xcdatamodel file:
Hope this helps