I have played tennis for 25 years, and I always try to improve my game – whether by watching some coaching videos or trying new apps like Swing Vision. At the latest WWDC, I noticed a What's New in Core Motion session about introducing higher-frequency sensor data. The presenter was showing how such data can be used to analyze a golf swing. I thought that tennis could also be a perfect use case for leveraging the new sensors, so I set out to build an app that would collect raw motion data and share it as a file for further analysis.
In this part 1, I will show how to build such an app and share sample code. In the following parts, I will present data from a real tennis session and try to turn it into something meaningful.
Core Motion has been with us since the early days of iOS SDK, and it has always provided interesting capabilities that led to fun applications – such as the famous iBeer – or games where you could tilt your phone to control objects. On iPhones, Core Motion has also powered utility things like navigation and, eventually, Health and Activity. With the advent of the Apple Watch, motion data became even more useful in various workout apps where developers could use Apple-provided APIs or just raw data from the sensors.
Starting from WatchOS 10.0, there is a new class, CMBatchedSensorManager, that can give 800-Hz accelerometer and 200-Hz gyroscope updates, which are 8x and 2x, respectively, more frequent than in the previous versions.
Let's implement a simple Watch app that can collect the data from the new sensors and transmit them to the iPhone so that we can use this data later.
Full code is available on my GitHub: https://github.com/pavelshadrin/tennis-motion
First of all, we need a shared Codable
model that would be able to hold the sensor data. Codable
will let us encode and pass this data between the devices or store it on disk if needed:
struct AccelerometerSnapshot: Codable {
let timestamp: TimeInterval
let accelerationX: Double
let accelerationY: Double
let accelerationZ: Double
}
struct GyroscopeSnapshot: Codable {
let timestamp: TimeInterval
let rotationX: Double
let rotationY: Double
let rotationZ: Double
}
struct TennisMotionData: Codable {
let accelerometerSnapshots: [AccelerometerSnapshot]
let gyroscopeSnapshots: [GyroscopeSnapshot]
}
struct TennisDataChunk: Codable {
let date: Date
let data: TennisMotionData
// init from non-codable CM classes
}
On the Watch, apart from the UI to start and stop motion data collection, we need to implement several main things:
1. Set up CMBatchedSensorManager
This is the main driving force of our app.
let sensorManager = CMBatchedSensorManager()
2. Set up HealthKit
and workout-related logic
As a bonus, the app will record tennis workouts and save them in the Health app.
let healthStore = HKHealthStore()
var workoutSession: HKWorkoutSession?
var builder: HKLiveWorkoutBuilder?
// ...
// sensor data can be collected only during workout
let configuration = HKWorkoutConfiguration()
configuration.activityType = .tennis
configuration.locationType = .outdoor
do {
workoutSession = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
builder = workoutSession?.associatedWorkoutBuilder()
} catch {
return
}
builder?.dataSource = HKLiveWorkoutDataSource(
healthStore: healthStore,
workoutConfiguration: configuration
)
3. Set up WatchConnectivity
session to pass the recorded data to iPhone
let session = WCSession.default
if WCSession.isSupported() {
session.delegate = self
session.activate()
}
4. Connect all those pieces together
When the user starts data collection, we need to start the activity and begin data collection:
let startDate = Date()
workoutSession?.startActivity(with: startDate)
builder?.beginCollection(withStart: startDate) { (success, error) in
if success {
self.state = .active
}
Task {
do {
for try await data in CMBatchedSensorManager().accelerometerUpdates() {
let dataChunk = TennisDataChunk(date: Date(), accelerometerData: data, gyroscopeData: [])
sendToiPhone(dataChunk: dataChunk)
}
} catch let error as NSError {
print("\(error)")
}
}
Task {
do {
for try await data in CMBatchedSensorManager().deviceMotionUpdates() {
let dataChunk = TennisDataChunk(date: Date(), accelerometerData: [], gyroscopeData: data)
sendToiPhone(dataChunk: dataChunk)
}
} catch let error as NSError {
print("\(error)")
}
}
}
With this code above, every chunk of fresh data from the sensors will be immediately sent to the iPhone. If we don't send it right away, the data will become too large to fit into a message payload that can be sent quickly with session.sendMessage
. Otherwise, we would have to pass files. The simplest and quickest way is still to send a message with a Dictionary
:
private func sendToiPhone(dataChunk: TennisDataChunk) {
let dict: [String : Any] = ["data": dataChunk.encodeIt()]
session.sendMessage(dict, replyHandler: { reply in
print("Got reply from iPhone")
}, errorHandler: { error in
print("Failed to send data to iPhone: \(error)")
})
}
Then, on the receiving end, the main things to take care of are:
1. Receive and decode the motion data chunks to display them in a table view
extension ViewController: WCSessionDelegate {
// ...
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
guard let data: Data = message["data"] as? Data else { return }
let chunk = TennisDataChunk.decodeIt(data)
DispatchQueue.main.async {
self.tennisDataChunks.append(chunk)
}
}
}
2. Be able to export those chunks as a file – for example, a .csv and then share it using UIActivityViewController
to send it via AirDrop or save it to iCloud drive:
private func exportAccelerometerData() {
var result = tennisDataChunks.reduce("") { partialResult, chunk in
if !chunk.data.accelerometerSnapshots.isEmpty {
return partialResult + "\n\(chunk.createAcceletometerDataCSV())"
}
}
shareStringAsFile(string: result, filename: "tennis-acceleration-\(Date()).csv")
}
// same for gyroscope ^
// creating file and sharing it with share sheer
private func shareStringAsFile(string: String, filename: String) {
if string.isEmpty {
return
}
do {
let filename = "\(self.getDocumentsDirectory())/\(filename)"
let fileURL = URL(fileURLWithPath: filename)
try string.write(to: fileURL, atomically: true, encoding: .utf8)
let vc = UIActivityViewController(activityItems: [fileURL], applicationActivities: [])
self.present(vc, animated: true)
} catch {
print("cannot write file")
}
}
private func getDocumentsDirectory() -> String {
let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
let documentsDirectory = paths[0]
return documentsDirectory
}
Now, we can run both apps simultaneously and see the data popping up on the iPhone. This is what the final app looks like:
We've implemented two apps, one of which collects the motion data, encodes it, and sends the payload to the companion app. On the other side, we decode the payload, render it in the table view, and export it by writing the data into a .csv file and launching the system's share sheet. I hope this can serve as a good example of how to use the new APIs and leverage the frameworks that are commonly used in various workout and motion Apple Watch apps.
In the next article, I will show the motion data collected from my Apple Watch during a tennis session warm-up.